Commit Graph
6 Commits
Author SHA1 Message Date
Brennan Benson 80f5aae0f9 feat(agent-status): publish the main agent's own state beside the combined row state (#22452)
* feat(agent-status): publish the lead agent's own state beside the combined row state

Every status producer folded the main agent's state together with live child
work into one `state`, so a lead that had finished while a subagent still ran
read `working` and its own state was lost. The row now also carries
`lead: { state, outcome?, stateStartedAt }`, admitted by the one payload
normalizer on the relay wire, IPC and disk, and published from the Claude hook
lane, the structured host ingest and renderer bridge, Grok (now on the shared
fold) and Codex (own combine kept). The persisted child-only boundary flag is
derived from `lead` plus child evidence and no longer written; old rows map
onto `lead` at hydrate. Combined `state` and `workingMode` are unchanged for
every reader; a cross-lane parity table pins that, with the cancelled-turn
watch-loop story recorded as a known divergence.

* fix(agent-status): make Orca's inferred interrupt the primary source of a Claude lead cancellation

Current Claude Code sends no hook at all on a cancel and no is_interrupt on
Stop, so the cancellation enters the lead record from the server's inferred
interrupt and rides into the next real Stop; is_interrupt on a turn boundary
stays as the secondary source for builds that send it. Comments, the store
reference and the parity table say so; no suppression changes.

* docs(agent-status): the child-only boundary comment now describes the persisted shell fact

The old sentence said a hydrated row no longer carries the shell fact, which is
the opposite of the mechanism: claudeRunningNonAgentTask is persisted precisely
so hydration can read it, and only a pre-lead row lacks it — reading as
shell-free, the same assertion its legacy flag made at write time.

* rename the lead fact to mainAgent: the main agent's own state

* docs(agent-status): the inferred cancel comes from Ctrl+C, not Esc

* fix(agent-status): an inferred interrupt keeps an already settled main agent, and the row verdict docs name its inferred source

* fix(agent-status): a child-induced wait publishes the main agent state it displaced

* fix(agent-status): decide child-held Claude rows from the saved main agent fact

Restart seeds the Claude main agent from the row's saved mainAgent whenever it
settled and no shell held the row, instead of re-deriving a child-only shape.
OSC cannot settle or repaint a row child agents hold open, including a row
waiting on a child's permission prompt. A sticky child permission prompt still
records the main agent's own progress, and OSC repaints and inferred answers
keep the shell fact beside the main agent they preserve.

* fix(agent-status): keep a finished turn's main agent verdict and clock with that turn

A Claude SessionStart restarts the main agent's clock instead of inheriting the
previous session's last Stop. A Grok idle prompt or session end, and a late
Codex root Stop after an inferred cancel, restate the same finished turn, so
they keep its recorded verdict; only a new turn clears it.

* test(agent-status): publish the Grok verdict restatement past the late-event window

* docs(agent-status): describe hydrate seeding and the OSC refusal from the saved main agent fact

* fix(agent-status): push a held child permission row when its main agent changes

* fix(agent-status): keep the shell fact on a held child permission row so restart does not settle it

* docs(agent-status): note the held child permission row carries the shell fact and is pushed

* fix(agent-status): pair the Claude shell fact with the main agent at the one row-build point

Every non-hook rewrite (terminal-title repaint, inferred answer, held child
permission) had to re-carry the shell fact beside `mainAgent`, and each one that
forgot let a restart settle a row while a shell still ran. The row builder now
pairs the fact once: a listener event restates it, any other write keeps it only
while `mainAgent` is unchanged. Restart seeds a settled main agent only when the
row says no shell ran, and legacy child-only rows map to that explicitly.

A held child permission now also accepts the main agent event's background
evidence, as it already accepts its `mainAgent`, so the child's drain no longer
settles a row a shell still holds. The renderer keeps a previous `mainAgent`
only for writers that never carry one, so a hook row without it matches the
host snapshot.

