From 4dbda116c0bb9aa816b10a0c7af69aa3ad0afcbe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 13:34:34 +0400 Subject: [PATCH] =?UTF-8?q?fix(web-modules):=20=D0=BF=D0=BE=D0=B4=D1=85?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20409=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D1=82=20=D1=87=D1=83?= =?UTF-8?q?=D0=B6=D0=BE=D0=B9=20=D0=BE=D1=82=D1=81=D0=BB=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20=D0=BF=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=20(T3.I1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 1 + web/src/shell/activity/ActivityProvider.tsx | 15 +++++++++------ web/src/shell/activity/activity.test.tsx | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 21bf79a..37e53d6 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -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` | diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx index e10a180..8807f06 100644 --- a/web/src/shell/activity/ActivityProvider.tsx +++ b/web/src/shell/activity/ActivityProvider.tsx @@ -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]); diff --git a/web/src/shell/activity/activity.test.tsx b/web/src/shell/activity/activity.test.tsx index e16226c..347c57a 100644 --- a/web/src/shell/activity/activity.test.tsx +++ b/web/src/shell/activity/activity.test.tsx @@ -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") });