diff --git a/CHANGELOG.md b/CHANGELOG.md index 496087e7..017141f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,9 @@ ### 修复 Fixed +- **驾驶舱「展开全量日志」中途开页点击无感修复(2026-10-26,单写者直修)**:根因=task_log 事件只发不留——用户在轮中开页时订阅前已发的 stage 行全丢,日志面只剩订阅后 1 行,展开/收起视觉无差;修复三层:①后端 UpdateManager 增 ops stderr 日志尾(`_OPS_LOG_KEEP=200` deque,与前端 LOG_KEEP_LINES 同窗;start 建、下一轮 start 清扫、终态后仍随快照出——轮后开页也能播上一轮尾),snapshot() 增 `logs` 面(键=task_id,经 TasksSnapshotOut extra="allow" 透传);未知 task_id 照发事件不建尾(恢复轮替身不破);②前端 TaskProvider seedLogs 快照播种(SSE snapshot 事件+resync fetch 两口同接),合并律=尾更长才替换——同 task 的 append-only 流里更短的旧快照(fetch/SSE 竞态)不得缩水已收行;③驾驶舱 UX:展开钮带行数计数(单行时展开/收起也有可见差),新一轮起跑上升沿复位展开态(全新日志面不跨轮携带)。门:backend test_ops_rounds+test_tasks+pruning 90/0(新三例:尾随快照出+终态留+下轮清扫/205 行窗 200/未知 tid 只发不建),frontend vitest 437/437(新三例:快照播种+播种后追加/更短旧快照不缩水/计数+复位)+tsc+build 绿 + - **Cockpit "show full log" mid-round open click-deadness fix (2026-10-26, single-writer direct fix)**: root cause = task_log events are fire-and-forget — a page opened mid-round loses every stage line emitted before subscribe, leaving a 1-line log face where expand/collapse looks identical; fix in three layers: ① backend UpdateManager gains an ops stderr tail (`_OPS_LOG_KEEP=200` deque, same window as the frontend LOG_KEEP_LINES; created at start_ops_round, swept by the next round's start, retained past terminal state so a post-round page open seeds the previous round's tail too), snapshot() gains a `logs` face (key=task_id, flowing through TasksSnapshotOut extra="allow"); unknown task_id still emits without a tail (recovery-round stand-ins don't break); ② frontend TaskProvider seedLogs seeds from the snapshot (both the SSE snapshot event and the resync fetch), merge rule = replace only if the tail is longer — a shorter stale snapshot (fetch/SSE race) never shrinks already-received lines on the same append-only stream; ③ cockpit UX: the expand button carries a line count (a visible difference even with a single line), and a new round's rising edge resets the expanded state (a fresh log face never carries over). Gates: backend test_ops_rounds + test_tasks + pruning 90/0 (three new: tail in snapshot + survives terminal + swept next round / 205 lines capped at 200 / unknown tid emits without tail), frontend vitest 437/437 (three new: snapshot seeding + post-seed append / shorter stale snapshot never shrinks / count + reset) + tsc + build green + - **ops 轮次线程兜底 + spawn env 白名单(hotfix #196,arch 评审 C1/C4)**:①C1——run_round/_run_recovery_round 及 spawn_round Popen 后段无兜底,释放链上任一未捕获异常(磁盘满 OSError 等)→轮次线程死→ops task 永卡 running→_ops_active 永不清→全部源 worker 车道停摆至容器重启;修=_settle_crash 统一收口(失败原因 ops_log 照实记、finish_ops_round 落 failed/infra 终态各自 try/except 不撕车道、台账不硬写=崩溃轮无轮可记)+两入口整链 try/except Exception+spawn 后段 BaseException 兜底(孤儿 agent 进程 terminate 后 re-raise);验收=OSError 注入红测在案(test_run_round_crash_settles_task_and_frees_lane/test_recovery_round_crash_settles_task:task 终态+车道可再占+双红转绿实证);②C4——spawn env 旧形 {**os.environ, **_round_env()} 全量继承把 ADMIN_PASSWORD/JWT_SECRET/webhook 等仓外密钥全传子进程,违 least-privilege;修=_spawn_env() 显式白名单(契约单源=ops/agent/agent.mjs SPAWN_CONTRACT_ENV:OPS_*/PI_* 前缀名+精确名 GH_TOKEN/JINA_API_KEY/ZAI_CODING_CN_API_KEY/DOCS_ROOT/PRECEDENTS_DIR/LEDGER_FILE/IP_RADAR_ALERT_URLS,叠加运行必需 PATH/HOME/TMPDIR/IP_RADAR_DATA_DIR 后 overlay _round_env 桥注入),spawn_round/_run_recovery_round/_spawn_resume 回落面三处换用;IP_RADAR_ALERT_URLS = #184 finding webhook 契约员保留透传(agent 侧自读,与 backend _alerts.push 双消费面;伞票例举排除与契约冲突,按「以契约为准」裁定);auth.json/web-search.json 写入后 chmod 0600 钉住契约;契约面红测在案(假密钥断不出现+键集恰为白名单面硬编码对拍,测试面独立真源防套套逻辑;e2e 真跑 node 实证);③附带——假 agent 测试驱动面(FAKE_MODE/FAKE_OUT/FAKE_PKG)从 env 继承改同目录 cfg 文件(白名单前置解耦,7 调用点换 helper _fake_agent_set);test_ops_rounds 两处 _write_state 冻结日期钟面相对化(updated_at=now,10-25 起两测必红的存量时间炸弹) - **Ops round-thread safety net + spawn env allowlist (hotfix #196, arch-review C1/C4)**: ① C1 — run_round/_run_recovery_round and the post-Popen stretch of spawn_round had no backstop: any uncaught exception on the release chain (e.g. disk-full OSError) killed the round thread, leaving the ops task stuck in running forever, _ops_active never cleared, and every source worker lane stalled until a container restart; fix = a unified _settle_crash closer (failure reason logged verbatim via ops_log, finish_ops_round lands a failed/infra terminal state with per-step try/except so the lane is never torn) + whole-chain try/except Exception at both entries + a BaseException backstop after Popen (an orphaned agent process is terminated before the re-raise); acceptance = the OSError-injection red tests on record (test_run_round_crash_settles_task_and_frees_lane / test_recovery_round_crash_settles_task: terminal task state, the lane re-occupiable, red→green demonstrated); ② C4 — the old spawn env {**os.environ, **_round_env()} inherited wholesale, handing ADMIN_PASSWORD/JWT_SECRET/webhook and other out-of-repo secrets to the child process, violating least-privilege; fix = an explicit _spawn_env() allowlist (single source of truth = ops/agent/agent.mjs SPAWN_CONTRACT_ENV: OPS_*/PI_* prefixed names + exact names GH_TOKEN/JINA_API_KEY/ZAI_CODING_CN_API_KEY/DOCS_ROOT/PRECEDENTS_DIR/LEDGER_FILE/IP_RADAR_ALERT_URLS, over the runtime-necessary PATH/HOME/TMPDIR/IP_RADAR_DATA_DIR, then overlaid with the _round_env bridge injections), swapped into spawn_round/_run_recovery_round and the _spawn_resume fallback; IP_RADAR_ALERT_URLS stays as the #184 finding-webhook contract member (agent-side self-read, dual-consumption face with backend _alerts.push; the umbrella ticket listed it for exclusion, conflicting with the contract — ruled "contract wins"); auth.json/web-search.json get an explicit chmod 0600 to pin the contract; contract-face red tests on record (planted fake secrets asserted absent + the key set exactly matching a hardcoded independent-truth allowlist — no tautology against the module under test; e2e runs the real node child to prove it); ③ incidental — the fake-agent test drive surface (FAKE_MODE/FAKE_OUT/FAKE_PKG) moved from env inheritance to a same-directory cfg file (decoupling required before the allowlist lands; 7 call sites moved to the _fake_agent_set helper), and two frozen-date _write_state call sites in test_ops_rounds made clock-relative (updated_at=now — a stock time bomb that would have turned both tests red from 10-25 on) diff --git a/backend/ipdb/_tasks.py b/backend/ipdb/_tasks.py index 49dab566..c4590a8b 100644 --- a/backend/ipdb/_tasks.py +++ b/backend/ipdb/_tasks.py @@ -17,6 +17,11 @@ logger = logging.getLogger(__name__) +# ops 轮 stderr 日志尾窗(2026-10-26 修):= 前端 TaskProvider +# LOG_KEEP_LINES 同值——尾随快照出后前端同窗截留,两端窗口一致防 +# 播种面反复增缩。进度面非存档,证据本体在包/台账(哲学 1)。 +_OPS_LOG_KEEP = 200 + @dataclass class Task: @@ -103,6 +108,15 @@ def __init__(self, resolve_source: Callable, lock_for: Callable, # finish_ops_round 的 notify_all 放行。 self._ops_active: Optional[str] = None + # ops 轮 stderr 日志尾(2026-10-26 修,驾驶舱「展开全量日志」中途 + # 开页只见订阅后行的根因):task_log 即时流之外的有界留存,快照面 + # 携带(snapshot() "logs" 键,键=task_id)——中途开页/SSE 重连可 + # 播种订阅前已发行。窗=前端 TaskProvider LOG_KEEP_LINES 同值(200); + # 证据本体在包/台账,这里只是即时进度面(哲学 1)。生命周期同 + # _tasks 的 ops 面:start 建、下一轮 start 清扫(终态后仍随快照出, + # 供轮后开页播「上一轮」尾行)。 + self._ops_logs: dict[str, deque[str]] = {} + self._queue: deque[str] = deque() self._queue_cv = threading.Condition() @@ -214,7 +228,10 @@ def snapshot(self) -> dict: by_source[t.source_name] = t tasks = [t.to_dict() for t in by_source.values()] batch = self._batches[self._active_batch].to_dict() if self._active_batch else None - return {"tasks": tasks, "batch": batch} + # ops 日志尾随快照出(键=task_id;源任务无此面)——中途开页/ + # SSE 重连播种订阅前已发行(__init__ _ops_logs 注释)。 + logs = {tid: list(lines) for tid, lines in self._ops_logs.items()} + return {"tasks": tasks, "batch": batch, "logs": logs} # --- batch control (Task 4) / pause / cancel / bus: added in later tasks --- def enqueue_batch(self, source_names: list[str]) -> str: @@ -406,9 +423,11 @@ def start_ops_round(self, round_type: str): for tid in stale: self._tasks.pop(tid, None) self._prog.pop(tid, None) + self._ops_logs.pop(tid, None) task = Task(id=uuid.uuid4().hex[:12], source_name="ops", host=None, state="running") self._tasks[task.id] = task + self._ops_logs[task.id] = deque(maxlen=_OPS_LOG_KEEP) self._ops_active = task.id self._emit({"type": "task", "task": task.to_dict()}) return task, None @@ -429,7 +448,13 @@ def finish_ops_round(self, task_id: str, state: str, error: Optional[str] = None self._queue_cv.notify_all() def ops_log(self, task_id: str, line: str) -> None: - """轮次 stderr 进度行([turn]/[tool],spawn 契约面)→ 事件总线 → SSE。""" + """轮次 stderr 进度行([turn]/[tool],spawn 契约面)→ 事件总线 → SSE; + 同步入有界日志尾(快照面,__init__ _ops_logs 注释)。未知 task_id + (恢复轮替身等)只发事件不建尾——尾随 start_ops_round 生命周期。""" + with self._lock: + tail = self._ops_logs.get(task_id) + if tail is not None: + tail.append(line) self._emit({"type": "task_log", "task_id": task_id, "line": line}) # --- internals --- diff --git a/backend/tests/test_ops_rounds.py b/backend/tests/test_ops_rounds.py index 1f00a030..e277f022 100644 --- a/backend/tests/test_ops_rounds.py +++ b/backend/tests/test_ops_rounds.py @@ -916,6 +916,46 @@ def test_ops_log_emits_task_log_event(): assert logs[0]["line"].startswith("[turn 1]") +def test_ops_log_tail_snapshot_and_sweep(): + """ops stderr 行留存日志尾:快照携带 logs 面;终态后仍在(轮后开页可播); + 下一轮 start 清扫(2026-10-26 修,驾驶舱中途开页只见订阅后行的根因)。""" + mgr = _make_manager([]) + t1, _ = mgr.start_ops_round("health") + for i in range(3): + mgr.ops_log(t1.id, f"line-{i}") + mgr.finish_ops_round(t1.id, "done") + snap = mgr.snapshot() + assert snap["logs"][t1.id] == ["line-0", "line-1", "line-2"], "终态任务尾仍随快照出" + t2, _ = mgr.start_ops_round("health") + assert t1.id not in mgr.snapshot()["logs"], "下一轮清扫上一轮日志尾" + mgr.finish_ops_round(t2.id, "done") + + +def test_ops_log_tail_caps_at_200(): + """日志尾窗 = _OPS_LOG_KEEP(200,与前端 LOG_KEEP_LINES 同值):更早行滑出。""" + mgr = _make_manager([]) + t1, _ = mgr.start_ops_round("health") + for i in range(205): + mgr.ops_log(t1.id, f"line-{i}") + mgr.finish_ops_round(t1.id, "done") + tail = mgr.snapshot()["logs"][t1.id] + assert len(tail) == 200 and tail[0] == "line-5" and tail[-1] == "line-204" + + +def test_ops_log_unknown_task_emits_without_tail(): + """未知 task_id(恢复轮替身等):事件仍发(即时流不因尾缺失丢行),尾面不建。""" + mgr = _make_manager([]) + events = [] + orig = mgr._emit + mgr._emit = events.append + try: + mgr.ops_log("nope", "orphan-line") + finally: + mgr._emit = orig + assert events and events[0]["type"] == "task_log" + assert "nope" not in mgr.snapshot()["logs"] + + # ── D. 路由层(鉴权/校验/202/409/失败面)──────────────────────────────────── diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 81fbf78c..d58ecee4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -434,7 +434,7 @@ export async function fetchEvalModel(): Promise { // /api/events SSE 消息负载(backend 任务流协议)。可选字段容缺:老事件/ // 边界事件可能缺 task/batch;消费方(TaskProvider.applyEvent)自行窄化。 export type TaskEvent = - | { type: "snapshot"; data?: { tasks: TaskState[]; batch: BatchState | null } } + | { type: "snapshot"; data?: { tasks: TaskState[]; batch: BatchState | null; logs?: Record } } | { type: "task"; task?: TaskState } | { type: "task_progress"; task_id?: string; received?: number; total?: number } | { type: "task_log"; task_id?: string; line?: string } // ops 轮 stderr [turn]/[tool]/[tool-result] 行(P2;票 #142 修约后含参数摘要与完成行) @@ -466,6 +466,9 @@ export interface BatchState { export interface TasksSnapshot { tasks: TaskState[]; batch: BatchState | null; + // ops 轮日志尾(2026-10-26 修,backend snapshot "logs" 面,键=task_id): + // 中途开页/SSE 重连播种订阅前已发的 task_log 行;源任务无此面。 + logs?: Record; } export async function getTasks(): Promise { diff --git a/frontend/src/pages/OpsCockpit.tsx b/frontend/src/pages/OpsCockpit.tsx index 64490fbf..f9f3bd41 100644 --- a/frontend/src/pages/OpsCockpit.tsx +++ b/frontend/src/pages/OpsCockpit.tsx @@ -109,15 +109,20 @@ export function OpsCockpit({ onGoRulings, onUnauthorized }: { .finally(() => setLedgerDone(true)); }, [hasSummary, onUnauthorized]); + // 可折叠全量日志(声明前置:下方 running 翻转 adjust 引用 setExpanded) + const [expanded, setExpanded] = useState(false); + // 已耗时:任务面无创建时间戳(桥零改),自本页首见 running 起计(ref 写读均限 // effect/回调,Date.now 不入 render);每秒走字。running 翻转时归零显 00:00 - // (纯 state 的 render-time adjust),真值由首个 tick 接管。 + // (纯 state 的 render-time adjust),真值由首个 tick 接管。上升沿顺带收起 + // 展开态:新一轮 = 全新日志面,展开不跨轮携带(2026-10-26 修)。 const startedRef = useRef(null); const [elapsedMs, setElapsedMs] = useState(0); const [prevRunning, setPrevRunning] = useState(null); if (prevRunning !== running) { setPrevRunning(running); setElapsedMs(0); + if (running) setExpanded(false); } useEffect(() => { if (!running) { startedRef.current = null; return; } @@ -130,7 +135,6 @@ export function OpsCockpit({ onGoRulings, onUnauthorized }: { }, [running]); // 可折叠全量日志:展开时自动滚底(新行落底即视) - const [expanded, setExpanded] = useState(false); const logRef = useRef(null); useEffect(() => { const el = logRef.current; @@ -200,6 +204,9 @@ export function OpsCockpit({ onGoRulings, onUnauthorized }: { className="mt-1 rounded-md border border-zinc-700 px-3 py-1 text-[11px] text-zinc-300 transition-colors hover:border-emerald-500/50 hover:text-emerald-400" > {expanded ? t("ops.cockpit.logHide") : t("ops.cockpit.logShow")} + {/* 行数入钮(2026-10-26 修):展开/收起在仅一行时视觉无差, + 计数 = 「还有多少行」的显性可供性 */} + {logLen} )} {expanded && ( diff --git a/frontend/src/pages/__tests__/OpsPage.test.tsx b/frontend/src/pages/__tests__/OpsPage.test.tsx index 2ffa158e..518ee35d 100644 --- a/frontend/src/pages/__tests__/OpsPage.test.tsx +++ b/frontend/src/pages/__tests__/OpsPage.test.tsx @@ -363,6 +363,33 @@ describe("OpsPage — 驾驶舱卡(票 #142)", () => { expect(screen.queryByText("[turn 1] stopReason=toolUse usage(in=100 out=20)")).toBeNull(); }); + it("在飞:展开钮带行数计数(单行时展开/收起也有可见差,2026-10-26 修)", () => { + renderOps({ + tasks: [opsTask("running")], + logs: { t1: ["[stage] TRIAGE start", "[stage] TRIAGE done gate=PASS"] }, + }); + const btn = screen.getByRole("button", { name: /show full log/i }); + expect(btn.textContent).toMatch(/2/); // 计数 = 尾里还有多少行 + fireEvent.click(btn); + expect(screen.getByRole("button", { name: /collapse log/i }).textContent).toMatch(/2/); + }); + + it("新一轮起跑:展开态复位收起(全新日志面不跨轮携带,2026-10-26 修)", () => { + const { rerenderOps } = renderOps({ + tasks: [opsTask("running")], + logs: { t1: ["[turn 1] l1", "[tool] l2"] }, + }); + fireEvent.click(screen.getByRole("button", { name: /show full log/i })); + expect(screen.getByText("[turn 1] l1")).toBeTruthy(); // 展开在 + // 轮终(下降沿)→ 新轮起跑(上升沿 t2):展开复位,只显末行 + rerenderOps({ tasks: [opsTask("failed")], logs: {} }); + const t2 = { ...opsTask("running"), id: "t2" }; + rerenderOps({ tasks: [t2], logs: { t2: ["[stage] PROBE start"] } }); + expect(screen.getByRole("button", { name: /show full log/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /collapse log/i })).toBeNull(); + expect(screen.getByText("[stage] PROBE start")).toBeTruthy(); // 收起面=末行 + }); + it("轮结束(done,内存日志优先):摘要卡 = 包名 + usage/cost + 跳「档案·报告」", () => { const done = opsTask("done"); renderOps({ diff --git a/frontend/src/tasks/TaskProvider.tsx b/frontend/src/tasks/TaskProvider.tsx index a45b075e..9bb8a3a4 100644 --- a/frontend/src/tasks/TaskProvider.tsx +++ b/frontend/src/tasks/TaskProvider.tsx @@ -36,12 +36,33 @@ export function TaskProvider({ children, onUnauthorized }: { // 若也置位,resync 快照将几乎总被丢弃(SSE 溢出丢掉 done 事件时,UI // 中的 batch 会永远卡在 running)。 + // 快照日志尾播种(2026-10-26 修,backend snapshot "logs" 面):中途开页/ + // SSE 重连恢复订阅前已发的 task_log 行。合并律 = 尾更长才替换:同 task + // 的 append-only 流,后端尾 ⊇ 订阅前收到的行前缀;更短的旧快照(fetch/ + // SSE 竞态)不得缩水已收行。尽力而为面,不追加合并防重。 + const seedLogs = (incoming?: Record) => { + if (!incoming) return; + setLogs((prev) => { + let changed = false; + const next = { ...prev }; + for (const [tid, lines] of Object.entries(incoming)) { + if (!Array.isArray(lines) || lines.length === 0) continue; + if (!next[tid] || lines.length > next[tid].length) { + next[tid] = lines; + changed = true; + } + } + return changed ? next : prev; + }); + }; + const applyEvent = (e: TaskEvent) => { if (SAW_EVENTS.has(e.type)) sseSawRef.current = true; if (e.type === "snapshot" && e.data) { tasksRef.current = Object.fromEntries(e.data.tasks.map((t) => [t.id, t])); setTasks(Object.values(tasksRef.current)); setBatch(e.data.batch ?? null); + seedLogs(e.data.logs); } else if (e.type === "task" && e.task) { const prev = tasksRef.current[e.task.id]; if (prev && (e.task.state === "failed" || e.task.state === "cancelled")) { @@ -82,6 +103,7 @@ export function TaskProvider({ children, onUnauthorized }: { tasksRef.current = Object.fromEntries(snap.tasks.map((t) => [t.id, t])); setTasks(Object.values(tasksRef.current)); setBatch(snap.batch); + seedLogs(snap.logs); } catch (e) { // 会话中 401:踢回登录页(卸壳即断 SSE 重连);其余错误吞下 — // 之前这里是未处理 rejection。 diff --git a/frontend/src/tasks/__tests__/TaskProvider.ops.test.tsx b/frontend/src/tasks/__tests__/TaskProvider.ops.test.tsx index b0d8dd58..7ebd2cbf 100644 --- a/frontend/src/tasks/__tests__/TaskProvider.ops.test.tsx +++ b/frontend/src/tasks/__tests__/TaskProvider.ops.test.tsx @@ -94,4 +94,59 @@ describe("TaskProvider ops SSE face (running state + task_log)", () => { expect(text).not.toContain("line-0|"); expect(text.split("|")).toHaveLength(200); }); + + it("seeds log lines from a snapshot's logs face (mid-round page open, 2026-10-26 fix)", async () => { + const es = makeFakeEventSource(); + (globalThis as { EventSource?: unknown }).EventSource = es.FakeEventSource; + render(); + await screen.findByText("0"); + await act(async () => { + es.fireMessage({ + type: "snapshot", + data: { + tasks: [{ id: "t1", source: "ops", host: null, state: "running", error: null, batch_id: null }], + batch: null, + logs: { t1: ["[stage] PROBE start", "[stage] TRIAGE start"] }, + }, + }); + }); + expect(screen.getByTestId("logs").textContent) + .toBe("[stage] PROBE start|[stage] TRIAGE start"); + // 播种后后续 task_log 照常追加在尾之后(同一条 append-only 流) + await act(async () => { + es.fireMessage({ type: "task_log", task_id: "t1", line: "[stage] TRIAGE done gate=PASS" }); + }); + expect(screen.getByTestId("logs").textContent) + .toBe("[stage] PROBE start|[stage] TRIAGE start|[stage] TRIAGE done gate=PASS"); + }); + + it("snapshot seeding never shrinks already-received lines (fetch/SSE race)", async () => { + const es = makeFakeEventSource(); + (globalThis as { EventSource?: unknown }).EventSource = es.FakeEventSource; + render(); + await screen.findByText("0"); + await act(async () => { + es.fireMessage({ + type: "snapshot", + data: { + tasks: [{ id: "t1", source: "ops", host: null, state: "running", error: null, batch_id: null }], + batch: null, + logs: { t1: ["only-one-stale-line"] }, + }, + }); + es.fireMessage({ type: "task_log", task_id: "t1", line: "l0" }); + es.fireMessage({ type: "task_log", task_id: "t1", line: "l1" }); + es.fireMessage({ type: "task_log", task_id: "t1", line: "l2" }); + // 竞态回放:更短的旧快照尾再来 → 已收 3 行不缩水 + es.fireMessage({ + type: "snapshot", + data: { + tasks: [{ id: "t1", source: "ops", host: null, state: "running", error: null, batch_id: null }], + batch: null, + logs: { t1: ["stale-again"] }, + }, + }); + }); + expect(screen.getByTestId("logs").textContent).toBe("only-one-stale-line|l0|l1|l2"); + }); });