* test(agent-status): pin that restart never seeds a main agent from a row silent about its shell

* docs(agent-status): the row builder pairs the shell fact with the main agent, and restart seeds only on an explicit no-shell

* test(agent-status): name the legacy-row case parameter for what it holds

* docs(agent-status): name which rows carry the main agent fact
2026-09-23 17:45:50 -07:00
Brennan Benson 4c696a1e2a fix(agent-status): a structured session with live child work reads as working (#22295)
* fix(agent-status): a structured session with live child work reads as working

An idle native-chat session whose subagent was still running showed a green
check in the sidebar, the collapsed worktree pill, and worktree ps, while a
terminal Claude session in the same situation showed working. The two lanes
folded child work into the parent's status with different code: the hook
listener did, the structured lane did not.

Both lanes now share one child-work liveness vocabulary and one lead-status
fold. Live agent work makes a settled lead working; shells and monitors alone
make it monitoring. The structured lane derives liveness from the background
task list already on the wire, in both its readers, so the sidebar, the CLI,
the dashboard and mobile agree. The Claude task-kind table is one shared file
covering both the hook inventory and SDK stream names, and the renderer bridge
reuses the shared child-work projection instead of carrying its own copy.

* fix(agent-status): a blocked or out-of-contact subagent still holds its session working

Child-work liveness retired an agent-kind child on any state but working/monitoring,
while the shell beside it stayed live on everything except done/idle. A subagent
waiting on a permission prompt, or one whose host lost contact, therefore counted
for less than a backgrounded sleep and let the session read done. Both kinds now
share the settlement rule `resolveAgentChildWorkFreshness` already reads rows by:
only an explicit done/idle retires child work.

Also keep empty task labels out of the shared background-task projection candidate,
so a host that publishes `name: ''` cannot beat the child-row fallbacks.

* test(agent-status): pin the widened hook-inventory agent names, and correct two stale claims

The hook inventory now classifies through the shared kind table, which also maps the
SDK stream's `local_agent` / `local_subagent`. Nothing pinned that widening, so add
cases for all four agent names — including `teammate`, whose pane state stays `done`
under the #8825 idle-squat rule.

Two comments the fold made false:
- the teardown marker rule's comment claimed it could not disagree with what the UI
  calls working; it is deliberately lead-only, so now it says that and why;
- the agent-status store reference still described the structured row's `state` as the
  deleted `structuredAgentSessionStatusState`, and omitted the `workingMode` the ingest
  now writes.

* fix(agent-status): the state clock restarts when monitoring becomes a real turn

`stateStartedAt` carried forward whenever the prior `state` matched, which was sound
while `state` meant "a turn is running". Now that it folds in child work, an idle lead
watching a `sleep 3600` publishes `working`/`monitoring`; the user's prompt 45 minutes
later keeps `state: 'working'`, so the row inherited the watch loop's clock and read
"Working for 45m" the instant the turn began. Monitoring is its own displayed label
(`worktree-card-compact-agent-row.tsx:40`), so the continuity key is now the whole
published work identity — state AND workingMode — in both writers.

Also record two facts the code stated wrongly: the structured lane's `interrupted: false`
is inert (a projected session status has no interrupted member) rather than a decision,
and the child-work liveness rule's escape hatch is the roster's session lifetime, not a
settled state.

* fix(agent-status): a workflow is watch work, and child work dates itself

Two defects the fold introduced.

`isAgentChildWorkKind` counted `workflow` as agent work, so a structured session
whose only live task was a backgrounded `local_workflow` published a full working
spinner while the children projection — which admits `kind === 'agent'` only —
rendered nothing to expand, and the same workflow in a terminal pane showed the
monitoring badge instead. The repo already decides this: `isClaudeSubagentTask`
excludes workflows by name, and MATERIALIZED_TASK_KINDS leaves "the backgrounded
shell command and the workflow" to the non-agent owner. The predicate is now
`kind === 'agent'`, and the three sites that restated the same test route through
it, so a new kind is decided in one place instead of three that merely agree.

`evidenceObservedAt` dated every row by `summary.updatedAt`, the journal's last
activity. The journal cannot date child work: its clock stopped when the lead's
turn did, so a genuinely live roster aged past the 30-minute staleness window and
mobile's dot decayed a running session to idle. The fold now reports whether child
work alone holds the row open, and only then does the host's observation clock
stand in — keeping "a restart's republish is not new evidence" for lead turns.

* fix(agent-status): the sidebar dates child work the same way the host does

`fromChildWork` reached the host ingest but not the renderer bridge, so after ~30
minutes of live child work with no journal activity the sidebar's row aged into
staleness while `worktree ps` and mobile stayed fresh — two writers for one session
answering differently, which is the defect this PR exists to remove. For a remote
host the client's own receipt time is also the more honest clock, since the journal
stamp is the host's and is never comparable against this machine's now.
2026-09-22 13:48:07 -07:00
Neil e8a7be4ce2 fix(omp): recover retired pane status with validated restart authority
Merged after fresh run 35448889017 passed all required checks, including static analysis, typecheck, package jobs, all test shards, changed E2E, Docker SSH E2E, and verify.
2026-09-19 08:05:33 -07:00
Brennan Benson 291b4ddd6f feat(agent-status): route structured sessions through canonical ownership (#20718)
* feat(agent-status): route structured status through canonical ownership and fence child lifetimes

Restacked onto the canonical store and child-work contract. Completing that
restack drops the `reopenStructuredParent` mutation flag this change had
carried, along with its contract field, its codec branch, and its single
call site in structured ingest, which passed a hardcoded `true`.

The flag was a narrow escape hatch from the absolute `tombstones.has(...)`
rule that governed parent upserts in this branch's original base. The
canonical store replaces that rule with a revision envelope, because a
bounded store compacts tombstones away and a presence-based guard silently
stops fencing once one is evicted. With the envelope deciding the outcome,
the escape hatch has nothing left to escape from, so removing it changes no
production behaviour.

`agent-status-store-reopen.test.ts` is rewritten against the envelope: the
reopen case now pins that an unflagged republication succeeds while replay
from before the reopen stays fenced even after the parent tombstone is
compacted away, and the second case pins where the guard genuinely bites —
a republication inside the removing mutation itself, for every subject kind.

* fix(agent-status): re-admit unchanged structured owners after teardown

* fix(agent-status): clear anti-slop object-param and Reflect.apply findings

- agent-status-store-byte-budget.ts: type the byte-budget helper's
  record parameter as the union of what its call sites actually pass
  (the snapshot header plus each store entity record) instead of the
  broad `object`.
- server-structured-canonical-status.test.ts: replace `Reflect.apply`
  with a typed, explicitly-bound call that models a caller at an
  untyped boundary omitting the trusted owner subject.

* docs(agent-status): drop the 2A progress doc from docs/reference

docs/reference/ holds implementation detail, not rollout progress. The
canonical-boundary notes move to the effort's working directory; the
agent-status-store status section keeps the boundary statement and loses
the now-dangling link.

* fix(agent-status): mint the canonical epoch on first use, not at construction

The hook server's canonical store was built in an instance-member initializer, so
constructing AgentHookServer — which happens at import time for the module
singleton — demanded a live randomUUID. Any importer that stubs node:crypto threw
'Invalid agent status store epoch' before a single test ran.

The store is now created on first canonical access and reset by dropping it, so
construction owes nothing to a crypto implementation and the epoch still rotates
per authority incarnation.

* fix(agent-status): drop the orphaned snapshot budget and a duplicated pane guard

Two leftovers from the canonical-store routing change:

agent-status-store-snapshot-budget.ts lost its only caller when the store state
switched to agentStatusStoreFitsByteBudget. Nothing in the repo imports it now,
so the module goes with the caller it existed for. The replacement is not a
straight copy: it only memoises a record's measured size once the record is
frozen, so a still-mutable record can no longer return a stale byte count.

persistedStructuredWorkerPaneKeyIsValid repeated its public-pane-key rejection
verbatim three lines below the first one. The tests covering that rejection pass
on the first occurrence alone, so the second decided nothing and only obscured
which predicate was load-bearing.

* fix(agent-status): stop a failed structured publish from latching as owned

Three defects found reviewing the structured routing path.

combinedStatusEntries defaulted a missing listing order to 0, but the counter it
compares against starts at 1, so any unordered row sorted above every ordered
one. Unknown order now sorts last.

The owner map recorded a session as owned before the sink ran. A publish that
threw therefore left matchesLocation reporting an owned location for a row that
was never written, and the unchanged-projection path — the only thing that would
re-offer it — stopped. The address still has to survive a throw so teardown can
forget a row that did land, so the two facts are now separate: the address is
recorded up front, and only a publish that returned marks the row as landed.

The reopen test claimed the revision envelope rather than the tombstone fences a
stale replay. It cannot tell: transport consecutiveness, the parent-revision
validator and the tombstone guard each refuse that replay alone, and ablating any
two leaves the test green. It now asserts the outcome and says so.
2026-09-16 01:20:58 -07:00
Brennan BensonandMerge Sim da5d555259 refactor(agent-status): delete the runtime's retained row store (PR 1b) (#19785)
* docs(agent-status): plan PR 1b at file level

Names the five RuntimeAgentRowStore call sites and what each becomes, why
terminalHandle has to be stamped before the store can go, and the one
intended behavior change.

* feat(agent-status): stamp the pane terminal handle on hook-server rows

The runtime's retained row store carried the pty binding two readers need. Put
that fact on the row that already owns the pane instead, resolved through the
same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot
disagree about which terminal a pane is.

Carried forward when a later write resolves no handle (only main's OSC parse
can), and never persisted: a handle belongs to the runtime that issued it.

* refactor(agent-status): route the session-tabs republish off the store

`retain()` was not only a duplicate store: its boolean return was the signal
that republished `session.tabs` for a status-only transition, which no title
change covers (#7970). `hook-status-session-tabs-invalidation.ts` already
mirrors that change set plus hook restore provenance, so route the signal off
the store rather than keep a second comparator.

Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out
deliberately skips — now load-bearing, because a dismissed row leaves the
listing at once.

Installed on both hosts. orcad had neither the OSC producer nor this signal, so
its runtime observed agent status and published it nowhere; deleting the
retained copy without wiring it would list no PTY agents there at all.

* refactor(agent-status): delete the runtime's duplicate retained row store

`RuntimeAgentRowStore` held the same payload the hook server already holds, so
the same pane could legitimately read differently in the sidebar, in
`worktree ps`, and on the phone. Both of its readers move onto the store's
snapshot in `runtime-hook-agent-row-selection.ts`, and
`collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook
reconciliation that only existed because two stores could disagree.

`ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`,
which is how a row still resolves the connected PTY behind it — the
working-terminal rollup's match key, and the last rescue for a row whose pane
binding a controller incarnation nulled under it.

The one intended behavior change: a row the user dismisses on the desktop
leaves `worktree ps` and mobile at once instead of lingering until the pty
exits. One store means one dismissal.

The suites written against the retained store are rewired to a real
AgentHookServer rather than deleted, so each still asserts the listing
behavior it named.

* docs(agent-status): record what PR 1b landed

Past tense, plus two corrections to the plan: `terminalHandle` is not the pty
id (they are different identifiers, and the explicit-status reader was already
comparing against a real handle), and the legacy numeric pane key is a
consequence the plan did not name.

* fix(agent-status): harden single-store lifecycle

* fix(agent-status): preserve mobile terminal rejoin

* fix(agent-status): preserve unverifiable remote rows

* fix(agent-status): own PTY row lifecycle in hook server

* fix(agent-status): preserve state and renew freshness

* fix(agent-status): ignore freshness for dismissed identity rows

* fix(agent-status): fence orcad observed identities

* fix(orcad): always release daemon adapter on cleanup

* fix(agent-status): cover remint and headless lifecycle edges

* fix agent status identity recovery gaps

* fix(agent-status): suppress duplicate child-only row mutation

* test(runtime): preserve hook store wiring in transcript harness

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 15:28:16 -07:00
Brennan BensonandMerge Sim ebb1acfa37 refactor(agent-status): publish structured sessions into the hook server store (#19683)
* refactor(agent-status): publish structured sessions into the hook server store

Structured (native chat) sessions have no PTY and no hook script, so their
status never reached the hook server's store; #19217 gave `worktree ps` its
own adapter over the structured feed instead. The feed now writes every
projection into that store through a status sink the runtime wires, drops
the row when the host closes the session, and `worktree ps` reads the one
snapshot like every other agent.

Rows carry a `structuredHost` marker and the journal clock; they are never
persisted to last-status.json, and the main process does not forward them
to the renderer yet, whose feed bridge still owns them until it is retired.

Design and the two follow-ups: docs/reference/agent-status-store.md.

* chore: drop stray @pnpm/exe lockfile entry

An unrelated local pnpm run added @pnpm/exe as a packageManagerDependency
with no package.json change, so CI's --frozen-lockfile install failed
before any job ran.

* docs(agent-status): describe the step that actually landed

The design record claimed PR 1 deletes RuntimeAgentRowStore, drops the
retained-versus-hook reconciliation, stamps terminalHandle on OSC rows, and
tags rows with a source field of 'structured-host'. None of that is true of
the shipped code: the retained store and its reconciliation are still in
place, and the row field is structuredHost: 'held' | 'owned'.

AGENTS.md points every future contributor here before they touch agent
status, so split the roadmap into the 1a that landed and the 1b that has not,
and name the fields the code actually writes.

* fix(agent-status): pair session removal with the status-row forget

A session dropped from the host's map without an explicit forget left its row
in the store forever: `structuredHostOwned` bypasses the staleness check, so a
failed re-attach (the Claude rewind path reaches one) stranded a permanently
working agent in `worktree ps` and on mobile with no UI able to clear it.
Deletion and forget are now one operation both callers route through.

* fix(agent-status): give orcad the store worktree ps reads from

`orcad` constructed its runtime with neither `getAgentStatusSnapshot` nor
`structuredAgentStatusSink`, so once `worktree ps` sourced rows only from that
snapshot the headless host published nowhere and listed nothing. The hook
server's store is a module singleton whose import tree never reaches Electron,
and its file paths come from `start()`, which orcad never calls.

* fix(agent-status): drop a structured row without a renderer clear

`dropStructuredStatus` went through `clearPaneState`, which fans a pane clear
out to the renderer for a pane key the renderer's own feed bridge still writes
- so 'exactly one writer per pane key' held for writes and not for deletes.
`dropStatusEntry` routes through the status-drop tap instead, and skips the
resume-identity remnant: a structured session has no pane to resume into, and
every null-status publish would otherwise re-mint one.

* test(agent-status): pin both half-migration structured-row filters

Neither the `agentStatus:getSnapshot` filter nor the main-window listener's had
a single assertion, so deleting either — the first step of PR 2 — was green
everywhere. Also covers the perf skip and the drop's lack of a renderer clear.

* docs(agent-status): correct three statements this PR made false

The sink JSDoc claimed only tests construct a host without one; `orcad` did.
The doc argued a structured row needs no tab mirror 'because headless serve has
no renderer', reasoning about exactly the topology the wiring had not reached.
The deleted runtime adapter's warning that the pane key must be the DERIVED one
- never a bearer handle or minted worker key - was lost with it.

* test(agent-status): declare orcad in the hook-row producer census

Wiring the hook store into the orcad runtime added a production site that
hands hook rows to a consumer, which the census ratchet pins deliberately.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 11:27:27 -07:00