Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Observed harness state is a driver-written catalog record

Status: accepted

Design decision made by Johannes on 2026-08-23 (interview over three research
reports and issue #268, which carries the measurements cited below). Merge and
acceptance approval required: upstream maintainers.

## Context

st2 exposes what an agent *declares* — presence, on a five-minute heartbeat
with a fifteen-minute staleness horizon — and nothing about what its harness is
*observed* doing. The one observed state machine that exists,
`CodexObservedState`, persists host-local under `$XDG_STATE_HOME`, has zero
production readers, and is invisible to the roster, Doctor, and every remote
supervisor. Claude, pi, and OpenCode have no observed-state type at all. The
concrete consumer is the one #261 and #268 name: a downstream TUI rendering a
seat's working state, which today cannot distinguish "alive and doing nothing"
from "recently active" because `lastActivity` advances on the agent's own
heartbeat. #162 assigns provider-specific idle/active classification to
drivers and keeps the generic envelope in core; this decision fills that
envelope.

## Decision

Observed harness state is **one driver-written record in the catalog**,
`<agent-dir>/harness-state`, schema `st2.harness-state.v1`, carrying the full
tuple in v1: `state ∈ idle | active | child | ended` (`unknown` derived, never
written; `child` reserved with no producer), **`blockedOn` as a field**
(`human | none`), and `inputBuffer ∈ empty | nonempty | unknown`. The record
follows the presence record's transport discipline — embedded origin
timestamp, atomic tmp+rename, byte-distinct on every write — with its own
staleness constants, deliberately not aliases of `status::STATUS_*`. Freshness
is **transition writes plus a slow heartbeat** on the existing five-minute
presence cadence, and a writer that loses sight of its harness stops
heartbeating rather than refreshing a state it cannot see. **All four
harnesses ship producers**: Codex projects its existing control state, Claude
combines hooks with a wrapper-owned terminal write, pi exports the injected
extension's positive idle signal as an evented producer, and OpenCode gains a
full driver — session wrapper, presence lease, state producer, and a native
delivery transport at parity with the other drivers. Exposure is **joined into
`agents --json`** beside declared presence, plus a Doctor advisory. Teardown
gets both fixes: the wrapper writes its terminal record *before* escalating
SIGKILL into its own process group, and same-host readers cross-check the
record's pty session, where only provable death downgrades. Transition history
is deferred; `transitions` and `sinceMs` keep the record forward-compatible
with it. The work proceeds without waiting on #242 and flags the delivery
contract as that PR's territory.

## Options

| Option | Result | Reason |
| --- | --- | --- |
| 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). |
| 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. |
| 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. |
| `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. |
| 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. |
| 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. |
| 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. |

## Evidence and Argument

The signal already exists, typed and durable, for exactly one harness in the
wrong place: `CodexObservedState` is written on every transition to
`$XDG_STATE_HOME/st2/codex/<hash>/control-state.json`, and at current main it
has no production reader — the pump rebuilds from `AwaitingStatus` on restart.
So the Codex producer is a projection of shipped state, not a new observation
path. The two rows #268's adversarial review corrected are binding here:
`ActiveWithoutTurn` and `ConflictingTurn` project to `active`, because Codex
positively reported activity and st2 merely cannot name a steerable turn; and
`Held` never enters the published vocabulary, because it is defined as the
complement of steerable — a delivery predicate, which decision 0001 already
established must not govern an observability surface. pi is the strongest
evidence for the evented path: the injected extension's `ctx.isIdle()` is a
positive in-process signal (false exactly for `agent_start`..`agent_end`),
which is the "stronger evented signal" root DQ2 asks for, on the one harness
that already has a channel. On vocabulary provenance: the
`idle|active|child|unknown × inputBuffer` words #268 reuses are from PR #123,
which remains open — this decision *defines* those words for st2 rather than
reusing something landed. The self-wake hazard #268 warns about is live today:
the Codex delivery pump arms an unfiltered recursive watch on the whole agent
directory while its own five-minute presence refresh writes into that tree, so
scoping delivery-input watching is a prerequisite, not a precaution.

## Consequences

- The vocabulary (`idle`, `active`, `child`, `ended`, `unknown`; `blockedOn`;
`inputBuffer`) is defined by this decision and
[`05-harness-state`](../05-harness-state/requirements.md), not inherited
from PR #123. If #123 lands with different words, that PR reconciles to
these or supersedes this decision explicitly.
- Homographs to manage: *observed harness state* is neither **presence**
(agent-authored), **session state** (task-record liveness), R08's declared
**activity status**, nor R09's **working state** (restored context). The
ontology pins all four apart.
- The roster wire shape changes: the three full-string pinned assertions and
the stable-roster invariant wording are edited deliberately in the same
change that adds `observedState`, with the new proof named.
- `blocked` is vacuous under `bypassPermissions`, which is what
`examples/native/agent-claude.kdl` ships today; a fleet running entirely on
it gains nothing from the axis until that changes (OHS-T02).
- The OpenCode native delivery transport is designed against #242's contract
shape and does not wait for it; #242 reconciles at review.
- The `.decisions/` series carries two documents numbered 0005 from separate
PRs. This decision takes 0006 and leaves the collision as recorded history;
numbers are not reused.
- Root DQ2 and DQ3 are updated: pi gains an evented signal, the observed half
of DQ3 is specified here, and the declared half (activity status, plan,
plan step) plus supervisor-following behavior remain open.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# DELTA-005: observed harness state is specified ahead of its producers

## Current mismatch

[`05-harness-state`](../05-harness-state/spec.md) and decision
[`0006`](../.decisions/0006-observed-harness-state-is-a-driver-written-catalog-record.md)
specify one observed-state record with producers on all four maintained
harnesses, a roster join, Doctor exposure, and scoped delivery-input watching.
The shipped implementation at the time this delta was filed is the envelope
alone: `src/harness_state.rs` (record, writer, derivation, liveness
cross-check hook) with its module tests. No producer writes the record, the
roster does not expose it, the Codex delivery pump still watches its whole
agent directory unfiltered, and OpenCode has no typed driver.

## Why the docs land first

The vocabulary and derivation rules are the contract the fractal TUI pins;
they were interview-settled on 2026-08-23 and are cheaper to review as one
document set than re-derived per producer PR. Producers are independent
vertical slices (Codex projection, Claude hooks + wrapper, pi extension,
OpenCode driver) and land incrementally against this spec.

## Direction

Update implementation to match the spec, in dependency order.

## Resolution Signal

Checked off as each lands with its named tests green; this delta is deleted by
the change that completes the last box.

- [ ] Scoped delivery-input watching replaces the Codex pump's unfiltered
agent-dir watch, with the no-self-wake regression test.
- [ ] Codex producer: projection per the spec table, wired to the control
pump's transitions, heartbeat, and terminal write.
- [ ] Claude producer: hook-side classification plus wrapper-owned heartbeat
and terminal write (pre-escalation ordering fixed).
- [ ] pi producer: evented extension transitions over the channel; wrapper
writes.
- [ ] OpenCode: typed driver + `opencode-session` wrapper (presence lease),
then its producer, then the native delivery transport.
- [ ] Roster `observedState` join with the pinned assertions updated and the
stable-roster invariant wording amended in the same change.
- [ ] Doctor advisory for owned agents.
59 changes: 59 additions & 0 deletions docs/vrs/05-harness-state/open-questions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Observed harness state open questions

Each entry links a spec `DQ-H*`. Questions leave this file when resolved —
into [spec.md](./spec.md) as decisions or `.experiments/` as tested
hypotheses.

- **DQ-H1 Claude blocked-exit edge.** `PermissionRequest` carries no
`tool_use_id`, so leaving `blockedOn: human` can only match on tool *name* —
and Claude batches tool calls (`PostToolBatch` carries plural `tool_calls`),
so the first call's `PostToolUse` would clear `blocked` while the human still
faces the second call's prompt. The corpus enters `blocked` in 2 of 9
captures and exits in 1, with one tool and no batching: a rule validated on
a single exit path is not validated. Until then the producer holds
`blockedOn: human` until turn end (`Stop`) rather than encoding an exit rule
that cannot hold. Resolves by: a capture with batched tool calls where the
second call needs permission, then specifying the exit edge against it.
- **DQ-H2 Transport cost of per-transition writes.** Presence refreshes every
five minutes; turn boundaries are far more frequent, and burst coalescing
measured 4 transitions per turn 0.1–0.4 ms apart. No measurement establishes
what per-transition replicated writes cost on a real catalog under a real
transport (OHS-T01 accepts this for v1). Resolves by: measuring write and
sync volume on a live catalog; if unacceptable, a minimum-interval
coalescing window is the tuning knob, at the cost of spinner latency.
- **DQ-H3 `child` has no producer.** The word is reserved because the tuple's
reasoning needs it (a long-running foreground command is neither the model
working nor idle), but the producer that would have supplied it — the PTY
screen observer — is cut: its idle proof was Codex-specific and its Claude
arm collided with the empty-composer sentinel (#268 Limits). Resolves by: a
harness exposing a positive child-process signal (Claude `PreToolUse`/
`PostToolUse` pairs are the candidate), proven against batching.
- **DQ-H4 Ungraceful-death coverage.** The wrapper's terminal write covers
child-reap and SIGTERM; nothing in-process covers SIGKILL escalation into
the wrapper's own group or an external forced kill (invariant row 11's
case). Same-host readers NARROW the window via the liveness cross-check —
only for provably dead sessions; `pty kill` removes the pidfile and leaves
the probe indeterminate, so a same-host reader then shares the
fifteen-minute horizon (or waits for the next relaunch claim) exactly like
a cross-host one. Resolves by: either
accepting the horizon (documenting it as the cross-host bound) or a
supervisor-side terminal write derived from reconcile's session state —
which would need its own fencing rules to avoid a supervisor overwriting a
live wrapper's record.
- **DQ-H5 Supervisor-following behavior.** Root `DQ3` sets two gates for
catalog agent state: stale-state behavior (addressed throughout this
subsystem) and supervisor-following behavior — what a *remote* supervisor
may conclude and do from this record. The second gate is unmet, which is a
reason decision 0006's spec ships Draft. #107 already bounds it: a remote
`unknown` is no fresh observation, never proof of ill health, and never
gates local work. Resolves by: specifying remote-reader semantics with a
proof, or explicitly scoping the record same-host advisory.
- **DQ-H6 OpenCode state source.** The producer needs a verified source.
Candidate: OpenCode's server/SDK event surface (session state, message
lifecycle); fallback: the shipped composer adapter's positive markers
(`ctrl+p commands` footer) with documented limits. Nothing is measured yet —
the DING adapter (#313) proves only composer classification. Resolves by:
an `.experiments/` capture of OpenCode's event surface on a pinned version,
then choosing the source and its skew policy — the repo's standing rule
(pin where version skew fails silently, as `checks.pi-extension-types` and
`SUPPORTED_CODEX_CLI_VERSIONS` do) applies.
Loading
Loading