fix(web-modules): подхват после 409 заменяет чужой отслеживаемый пакет (T3.I1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 13:34:34 +04:00
parent 844ddbde26
commit 8b0d0bc005
3 changed files with 26 additions and 7 deletions

View File

@ -132,3 +132,4 @@ type HubModule = {
| 28 | 24.10.2026 | Task 3: wiki `refresh.start` приводит ответ `{batch_id}` к `{id}`; `track()` перехватывает отслеживание, если текущий пакет не «свой» (раньше — только если ничего не отслеживалось) | контракт ядра единый `{id}`; пакет нового проекта важнее чужого | отдельная форма ответа / прежняя проверка |
| 29 | 24.10.2026 | Task 3: сводка `useActivitySummary`: error > offline > busy > online | ошибка и офлайн агента важнее, чем «идёт пакет» | поменять порядок |
| 30 | 24.10.2026 | Task 3: временный `shell/activity/StatusBlock.tsx` (строки модулей и строка событий узкого экрана) вместо `SidebarStatus`/`TopbarNotice` модулей; Task 4 заменяет его; страница вики берёт действие из трекера, уведомление об ошибке постановки (`syncError`) убрано — ошибка видна событием модуля | оболочка должна работать и набор быть зелёным до Task 4 | вернуть Notice на странице вики |
| 31 | 24.10.2026 | Task 3, fix T3.I1: подхват после 409 (`pickup(true)`) заменяет отслеживаемый чужой пакет первым (самым старым) другим незавершённым пакетом модуля; если другого нет — чужой остаётся | детали 409 (`batch_id`) клиент не разбирает, а список без команд не различает типы | брать `batch_id` из `ApiError.detail` |

View File

@ -49,16 +49,19 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
}, []);
// M-3: подхватить незавершённый пакет модуля (самый старый), если ничего не отслеживается
const pickup = useCallback(() => {
// replaceForeign — после 409: идущий пакет важнее чужого отслеживаемого (T3.I1), чужой пропускается при выборе
const pickup = useCallback((replaceForeign = false) => {
if (!refresh) return;
if (scoped && projectRef.current === null) return;
if (activeRef.current || startingRef.current || picking.current) return;
const blocked = () => !!activeRef.current && !(replaceForeign && !activeRef.current.own);
if (blocked() || startingRef.current || picking.current) return;
picking.current = true;
const gen = generation.current;
(async () => {
const r = await api.get<{ batches: Batch[] }>("/batches", { module: name, limit: PICKUP_LIMIT, project: scoped ? projectRef.current : undefined });
if (gen !== generation.current || activeRef.current || startingRef.current) return;
const open = r.batches.filter((b) => b.status === "queued" || b.status === "running").sort((a, b) => a.id - b.id)[0];
if (gen !== generation.current || blocked() || startingRef.current) return;
const skip = replaceForeign ? activeRef.current?.id : undefined;
const open = r.batches.filter((b) => (b.status === "queued" || b.status === "running") && b.id !== skip).sort((a, b) => a.id - b.id)[0];
if (!open) return;
failures.current = 0;
setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false });
@ -80,7 +83,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
}
}
pickup();
const t = setInterval(pickup, PICKUP_MS);
const t = setInterval(() => pickup(), PICKUP_MS);
return () => clearInterval(t);
}, [project, scoped, pickup, setActiveNow]);
@ -182,7 +185,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
} finally {
startingRef.current = false;
setStarting(false);
if (conflict) pickup();
if (conflict) pickup(true);
}
}, [refresh, scoped, status, qc, report, pickup, setActiveNow]);

View File

@ -212,7 +212,7 @@ describe("ActivityProvider", () => {
expect(getToken()).toBe("new-tok");
});
it("T4.1: упавшее перечитывание снимка после своего пакета не гасит подхват следующей независимой загрузки", async () => {
it("T4.1: упавшее перечитывание снимка после своего пакета не мешает подхвату следующего пакета через 60 с", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
let boardFails = false, listed: unknown[] = [];
const h = hub({
@ -352,6 +352,21 @@ describe("ActivityProvider", () => {
expect(h.count("POST /wiki/sync")).toBe(1);
});
it("wiki 409 with foreign batch tracked switches to running own batch: чужой пакет не мешает подхвату идущего", async () => {
let conflict = false;
hub({
"POST /wiki/sync": () => { conflict = true; return json(409, { error: "sync_in_progress", detail: { batch_id: 9 } }); },
...listing((mod) => (mod === "wiki" ? (conflict ? [item(5, "wiki"), item(9, "wiki")] : [item(5, "wiki")]) : [])),
...byId({ 5: "ensure_root", 9: "wiki_sync" }, () => "running"),
});
const m = mount();
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 5 }));
expect(m.now().wiki.busy).toBe(false);
await act(() => m.now().wiki.start());
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
await waitFor(() => expect(m.now().wiki.busy).toBe(true));
});
it("wiki: «Обновить вики» шлёт проект и автора viewer; пакет — из batch_id ответа", async () => {
let body: unknown;
hub({ "POST /wiki/sync": (_u, b) => { body = b; return json(201, { batch_id: 11 }); }, ...byId({ 11: "wiki_sync" }, () => "running") });