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
14 changes: 14 additions & 0 deletions src/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import base64
import html
import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime
Expand Down Expand Up @@ -52,6 +53,19 @@ 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 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():
Expand Down
72 changes: 70 additions & 2 deletions src/mcp/session_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -79,9 +80,55 @@ 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/<branch>``). Each worktree has its own working
directory, so a session created under ``<worktree>/.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. 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/<name>`` 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
return base


def session_dir(cwd: str | Path, run_name: str) -> Path:
"""Return ``<cwd>/.arbor/sessions/<run_name>`` (the per-run session root)."""
return Path(cwd).resolve() / ".arbor" / "sessions" / _safe_run_name(run_name)
"""Return ``<base>/.arbor/sessions/<run_name>`` (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:
Expand Down Expand Up @@ -285,6 +332,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}


Expand All @@ -299,6 +349,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)}


Expand All @@ -308,6 +366,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}


Expand Down Expand Up @@ -476,6 +537,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}


Expand Down Expand Up @@ -612,6 +676,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,
Expand Down
120 changes: 120 additions & 0 deletions src/mcp/session_timing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""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.

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

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)
# 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(
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
38 changes: 25 additions & 13 deletions src/webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 };
});
Expand Down Expand Up @@ -315,7 +321,7 @@
</div>
</main>
</div>
<div id="overlay"></div>`;
<div id="overlay" style="color:var(--tx);"></div>`;
renderContent();
renderOverlay();
_gateKey=null; renderGate();
Expand Down Expand Up @@ -417,14 +423,20 @@ <h1 style="margin:8px 0 0; font-size:30px; font-weight:700; letter-spacing:-.025
<span id="hp-started" style="color:var(--tx2); font-family:'IBM Plex Mono',monospace;">Started ${fmtAgo(SNAP.elapsed_seconds)}</span>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:12px; align-items:stretch;">
${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')}
</div>
${(()=>{ 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 `<div style="display:grid; grid-template-columns:repeat(${cards.length},minmax(0,1fr)); gap:12px; align-items:stretch;">${cards.join('')}</div>`;
})()}
</div>

<div id="home-charts" style="display:grid; grid-template-columns:1.4fr 1fr; gap:var(--gap); margin-bottom:var(--gap);">${chartsInnerHTML()}</div>
Expand Down Expand Up @@ -633,7 +645,7 @@ <h1 style="margin:8px 0 0; font-size:30px; font-weight:700; letter-spacing:-.025
const running = runningNode();
const node = running || bestNode();
const bn = bestNode();
const title = node ? (node.hypothesis||node.node_id) : 'No active hypothesis yet';
const title = node ? (oneLine(node.hypothesis)||node.node_id) : 'No active hypothesis yet';
const diag = node ? (node.insight || node.pruned_reason || 'Diagnosis will appear once the node reports.') : 'Waiting for the coordinator to draft a hypothesis.';
const benching = !!running;
const conf = confidencePct();
Expand Down Expand Up @@ -700,7 +712,7 @@ <h3 style="margin:0; font-size:18px; font-weight:600; line-height:1.28; letter-s
const isAccepted = n.node_id===bestId;
const m = isAccepted ? { label:'ACCEPTED', color:'var(--accent)', icon:'grid' } : meta;
return { id:n.node_id, rank:i+1, status:m.label, color:m.color, soft:softFor(m.color), bd:bdFor(m.color), icon:m.icon,
title:n.hypothesis||n.node_id, desc:(n.insight||n.pruned_reason||'').slice(0,90) || '—',
title:oneLine(n.hypothesis)||n.node_id, desc:(n.insight||n.pruned_reason||'').slice(0,90) || '—',
impact: n.score!=null?'—':'pending', score: n.score!=null?imprStr(n.score):((n.status||'').toLowerCase()==='running'?'running':'queued'),
scoreColor: scoreColorFor(n.score), branch:n.branch||n.node_id, node:n,
approach: n.insight || 'No path recorded yet.', result: n.pruned_reason || (n.score!=null?('Benchmarked at '+imprStr(n.score)+'.'):'In progress.') };
Expand Down Expand Up @@ -971,7 +983,7 @@ <h2 style="margin:9px 0 0; font-size:20px; font-weight:700; letter-spacing:-.01e
<span style="flex:none; width:46px; height:46px; border-radius:12px; display:grid; place-items:center; color:${meta.color}; background:${softFor(meta.color)};">${ic('git-branch',22)}</span>
<div style="flex:1; min-width:0;">
<div style="display:flex; align-items:center; gap:9px; flex-wrap:wrap;"><span style="font-family:'IBM Plex Mono',monospace; font-size:12px; color:var(--tx3);">${esc(n.branch||n.node_id)}</span><span style="padding:4px 9px; border-radius:6px; font-size:10.5px; font-weight:700; letter-spacing:.06em; color:${meta.color}; background:${softFor(meta.color)}; border:1px solid ${bdFor(meta.color)};">${meta.label}</span><span style="font-family:'IBM Plex Mono',monospace; font-size:11.5px; color:var(--tx3);">parent ${esc(parent)}</span></div>
<h2 style="margin:9px 0 0; font-size:20px; font-weight:700; letter-spacing:-.01em; line-height:1.25;">${esc(n.hypothesis||n.node_id)}</h2>
<h2 style="margin:9px 0 0; font-size:20px; font-weight:700; letter-spacing:-.01em; line-height:1.25; color:var(--tx);">${esc(oneLine(n.hypothesis)||n.node_id)}</h2>
</div>
<button data-act="close-node" title="Close" class="hb" style="all:unset; cursor:pointer; flex:none; width:32px; height:32px; border-radius:8px; display:grid; place-items:center; color:var(--tx3); border:1px solid var(--line);">${ic('x',15)}</button>
</div>
Expand Down
Loading
Loading