Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
29 changes: 27 additions & 2 deletions backend/ipdb/_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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 ---
Expand Down
40 changes: 40 additions & 0 deletions backend/tests/test_ops_rounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/失败面)────────────────────────────────────


Expand Down
5 changes: 4 additions & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ export async function fetchEvalModel(): Promise<EvalModel | null> {
// /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<string, string[]> } }
| { 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 修约后含参数摘要与完成行)
Expand Down Expand Up @@ -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<string, string[]>;
}

export async function getTasks(): Promise<TasksSnapshot> {
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/pages/OpsCockpit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const [prevRunning, setPrevRunning] = useState<boolean | null>(null);
if (prevRunning !== running) {
setPrevRunning(running);
setElapsedMs(0);
if (running) setExpanded(false);
}
useEffect(() => {
if (!running) { startedRef.current = null; return; }
Expand All @@ -130,7 +135,6 @@ export function OpsCockpit({ onGoRulings, onUnauthorized }: {
}, [running]);

// 可折叠全量日志:展开时自动滚底(新行落底即视)
const [expanded, setExpanded] = useState(false);
const logRef = useRef<HTMLPreElement>(null);
useEffect(() => {
const el = logRef.current;
Expand Down Expand Up @@ -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 修):展开/收起在仅一行时视觉无差,
计数 = 「还有多少行」的显性可供性 */}
<span className="ml-1.5 font-mono text-zinc-500">{logLen}</span>
</button>
)}
{expanded && (
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/pages/__tests__/OpsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/tasks/TaskProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,33 @@
// 若也置位,resync 快照将几乎总被丢弃(SSE 溢出丢掉 done 事件时,UI
// 中的 batch 会永远卡在 running)。

// 快照日志尾播种(2026-10-26 修,backend snapshot "logs" 面):中途开页/
// SSE 重连恢复订阅前已发的 task_log 行。合并律 = 尾更长才替换:同 task
// 的 append-only 流,后端尾 ⊇ 订阅前收到的行前缀;更短的旧快照(fetch/
// SSE 竞态)不得缩水已收行。尽力而为面,不追加合并防重。
const seedLogs = (incoming?: Record<string, string[]>) => {
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")) {
Expand Down Expand Up @@ -82,6 +103,7 @@
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。
Expand All @@ -106,7 +128,7 @@
() => setConnection("reconnecting"),
);
return () => { alive = false; unsub(); };
}, []);

Check warning on line 131 in frontend/src/tasks/TaskProvider.tsx

View workflow job for this annotation

GitHub Actions / frontend

React Hook useEffect has missing dependencies: 'applyEvent' and 'onUnauthorized'. Either include them or remove the dependency array. If 'onUnauthorized' changes too often, find the parent component that defines it and wrap that definition in useCallback

const value: TasksCtxValue = {
tasks, batch, connection, logs,
Expand Down
Loading
Loading