|
| 1 | +# Observed harness state is a driver-written catalog record |
| 2 | + |
| 3 | +Status: accepted |
| 4 | + |
| 5 | +Design decision made by Johannes on 2026-08-23 (interview over three research |
| 6 | +reports and issue #268, which carries the measurements cited below). Merge and |
| 7 | +acceptance approval required: upstream maintainers. |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +st2 exposes what an agent *declares* — presence, on a five-minute heartbeat |
| 12 | +with a fifteen-minute staleness horizon — and nothing about what its harness is |
| 13 | +*observed* doing. The one observed state machine that exists, |
| 14 | +`CodexObservedState`, persists host-local under `$XDG_STATE_HOME`, has zero |
| 15 | +production readers, and is invisible to the roster, Doctor, and every remote |
| 16 | +supervisor. Claude, pi, and OpenCode have no observed-state type at all. The |
| 17 | +concrete consumer is the one #261 and #268 name: a downstream TUI rendering a |
| 18 | +seat's working state, which today cannot distinguish "alive and doing nothing" |
| 19 | +from "recently active" because `lastActivity` advances on the agent's own |
| 20 | +heartbeat. #162 assigns provider-specific idle/active classification to |
| 21 | +drivers and keeps the generic envelope in core; this decision fills that |
| 22 | +envelope. |
| 23 | + |
| 24 | +## Decision |
| 25 | + |
| 26 | +Observed harness state is **one driver-written record in the catalog**, |
| 27 | +`<agent-dir>/harness-state`, schema `st2.harness-state.v1`, carrying the full |
| 28 | +tuple in v1: `state ∈ idle | active | child | ended` (`unknown` derived, never |
| 29 | +written; `child` reserved with no producer), **`blockedOn` as a field** |
| 30 | +(`human | none`), and `inputBuffer ∈ empty | nonempty | unknown`. The record |
| 31 | +follows the presence record's transport discipline — embedded origin |
| 32 | +timestamp, atomic tmp+rename, byte-distinct on every write — with its own |
| 33 | +staleness constants, deliberately not aliases of `status::STATUS_*`. Freshness |
| 34 | +is **transition writes plus a slow heartbeat** on the existing five-minute |
| 35 | +presence cadence, and a writer that loses sight of its harness stops |
| 36 | +heartbeating rather than refreshing a state it cannot see. **All four |
| 37 | +harnesses ship producers**: Codex projects its existing control state, Claude |
| 38 | +combines hooks with a wrapper-owned terminal write, pi exports the injected |
| 39 | +extension's positive idle signal as an evented producer, and OpenCode gains a |
| 40 | +full driver — session wrapper, presence lease, state producer, and a native |
| 41 | +delivery transport at parity with the other drivers. Exposure is **joined into |
| 42 | +`agents --json`** beside declared presence, plus a Doctor advisory. Teardown |
| 43 | +gets both fixes: the wrapper writes its terminal record *before* escalating |
| 44 | +SIGKILL into its own process group, and same-host readers cross-check the |
| 45 | +record's pty session, where only provable death downgrades. Transition history |
| 46 | +is deferred; `transitions` and `sinceMs` keep the record forward-compatible |
| 47 | +with it. The work proceeds without waiting on #242 and flags the delivery |
| 48 | +contract as that PR's territory. |
| 49 | + |
| 50 | +## Options |
| 51 | + |
| 52 | +| Option | Result | Reason | |
| 53 | +| --- | --- | --- | |
| 54 | +| Catalog record beside `status`, presence transport discipline | Selected | Remote supervisors and the TUI read through the catalog they already sync; the presence record's embedded-timestamp discipline is proven against transports that drop mtime. Accepted cost: a replicated write per state transition, unmeasured (OHS-T01). | |
| 55 | +| Host-local record plus CLI exposure only | Rejected | Zero transport cost, but remote readers get nothing and the consumer TUI must run on the owning host — the exact invisibility that leaves `control-state.json` unread today. | |
| 56 | +| Ride the stream/event ingress (#300) | Rejected | Events land in the inbox and DING the agent on every transition, emit is gated on a live owner binding and a healthy catalog, and stream direction is ingress into an agent, not egress about one. | |
| 57 | +| `blockedOn` as a fifth state | Rejected | An agent can be blocked while a child command runs; the field composes, a fifth state forces an ordering. A later-added axis would decode as `unknown` in every pinned v1 reader, so the field ships in v1 while the window is open. | |
| 58 | +| Sibling `st2 harness-state --json` command | Rejected for v1 | Zero pinned-wire edits, but the value of the surface is the comparison: a declared `busy` beside an observed `idle` is the wedged-agent signal, and a caller made to join two commands will skip it. The three pinned roster literals and the roster invariant wording change deliberately, in one change. | |
| 59 | +| Codex-only v1, other harnesses follow | Rejected | The TUI pins v1 when it ships; a fleet's Claude/pi/OpenCode seats reading `null` indefinitely re-creates the two-tier observability this exists to close. | |
| 60 | +| History (JSONL transitions) in v1 | Deferred | Measured burst coalescing — 4 transitions per turn, 0.1–0.4 ms apart (#268) — erases states shorter than the coalescing window, defeating the dwell-time analysis history is justified by. Deferred, not deleted: `transitions` + `sinceMs` make a later history additive. | |
| 61 | + |
| 62 | +## Evidence and Argument |
| 63 | + |
| 64 | +The signal already exists, typed and durable, for exactly one harness in the |
| 65 | +wrong place: `CodexObservedState` is written on every transition to |
| 66 | +`$XDG_STATE_HOME/st2/codex/<hash>/control-state.json`, and at current main it |
| 67 | +has no production reader — the pump rebuilds from `AwaitingStatus` on restart. |
| 68 | +So the Codex producer is a projection of shipped state, not a new observation |
| 69 | +path. The two rows #268's adversarial review corrected are binding here: |
| 70 | +`ActiveWithoutTurn` and `ConflictingTurn` project to `active`, because Codex |
| 71 | +positively reported activity and st2 merely cannot name a steerable turn; and |
| 72 | +`Held` never enters the published vocabulary, because it is defined as the |
| 73 | +complement of steerable — a delivery predicate, which decision 0001 already |
| 74 | +established must not govern an observability surface. pi is the strongest |
| 75 | +evidence for the evented path: the injected extension's `ctx.isIdle()` is a |
| 76 | +positive in-process signal (false exactly for `agent_start`..`agent_end`), |
| 77 | +which is the "stronger evented signal" root DQ2 asks for, on the one harness |
| 78 | +that already has a channel. On vocabulary provenance: the |
| 79 | +`idle|active|child|unknown × inputBuffer` words #268 reuses are from PR #123, |
| 80 | +which remains open — this decision *defines* those words for st2 rather than |
| 81 | +reusing something landed. The self-wake hazard #268 warns about is live today: |
| 82 | +the Codex delivery pump arms an unfiltered recursive watch on the whole agent |
| 83 | +directory while its own five-minute presence refresh writes into that tree, so |
| 84 | +scoping delivery-input watching is a prerequisite, not a precaution. |
| 85 | + |
| 86 | +## Consequences |
| 87 | + |
| 88 | +- The vocabulary (`idle`, `active`, `child`, `ended`, `unknown`; `blockedOn`; |
| 89 | + `inputBuffer`) is defined by this decision and |
| 90 | + [`05-harness-state`](../05-harness-state/requirements.md), not inherited |
| 91 | + from PR #123. If #123 lands with different words, that PR reconciles to |
| 92 | + these or supersedes this decision explicitly. |
| 93 | +- Homographs to manage: *observed harness state* is neither **presence** |
| 94 | + (agent-authored), **session state** (task-record liveness), R08's declared |
| 95 | + **activity status**, nor R09's **working state** (restored context). The |
| 96 | + ontology pins all four apart. |
| 97 | +- The roster wire shape changes: the three full-string pinned assertions and |
| 98 | + the stable-roster invariant wording are edited deliberately in the same |
| 99 | + change that adds `observedState`, with the new proof named. |
| 100 | +- `blocked` is vacuous under `bypassPermissions`, which is what |
| 101 | + `examples/native/agent-claude.kdl` ships today; a fleet running entirely on |
| 102 | + it gains nothing from the axis until that changes (OHS-T02). |
| 103 | +- The OpenCode native delivery transport is designed against #242's contract |
| 104 | + shape and does not wait for it; #242 reconciles at review. |
| 105 | +- The `.decisions/` series carries two documents numbered 0005 from separate |
| 106 | + PRs. This decision takes 0006 and leaves the collision as recorded history; |
| 107 | + numbers are not reused. |
| 108 | +- Root DQ2 and DQ3 are updated: pi gains an evented signal, the observed half |
| 109 | + of DQ3 is specified here, and the declared half (activity status, plan, |
| 110 | + plan step) plus supervisor-following behavior remain open. |
0 commit comments