From dc73e9557930d89350795d63c0c0dfd7482b99f3 Mon Sep 17 00:00:00 2001 From: AlcAI-AnimeHaven Date: Tue, 23 Jun 2026 19:04:01 +0200 Subject: [PATCH 1/4] fix(webui): make the keyless file-backed monitor report live progress The keyless WebUI (open_dashboard / `arbor web`) renders a session from disk with no live RunState or EventBus. build_session_snapshot only filled the tree, counters and scores, so the page showed a frozen run: runtime stuck at 0s, token/cache counters at zero, a dead pipeline, an empty activity feed, and a blank improvement chart. Node modals also rendered a black (invisible) title and showed the full multi-line hypothesis instead of a headline, and sessions created from a linked git worktree disappeared when the dashboard was opened from the main checkout. - Add a timing sidecar (mcp/session_timing.py -> .coordinator/activity.json): the Node model intentionally stores no timestamps, so session_ops now records a run clock, per-node started/finished, and a capped event log on add/update/ prune/worktree_create/merge. The tree JSON stays byte-compatible. - session_source: derive elapsed_seconds, per-node runtime_seconds/ finished_elapsed, the pipeline phase, running-node "agents", and best_score_history (fixes the empty/flat improvement chart) from the sidecar. - Hide the Tokens and Cache cards in keyless mode (flagged via snapshot.keyless) rather than show misleading zeros - those counters belong to the host harness, not Arbor - and reflow the stat grid. - Anchor .arbor/sessions at the main working tree when driven from a linked git worktree (session_ops._stable_arbor_base, mirrored in export.resolve_session_dir) so session state survives branch/worktree switches. - Fix the black modal title (set color on the #overlay host, which sits outside the themed shell wrapper) and show a one-line headline (oneLine) for node titles, keeping the full hypothesis in the modal Details. Add tests for the sidecar-backed snapshot, the activity recording, and the stable-base behaviour. Co-Authored-By: Claude Opus 4.8 --- src/export.py | 12 +++ src/mcp/session_ops.py | 68 +++++++++++++- src/mcp/session_timing.py | 105 ++++++++++++++++++++++ src/webui/index.html | 38 +++++--- src/webui/session_source.py | 161 +++++++++++++++++++++++++++++++--- src/webui/snapshot.py | 4 + tests/test_mcp_session_ops.py | 22 +++++ tests/test_webui_session.py | 51 ++++++++++- 8 files changed, 431 insertions(+), 30 deletions(-) create mode 100644 src/mcp/session_timing.py diff --git a/src/export.py b/src/export.py index b27ee26..9aa20bb 100644 --- a/src/export.py +++ b/src/export.py @@ -52,6 +52,18 @@ def resolve_session_dir(session: Path, cwd: Path | None = None) -> Path: cwd / candidate, cwd / CONFIG_DIR_NAME / "sessions" / str(session), ]) + # When invoked from a linked git worktree, keyless sessions are anchored + # at the main working tree (see session_ops._stable_arbor_base), so look + # there too — otherwise a session created by the host agent would appear + # "missing" the moment the dashboard is launched from the main checkout. + try: + from .mcp.session_ops import _stable_arbor_base + + stable = _stable_arbor_base(cwd) + if stable != cwd: + candidates.append(stable / CONFIG_DIR_NAME / "sessions" / str(session)) + except Exception: + pass for path in candidates: if path.exists() and path.is_dir(): diff --git a/src/mcp/session_ops.py b/src/mcp/session_ops.py index d3ad75c..d1ef89e 100644 --- a/src/mcp/session_ops.py +++ b/src/mcp/session_ops.py @@ -45,6 +45,7 @@ from ..coordinator.idea_tree import IdeaTree, Node from ..report.generator import generate_report +from . import session_timing # Branches we will never merge *into* — a hard safety rail for the trunk. PROTECTED_BRANCHES = {"main", "master"} @@ -79,9 +80,51 @@ def _safe_run_name(run_name: str) -> str: return safe or "session" +def _stable_arbor_base(cwd: str | Path) -> Path: + """Anchor the ``.arbor`` root at the *main* working tree, not a linked one. + + Host coding agents frequently drive a run from inside a linked git worktree + (e.g. ``.claude/worktrees/``). Each worktree has its own working + directory, so a session created under ``/.arbor`` is invisible from + the main checkout and appears to *vanish* the moment the agent switches + branches/worktrees. To keep session state stable regardless of which worktree + or branch is active, we resolve the main worktree root (the parent of the + shared git common dir) and anchor ``.arbor`` there. + + Only linked worktrees are redirected: a normal single checkout (where the + current toplevel already *is* the main worktree) and any non-git directory + are returned unchanged, so existing behaviour is preserved. + """ + base = Path(cwd).resolve() + try: + rc_top, top = git(base, "rev-parse", "--show-toplevel") + if rc_top != 0 or not top: + return base + rc_common, common = git(base, "rev-parse", "--git-common-dir") + if rc_common != 0 or not common: + return base + common_path = Path(common) + if not common_path.is_absolute(): + common_path = (base / common_path).resolve() + main_root = common_path.parent.resolve() + toplevel = Path(top).resolve() + # Redirect only when we are inside a *linked* worktree (toplevel differs + # from the main worktree root); otherwise keep the caller's directory. + if main_root != toplevel and main_root.exists(): + return main_root + except Exception: # pragma: no cover - git absent / unexpected layout + return base + return base + + def session_dir(cwd: str | Path, run_name: str) -> Path: - """Return ``/.arbor/sessions/`` (the per-run session root).""" - return Path(cwd).resolve() / ".arbor" / "sessions" / _safe_run_name(run_name) + """Return ``/.arbor/sessions/`` (the per-run session root). + + ``base`` is normally *cwd*, but is redirected to the main working tree when + *cwd* is a linked git worktree (see :func:`_stable_arbor_base`) so the + session — and the WebUI watching it — survive branch/worktree switches. + """ + return _stable_arbor_base(cwd) / ".arbor" / "sessions" / _safe_run_name(run_name) def coordinator_dir(cwd: str | Path, run_name: str) -> Path: @@ -285,6 +328,9 @@ def tree_add_node( status=status, # type: ignore[arg-type] ) tree.add_node(node) + session_timing.record_event( + coordinator_dir(cwd, run_name), node_id, "proposed", label=hypothesis, + ) return {"node_id": node_id, "depth": node.depth, "parent_id": parent_id} @@ -299,6 +345,14 @@ def tree_update_node(cwd: str | Path, run_name: str, node_id: str, **fields: Any if tree.get_node(node_id) is None: raise ValueError(f"node {node_id!r} not found") tree.update_node(node_id, **updates) + new_status = updates.get("status") + if new_status: + node = tree.get_node(node_id) + session_timing.record_event( + coordinator_dir(cwd, run_name), node_id, str(new_status), + label=(node.code_ref if node else None), + score=updates.get("score", node.score if node else None), + ) return {"node_id": node_id, "updated": sorted(updates)} @@ -308,6 +362,9 @@ def tree_prune(cwd: str | Path, run_name: str, node_id: str, reason: str = "") - if tree.get_node(node_id) is None: raise ValueError(f"node {node_id!r} not found") tree.prune_node(node_id, reason) + session_timing.record_event( + coordinator_dir(cwd, run_name), node_id, "pruned", label=reason or None, + ) return {"node_id": node_id, "status": "pruned", "reason": reason} @@ -476,6 +533,9 @@ def _add(branch_name: str) -> tuple[int, str, Path]: raise RuntimeError(f"git worktree add failed: {out}") tree.update_node(node_id, status="running", code_ref=branch) + session_timing.record_event( + coordinator_dir(cwd, run_name), node_id, "running", label=branch, + ) return {"worktree": str(wt), "branch": branch, "node_id": node_id} @@ -612,6 +672,10 @@ def git_merge_branch( tree.meta["test_trunk_score"] = float(test_score) tree.update_node(node_id, status="merged", code_ref=source_branch) + session_timing.record_event( + coordinator_dir(cwd, run_name), node_id, "merged", + label=source_branch, score=test_score, + ) return { "merged": source_branch, "target": target, diff --git a/src/mcp/session_timing.py b/src/mcp/session_timing.py new file mode 100644 index 0000000..f972360 --- /dev/null +++ b/src/mcp/session_timing.py @@ -0,0 +1,105 @@ +"""Lightweight timing/activity sidecar for keyless Arbor sessions. + +The keyless MCP path (:mod:`arbor.mcp.session_ops`) persists the Idea Tree, but +the :class:`~arbor.coordinator.idea_tree.Node` model intentionally carries **no +timestamps** — so a file-backed WebUI has no clock, no per-node runtime, and no +"recent activity" to render. Rather than pollute the byte-compatible tree JSON, +this module maintains a *separate* sidecar (``.coordinator/activity.json``) that +records when nodes were proposed / started / finished, plus a capped, append-only +event log. The WebUI's :mod:`arbor.webui.session_source` reads it to fill +``elapsed_seconds``, per-node ``runtime_seconds``/``finished_elapsed``, the active +pipeline phase, and the recent-activity feed. + +It is best-effort telemetry: every write is wrapped so a sidecar failure can +never break the real tree mutation that triggered it. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +SIDECAR_NAME = "activity.json" + +# Cap the event log so a long run can't grow the sidecar unbounded; the WebUI +# only ever shows the most recent handful. +_MAX_EVENTS = 200 + +# Kinds that mark a node as having started running / finished — used to stamp the +# per-node started_at / finished_at timing. +_RUNNING_KINDS = {"running"} +_TERMINAL_KINDS = {"done", "merged", "pruned", "needs_retry", "failed"} + + +def _path(coord_dir: str | Path) -> Path: + return Path(coord_dir) / SIDECAR_NAME + + +def load(coord_dir: str | Path) -> dict[str, Any]: + """Load the sidecar, returning an empty shape on any missing/invalid file.""" + try: + data = json.loads(_path(coord_dir).read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _save(coord_dir: str | Path, data: dict[str, Any]) -> None: + path = _path(coord_dir) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) + + +def record_event( + coord_dir: str | Path, + node_id: str | None, + kind: str, + *, + label: str | None = None, + score: float | None = None, +) -> None: + """Append an activity event and stamp node timing. Never raises. + + ``kind`` is a coarse lifecycle label (``proposed``/``running``/``done``/ + ``merged``/``pruned``/``needs_retry``/``failed``). The first event ever + recorded sets ``session_started_at`` (the WebUI's run clock). ``running`` + stamps the node's ``started_at``; terminal kinds stamp ``finished_at``. + """ + try: + now = time.time() + data = load(coord_dir) + data.setdefault("session_started_at", now) + data["updated_at"] = now + + if node_id: + nodes = data.setdefault("nodes", {}) + timing = nodes.setdefault(node_id, {}) + timing.setdefault("created_at", now) + if kind in _RUNNING_KINDS: + # A retry re-enters "running": refresh started_at and clear the + # previous finish so runtime reflects the active attempt. + timing["started_at"] = now + timing.pop("finished_at", None) + elif kind in _TERMINAL_KINDS: + timing.setdefault("started_at", timing.get("created_at", now)) + timing["finished_at"] = now + + event: dict[str, Any] = {"ts": now, "kind": kind} + if node_id: + event["node_id"] = node_id + if label: + event["label"] = label + if score is not None: + event["score"] = score + events = data.setdefault("events", []) + events.append(event) + if len(events) > _MAX_EVENTS: + del events[: len(events) - _MAX_EVENTS] + + _save(coord_dir, data) + except Exception: # pragma: no cover - telemetry must never break a mutation + return diff --git a/src/webui/index.html b/src/webui/index.html index 37833b3..e160cae 100644 --- a/src/webui/index.html +++ b/src/webui/index.html @@ -108,8 +108,14 @@ : { turns: [], busy: false }, gate: (s.gate && typeof s.gate === "object") ? s.gate : null, paused: !!s.paused, interactive: !!s.interactive, + // keyless: file-backed monitor — token/cache accounting is owned by the host + // harness and not observable, so the browser hides those panels. + keyless: !!s.keyless, }; } +/* First line of a (possibly multi-line) hypothesis — node titles should read as + * a one-liner of what's being tried, not the full four-line rationale. */ +function oneLine(t){ const s=(t==null?'':String(t)); for(const ln of s.split(/\r?\n/)){ const v=ln.trim(); if(v) return v; } return ''; } /* ───────────────────────── UI state ───────────────────────── */ const ST = { @@ -222,7 +228,7 @@ const v = n._root ? { tc:'var(--tx)', vc:'var(--tx2)', bg:'var(--card2)', bd:'var(--line2)', sh:'none', isBest:false } : visual(n); const label = n._root ? 'ref' : (n.branch || id); const valTxt = n._root ? fmtScore(n.score) : imprStr(n.score); - return { id, root:!!n._root, name: n._root?'Baseline':(n.hypothesis||id), idLabel:label, val:valTxt, + return { id, root:!!n._root, name: n._root?'Baseline':(oneLine(n.hypothesis)||id), idLabel:label, val:valTxt, valColor:v.vc, titleColor:v.tc, bg:v.bg, bd:v.bd, shadow:v.sh, isBest:v.isBest, cx:pp.cx, cy:pp.cy, w:NW, h:NH, node:n }; }); @@ -315,7 +321,7 @@ -
`; +
`; renderContent(); renderOverlay(); _gateKey=null; renderGate(); @@ -417,14 +423,20 @@

Started ${fmtAgo(SNAP.elapsed_seconds)} -
- ${statCard('Improvement', imprStr(best), 'vs baseline '+baseStr, 'var(--accent)', true)} - ${statCard('Best Score', fmtScore(best), 'baseline '+baseStr, 'var(--accent2)', false)} - ${statCard('Branches', String(bc.total), `active ${bc.active} · closed ${bc.closed}`, null, false)} - ${statCard('Runtime', fmtRun(SNAP.elapsed_seconds), 'total elapsed', null, false, 'st-runtime')} - ${statCard('Tokens', fmtTok(SNAP.tokens.input)+' / '+fmtTok(SNAP.tokens.output), 'in / out', 'var(--amber)', false, 'st-tokens')} - ${statCard('Cache', cacheHitStr(), fmtTok(SNAP.cache.read)+' read', 'var(--blue)', false, 'st-cache')} -
+ ${(()=>{ const cards=[ + statCard('Improvement', imprStr(best), 'vs baseline '+baseStr, 'var(--accent)', true), + statCard('Best Score', fmtScore(best), 'baseline '+baseStr, 'var(--accent2)', false), + statCard('Branches', String(bc.total), `active ${bc.active} · closed ${bc.closed}`, null, false), + statCard('Runtime', fmtRun(SNAP.elapsed_seconds), 'total elapsed', null, false, 'st-runtime'), + ]; + // Tokens & cache are reported by the LLM harness, not Arbor — keyless + // (file-backed) monitors can't observe them, so omit the cards entirely + // rather than show static zeros. + if(!SNAP.keyless){ cards.push( + statCard('Tokens', fmtTok(SNAP.tokens.input)+' / '+fmtTok(SNAP.tokens.output), 'in / out', 'var(--amber)', false, 'st-tokens'), + statCard('Cache', cacheHitStr(), fmtTok(SNAP.cache.read)+' read', 'var(--blue)', false, 'st-cache')); } + return `
${cards.join('')}
`; + })()}
${chartsInnerHTML()}
@@ -633,7 +645,7 @@

${ic('git-branch',22)}
${esc(n.branch||n.node_id)}${meta.label}parent ${esc(parent)}
-

${esc(n.hypothesis||n.node_id)}

+

${esc(oneLine(n.hypothesis)||n.node_id)}

diff --git a/src/webui/session_source.py b/src/webui/session_source.py index db0dfbf..f73b59f 100644 --- a/src/webui/session_source.py +++ b/src/webui/session_source.py @@ -9,6 +9,13 @@ (:func:`arbor.webui.snapshot.empty_state_dict`), so the existing page renders progress with no changes. +The Idea Tree JSON has no timing, so a sibling *activity sidecar* +(``.coordinator/activity.json``, written by :mod:`arbor.mcp.session_timing`) +supplies the run clock, per-node runtime, the active pipeline phase, and the +recent-activity feed. Token/cache accounting is owned by the host harness and is +*not* observable here — the snapshot is flagged ``keyless`` so the browser hides +those panels rather than render misleading zeros. + It is read-only and polled (the server's heartbeat calls it every ~1.5s), so the browser reflects tree updates as the host agent makes them. """ @@ -16,11 +23,18 @@ from __future__ import annotations import json +import time from pathlib import Path from typing import Any +from ..mcp import session_timing from .snapshot import empty_state_dict +# Node statuses that count as a running executor (drives phase + the agents map). +_RUNNING = "running" +# Statuses that contribute a point to the improvement trajectory. +_SCORED_STATUSES = ("done", "merged") + def _load_json(path: Path) -> dict[str, Any]: """Load a JSON object, returning ``{}`` on any missing/invalid file.""" @@ -31,12 +45,37 @@ def _load_json(path: Path) -> dict[str, Any]: return {} -def _node_to_tree_entry(node: dict[str, Any]) -> dict[str, Any]: +def _first_line(text: str, limit: int = 120) -> str: + """First non-empty line of *text*, truncated — used for compact previews.""" + for line in str(text).splitlines(): + line = line.strip() + if line: + return line if len(line) <= limit else line[: limit - 1] + "…" + return "" + + +def _node_to_tree_entry( + node: dict[str, Any], + timing: dict[str, Any], + now: float, + run_started: float | None, +) -> dict[str, Any]: """Map a stored IdeaTree node onto the browser's tree-entry shape. - Mirrors the live ``snapshot._idea_to_dict`` contract. Timing fields - (runtime/finished_elapsed) are unknown without a live run, so they are None. + Mirrors the live ``snapshot._idea_to_dict`` contract. ``runtime_seconds`` and + ``finished_elapsed`` come from the activity sidecar when available (else + None, exactly as before). """ + t = timing.get(node.get("id"), {}) if isinstance(timing, dict) else {} + started = t.get("started_at") + finished = t.get("finished_at") + runtime = None + if isinstance(started, (int, float)): + end = finished if isinstance(finished, (int, float)) else now + runtime = round(end - started, 1) + finished_elapsed = None + if isinstance(finished, (int, float)) and run_started is not None: + finished_elapsed = round(finished - run_started, 1) return { "node_id": node.get("id"), "hypothesis": node.get("hypothesis", ""), @@ -44,8 +83,8 @@ def _node_to_tree_entry(node: dict[str, Any]) -> dict[str, Any]: "score": node.get("score"), "branch": node.get("code_ref"), "parent_id": node.get("parent_id"), - "runtime_seconds": None, - "finished_elapsed": None, + "runtime_seconds": runtime, + "finished_elapsed": finished_elapsed, "pruned_reason": node.get("pruned_reason"), "insight": node.get("insight") or None, } @@ -63,24 +102,81 @@ def _best_score(meta: dict[str, Any], nodes: list[dict[str, Any]]) -> float | No scored = [ float(n["score"]) for n in nodes - if isinstance(n.get("score"), (int, float)) and n.get("status") in ("done", "merged") + if isinstance(n.get("score"), (int, float)) and n.get("status") in _SCORED_STATUSES ] if not scored: return None return min(scored) if meta.get("metric_direction") == "minimize" else max(scored) +def _best_score_history( + nodes: list[dict[str, Any]], + timing: dict[str, Any], + direction: str, +) -> list[float]: + """Running-best score over completed nodes, ordered by finish time. + + This is the series the browser's "Improvement Trajectory" chart plots; with + no live ``RunState`` it would otherwise be empty (a flat/degenerate line). + """ + points: list[tuple[float, float]] = [] + for n in nodes: + if n.get("status") not in _SCORED_STATUSES: + continue + score = n.get("score") + if not isinstance(score, (int, float)): + continue + fin = timing.get(n.get("id"), {}).get("finished_at") if isinstance(timing, dict) else None + # Nodes without a recorded finish still count — sort them last but stable. + points.append((float(fin) if isinstance(fin, (int, float)) else float("inf"), float(score))) + points.sort(key=lambda p: p[0]) + history: list[float] = [] + best: float | None = None + for _, score in points: + if best is None: + best = score + else: + best = min(best, score) if direction == "minimize" else max(best, score) + history.append(round(best, 6)) + return history + + +def _derive_phase(nodes: list[dict[str, Any]], events: list[dict[str, Any]]) -> str: + """Pick a coarse pipeline phase the browser can light up. + + Keyless runs have no coordinator loop, so we infer a plausible current stage + from tree state + the latest activity event: + + * a node is running → ``benchmark`` (the timing step is active) + * last event proposed → ``ideate`` + * last event terminal → ``decide`` + * otherwise → ``observe`` (idle with a tree) / ``monitoring`` + """ + if any(n.get("status") == _RUNNING for n in nodes): + return "benchmark" + if not events: + # No activity sidecar (legacy session): stay neutral rather than guess. + return "monitoring" + last = events[-1].get("kind") + if last in ("done", "merged", "pruned", "needs_retry", "failed"): + return "decide" + if last == "proposed": + return "ideate" + return "observe" + + def build_session_snapshot(session_dir: Path, run_name: str | None = None) -> dict[str, Any]: """Return a browser snapshot dict for the session at *session_dir*. - Reads ``.coordinator/idea_tree.json`` (nodes + meta) and the optional - ``run_info.json``. Always returns a valid snapshot — missing or partial - sessions degrade gracefully to the empty shape. + Reads ``.coordinator/idea_tree.json`` (nodes + meta), the activity sidecar, + and the optional ``run_info.json``. Always returns a valid snapshot — missing + or partial sessions degrade gracefully to the empty shape. """ session_dir = Path(session_dir) + coord_dir = session_dir / ".coordinator" state = empty_state_dict() state["run_name"] = run_name or session_dir.name - state["phase"] = "monitoring" + state["keyless"] = True run_info = _load_json(session_dir / "run_info.json") if run_info: @@ -88,7 +184,7 @@ def build_session_snapshot(session_dir: Path, run_name: str | None = None) -> di state["cwd"] = run_info.get("cwd", "") state["model"] = run_info.get("model", "—") - tree_json = _load_json(session_dir / ".coordinator" / "idea_tree.json") + tree_json = _load_json(coord_dir / "idea_tree.json") nodes_map: dict[str, Any] = tree_json.get("nodes", {}) if isinstance(tree_json.get("nodes"), dict) else {} meta: dict[str, Any] = tree_json.get("meta", {}) if isinstance(tree_json.get("meta"), dict) else {} root_id = tree_json.get("root_id", "ROOT") @@ -101,6 +197,21 @@ def build_session_snapshot(session_dir: Path, run_name: str | None = None) -> di root = nodes_map.get(root_id, {}) state["task"] = root.get("hypothesis", "") if isinstance(root, dict) else "" + # ── Timing sidecar: run clock, per-node runtime, recent activity ────────── + sidecar = session_timing.load(coord_dir) + timing = sidecar.get("nodes", {}) if isinstance(sidecar.get("nodes"), dict) else {} + events = sidecar.get("events", []) if isinstance(sidecar.get("events"), list) else [] + now = time.time() + run_started = sidecar.get("session_started_at") + if not isinstance(run_started, (int, float)): + # Fallback for sessions created before timing was tracked: approximate + # the clock from the tree file's mtime. + try: + run_started = (coord_dir / "idea_tree.json").stat().st_mtime + except OSError: + run_started = None + state["elapsed_seconds"] = round(now - run_started, 1) if isinstance(run_started, (int, float)) else 0 + def _count(status: str) -> int: return sum(1 for n in nodes if n.get("status") == status) @@ -109,10 +220,32 @@ def _count(status: str) -> int: "done": _count("done"), "pruned": _count("pruned"), "merged": _count("merged"), - "running": _count("running"), + "running": _count(_RUNNING), } state["baseline_score"] = meta.get("baseline_score") - state["metric_direction"] = meta.get("metric_direction", "maximize") + direction = meta.get("metric_direction", "maximize") + state["metric_direction"] = direction state["best_score"] = _best_score(meta, nodes) - state["tree"] = [_node_to_tree_entry(n) for n in nodes] + state["best_score_history"] = _best_score_history(nodes, timing, direction) + state["tree"] = [_node_to_tree_entry(n, timing, now, run_started) for n in nodes] + state["phase"] = _derive_phase(nodes, events) + + # Expose running nodes as "agents" so the browser's benchmarking indicator, + # progress estimate, and pipeline fallback have something to track. + agents: dict[str, Any] = {} + for n in nodes: + if n.get("status") != _RUNNING: + continue + nid = n.get("id") + started = timing.get(nid, {}).get("started_at") if isinstance(timing, dict) else None + label = n.get("code_ref") or nid or "executor" + agents[label] = { + "tool": "executor", + "node_id": nid, + "preview": _first_line(n.get("hypothesis", "")), + "ok": None, + "elapsed": round(now - started, 1) if isinstance(started, (int, float)) else None, + "duration": None, + } + state["agents"] = agents return state diff --git a/src/webui/snapshot.py b/src/webui/snapshot.py index c1187cc..7e4d188 100644 --- a/src/webui/snapshot.py +++ b/src/webui/snapshot.py @@ -46,6 +46,10 @@ def empty_state_dict() -> dict[str, Any]: "gate": None, "paused": False, "interactive": False, + # True only for the file-backed (keyless) monitor, where token/cache + # accounting is owned by the host harness and not observable by Arbor — + # the browser uses this to hide those panels rather than show 0s. + "keyless": False, } diff --git a/tests/test_mcp_session_ops.py b/tests/test_mcp_session_ops.py index 4ec2e8f..21fec2b 100644 --- a/tests/test_mcp_session_ops.py +++ b/tests/test_mcp_session_ops.py @@ -83,6 +83,28 @@ def test_update_node_drops_none_and_persists_score(tmp_path: Path) -> None: assert node is not None and node.status == "done" and node.score == 0.42 +def test_mutations_write_activity_sidecar(tmp_path: Path) -> None: + """Tree mutations record an activity sidecar (run clock + per-node timing + + event log) so the keyless WebUI has timing to render.""" + from arbor.mcp import session_timing + + ops.tree_add_node(tmp_path, "r", "ROOT", "an idea") + ops.tree_update_node(tmp_path, "r", "1", status="done", score=0.9) + + sidecar = session_timing.load(ops.coordinator_dir(tmp_path, "r")) + assert isinstance(sidecar.get("session_started_at"), (int, float)) + assert sidecar["nodes"]["1"].get("created_at") is not None + assert sidecar["nodes"]["1"].get("finished_at") is not None + kinds = [e["kind"] for e in sidecar["events"]] + assert kinds == ["proposed", "done"] + + +def test_stable_base_unchanged_outside_linked_worktree(tmp_path: Path) -> None: + """A non-git directory (and a normal checkout) is never redirected, so the + canonical ``/.arbor/sessions`` layout is preserved.""" + assert ops._stable_arbor_base(tmp_path) == tmp_path.resolve() + + def test_prune_marks_subtree(tmp_path: Path) -> None: ops.tree_add_node(tmp_path, "r", "ROOT", "idea") ops.tree_add_node(tmp_path, "r", "1", "child") diff --git a/tests/test_webui_session.py b/tests/test_webui_session.py index 93e694e..cfbdb03 100644 --- a/tests/test_webui_session.py +++ b/tests/test_webui_session.py @@ -53,7 +53,8 @@ def test_snapshot_maps_tree_counters_and_scores(tmp_path: Path) -> None: assert snap["run_name"] == "run_a" assert snap["task"] == "Improve score" assert snap["model"] == "host-model" - assert snap["phase"] == "monitoring" + assert snap["phase"] == "monitoring" # no activity sidecar → neutral + assert snap["keyless"] is True # browser hides token/cache panels assert snap["counters"] == {"proposed": 2, "done": 0, "pruned": 1, "merged": 1, "running": 0} assert snap["baseline_score"] == 0.2 assert snap["best_score"] == 0.55 # from meta.trunk_score @@ -64,6 +65,54 @@ def test_snapshot_maps_tree_counters_and_scores(tmp_path: Path) -> None: assert ids["2"]["status"] == "pruned" +def test_snapshot_uses_activity_sidecar_for_timing_and_phase(tmp_path: Path) -> None: + """With an activity sidecar present, the snapshot exposes a real run clock, + per-node runtime, an inferred phase, a running 'agent', and a non-empty + improvement trajectory.""" + import time + + from arbor.mcp import session_timing + + session = _write_session(tmp_path) + coord = session / ".coordinator" + start = time.time() - 120.0 + # Hand-write a sidecar: node 1 finished 60s in; node 3 is still running. + (coord / session_timing.SIDECAR_NAME).write_text( + json.dumps({ + "session_started_at": start, + "updated_at": start + 90, + "nodes": { + "1": {"created_at": start + 5, "started_at": start + 10, "finished_at": start + 60}, + "3": {"created_at": start + 70, "started_at": start + 80}, + }, + "events": [ + {"ts": start + 5, "kind": "proposed", "node_id": "1"}, + {"ts": start + 60, "kind": "merged", "node_id": "1", "score": 0.55}, + {"ts": start + 80, "kind": "running", "node_id": "3"}, + ], + }), + encoding="utf-8", + ) + # Add a running node so phase/agents reflect an in-flight experiment. + tree = json.loads((coord / "idea_tree.json").read_text(encoding="utf-8")) + tree["nodes"]["3"] = {"id": "3", "parent_id": "ROOT", "depth": 1, + "status": "running", "hypothesis": "try fp16\nsecond line", + "code_ref": "exp/n3"} + tree["nodes"]["ROOT"]["children_ids"].append("3") + (coord / "idea_tree.json").write_text(json.dumps(tree), encoding="utf-8") + + snap = build_session_snapshot(session, "run_a") + + assert snap["elapsed_seconds"] > 100 # real clock from session start + assert snap["phase"] == "benchmark" # a node is running + by_id = {n["node_id"]: n for n in snap["tree"]} + assert by_id["1"]["runtime_seconds"] == 50.0 # 60 - 10 + assert by_id["1"]["finished_elapsed"] == 60.0 # 60 since session start + assert by_id["3"]["runtime_seconds"] is not None and by_id["3"]["finished_elapsed"] is None + assert "exp/n3" in snap["agents"] # running node surfaced as agent + assert snap["best_score_history"] == [0.55] # one completed point + + def test_snapshot_handles_missing_session_gracefully(tmp_path: Path) -> None: snap = build_session_snapshot(tmp_path / "nope", "ghost") assert snap["run_name"] == "ghost" From b52520facf762cd52d378d99282eddb782ddc005 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Thu, 25 Jun 2026 09:40:32 +0800 Subject: [PATCH 2/4] fix(webui): guard keyless stable-base against git submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a git submodule, `git rev-parse --git-common-dir` points at the parent repo's `.git/modules/`, so anchoring `.arbor` at its parent would land in `.git/modules` rather than a working tree. Require the resolved main root to actually hold a `.git` entry before redirecting there — normal linked worktrees still redirect (their root has a `.git` dir), submodules no longer misfire. Adds a linked-worktree redirect test (env-guarded like the other worktree tests). --- src/mcp/session_ops.py | 8 ++++++-- tests/test_mcp_session_ops.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/mcp/session_ops.py b/src/mcp/session_ops.py index d1ef89e..bb7a022 100644 --- a/src/mcp/session_ops.py +++ b/src/mcp/session_ops.py @@ -109,8 +109,12 @@ def _stable_arbor_base(cwd: str | Path) -> Path: main_root = common_path.parent.resolve() toplevel = Path(top).resolve() # Redirect only when we are inside a *linked* worktree (toplevel differs - # from the main worktree root); otherwise keep the caller's directory. - if main_root != toplevel and main_root.exists(): + # from the main worktree root); otherwise keep the caller's directory. A + # real main worktree root has a ``.git`` entry (dir or file). Inside a git + # submodule, ``--git-common-dir`` points at the parent repo's + # ``.git/modules/`` whose parent is ``.git/modules`` — not a + # worktree — so the ``.git`` check keeps us from anchoring there. + if main_root != toplevel and (main_root / ".git").exists(): return main_root except Exception: # pragma: no cover - git absent / unexpected layout return base diff --git a/tests/test_mcp_session_ops.py b/tests/test_mcp_session_ops.py index 21fec2b..7c5fdfa 100644 --- a/tests/test_mcp_session_ops.py +++ b/tests/test_mcp_session_ops.py @@ -105,6 +105,21 @@ def test_stable_base_unchanged_outside_linked_worktree(tmp_path: Path) -> None: assert ops._stable_arbor_base(tmp_path) == tmp_path.resolve() +def test_stable_base_redirects_from_linked_worktree(tmp_path: Path) -> None: + """Inside a linked git worktree, ``.arbor`` anchors at the MAIN worktree root + so a file-backed session doesn't vanish when the agent switches worktrees.""" + if not _git_worktree_works(tmp_path / "_probe"): + pytest.skip("git worktree not functional in this environment") + repo = _init_repo(tmp_path) + ops.tree_add_node(repo, "r", "ROOT", "idea") + res = ops.worktree_create(repo, "r", "1", branch="exp/wt") + wt = Path(res["worktree"]) + assert wt.resolve() != repo.resolve() # genuinely a linked worktree + assert ops._stable_arbor_base(wt) == repo.resolve() # redirected to the main root + # …and the main checkout itself is still left unchanged. + assert ops._stable_arbor_base(repo) == repo.resolve() + + def test_prune_marks_subtree(tmp_path: Path) -> None: ops.tree_add_node(tmp_path, "r", "ROOT", "idea") ops.tree_add_node(tmp_path, "r", "1", "child") From 42201ea82e9f7c446512c8f356b3c854bcf76d52 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Thu, 25 Jun 2026 09:40:32 +0800 Subject: [PATCH 3/4] fix(webui): harden timing sidecar writes under concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a per-writer temp filename (pid + thread id) so two concurrent `_save` calls can't clobber a shared `.tmp` mid-write — the rename onto the final path stays atomic. Clean up the temp file on failure, and document that the read-modify-write in record_event still assumes a single writer per session. --- src/mcp/session_timing.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/mcp/session_timing.py b/src/mcp/session_timing.py index f972360..ff0ffb6 100644 --- a/src/mcp/session_timing.py +++ b/src/mcp/session_timing.py @@ -12,11 +12,19 @@ It is best-effort telemetry: every write is wrapped so a sidecar failure can never break the real tree mutation that triggered it. + +It assumes a *single writer* per session — the keyless MCP path is single-agent — +so the read-modify-write in :func:`record_event` is not locked; truly concurrent +writers could drop one another's events. The atomic rename in :func:`_save` (write +to a per-writer temp file, then ``replace``) still guarantees a reader never sees a +half-written file even under concurrency. """ from __future__ import annotations import json +import os +import threading import time from pathlib import Path from typing import Any @@ -49,9 +57,16 @@ def load(coord_dir: str | Path) -> dict[str, Any]: def _save(coord_dir: str | Path, data: dict[str, Any]) -> None: path = _path(coord_dir) path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(path) + # A per-writer temp name (pid + thread) so two concurrent _save calls can't + # clobber a shared temp file mid-write; the rename onto the final path stays + # atomic. (record_event's read-modify-write still assumes a single writer.) + tmp = path.with_suffix(f"{path.suffix}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) + except Exception: + tmp.unlink(missing_ok=True) # don't leave a stray temp file behind + raise def record_event( From 2d19200a410499395385e4d6df43edb21847b64d Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Thu, 25 Jun 2026 09:40:32 +0800 Subject: [PATCH 4/4] refactor(webui): keyless snapshot polish - flag the live snapshot `keyless: False` explicitly, so its shape matches empty_state_dict() and the keyless path (the browser hides token/cache cards only when keyless is true) - debug-log the stable-base session-lookup fallback in export instead of swallowing the exception silently - tiebreak the improvement-chart ordering on score, so legacy sessions with no sidecar timing (all keyed at +inf) still draw in a deterministic order --- src/export.py | 6 ++++-- src/webui/session_source.py | 4 +++- src/webui/snapshot.py | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/export.py b/src/export.py index 9aa20bb..ab399d7 100644 --- a/src/export.py +++ b/src/export.py @@ -11,6 +11,7 @@ import base64 import html import json +import logging import re from dataclasses import dataclass from datetime import datetime @@ -62,8 +63,9 @@ def resolve_session_dir(session: Path, cwd: Path | None = None) -> Path: stable = _stable_arbor_base(cwd) if stable != cwd: candidates.append(stable / CONFIG_DIR_NAME / "sessions" / str(session)) - except Exception: - pass + except Exception as exc: # best-effort: never block session lookup + logging.getLogger(__name__).debug( + "stable-base session lookup skipped: %s", exc) for path in candidates: if path.exists() and path.is_dir(): diff --git a/src/webui/session_source.py b/src/webui/session_source.py index f73b59f..11a2426 100644 --- a/src/webui/session_source.py +++ b/src/webui/session_source.py @@ -129,7 +129,9 @@ def _best_score_history( fin = timing.get(n.get("id"), {}).get("finished_at") if isinstance(timing, dict) else None # Nodes without a recorded finish still count — sort them last but stable. points.append((float(fin) if isinstance(fin, (int, float)) else float("inf"), float(score))) - points.sort(key=lambda p: p[0]) + # Order by finish time; tiebreak on score so legacy sessions with no timing + # (all keyed at +inf) still draw in a stable, deterministic order. + points.sort(key=lambda p: (p[0], p[1])) history: list[float] = [] best: float | None = None for _, score in points: diff --git a/src/webui/snapshot.py b/src/webui/snapshot.py index 7e4d188..c7d77c8 100644 --- a/src/webui/snapshot.py +++ b/src/webui/snapshot.py @@ -168,5 +168,9 @@ def state_to_dict(s: Any) -> dict[str, Any]: "companion": companion, "gate": _gate_to_dict(getattr(s, "pending_gate", None)), "paused": bool(getattr(s, "paused", False)), + # The live path always has token/cache telemetry; flag it explicitly so the + # shape matches empty_state_dict() / the keyless snapshot (browser hides the + # token/cache cards only when keyless is true). + "keyless": False, # interactive is set by the server (it knows whether input is enabled). }