diff --git a/docs/vrs/.decisions/0006-observed-harness-state-is-a-driver-written-catalog-record.md b/docs/vrs/.decisions/0006-observed-harness-state-is-a-driver-written-catalog-record.md new file mode 100644 index 00000000..bd45fff4 --- /dev/null +++ b/docs/vrs/.decisions/0006-observed-harness-state-is-a-driver-written-catalog-record.md @@ -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**, +`/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//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. diff --git a/docs/vrs/.delta/DELTA-005-harness-state-specified-ahead-of-full-implementation.md b/docs/vrs/.delta/DELTA-005-harness-state-specified-ahead-of-full-implementation.md new file mode 100644 index 00000000..c2f1918e --- /dev/null +++ b/docs/vrs/.delta/DELTA-005-harness-state-specified-ahead-of-full-implementation.md @@ -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. diff --git a/docs/vrs/05-harness-state/open-questions.md b/docs/vrs/05-harness-state/open-questions.md new file mode 100644 index 00000000..9e9d32ec --- /dev/null +++ b/docs/vrs/05-harness-state/open-questions.md @@ -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. diff --git a/docs/vrs/05-harness-state/requirements.md b/docs/vrs/05-harness-state/requirements.md new file mode 100644 index 00000000..ee0834ef --- /dev/null +++ b/docs/vrs/05-harness-state/requirements.md @@ -0,0 +1,164 @@ +# Observed harness state requirements + +## Context + +This subsystem defines observed harness state: the driver-written record of +what a harness is seen doing, published into the catalog beside the agent's +declared presence. It refines the slot root [`R08`](../requirements.md) leaves +undefined — R08 ratifies *declared* activity status and root spec `DQ3` marks +activity, plan, and plan step "remain undefined" — by specifying the +*observed* axis only. Declared activity status, current plan, and current plan +step stay unspecified and are not claimed here. Where this file and the root +disagree, the root wins and this file is wrong. + +Provider-specific idle/active/child/unknown classification belongs to harness +drivers per [#162](https://github.com/compoundingtech/st2/issues/162); st2 +core owns only the generic envelope, its fencing, freshness, and exposure. +The record-shape and coverage decisions are recorded in +[`.decisions/0006`](../.decisions/0006-observed-harness-state-is-a-driver-written-catalog-record.md). +Delivery gating is explicitly not this subsystem's concern: DING and the +native transports keep their own evidence +([`01-ding/requirements.md`](../01-ding/requirements.md)), and no delivery +path reads this record. + +## Assumptions + +- **OHS-A01 Drivers can observe their harness:** Every maintained harness + offers a positive observation source — the Codex app-server control stream, + Claude lifecycle hooks plus the wrapper's child poll, pi's injected + extension, and — conditionally, until `DQ-H6`'s capture confirms its event + semantics — OpenCode's server surface, which at this layer is the candidate + an experiment must validate before the producer trusts it. Where a source + exists the driver + projects it; where none exists the driver writes nothing, and the absence of + a record is itself honest ("never observed"), never a fabricated state. +- **OHS-A02 Advisory surface:** Consumers are humans, supervisors, Doctor, and + a roster/TUI. The record authorizes nothing: no delivery, no lifecycle + action, no reconciliation. A wrong-but-fresh record can mislead an operator; + it cannot misdeliver a message. +- **OHS-A03 Trusted writers:** The record is unauthenticated catalog state + under the trusted-fleet model (root `A02`). The writers are the owning + session's driver processes — the wrapper that owns the presence lease, and + the channel or hook subprocesses it shares its incarnation token with; + nothing verifies that claim. + +## Acceptable Tradeoffs + +- **OHS-T01 Unmeasured transport cost:** Turn boundaries are far more frequent + than the five-minute presence refresh, and no measurement establishes what a + per-transition replicated write costs on a real catalog under a real + transport (`DQ-H2`). v1 accepts this, bounded by writes only on transition + plus the slow heartbeat. +- **OHS-T02 Blocked is vacuous under bypass:** `blockedOn: human` can only be + produced where a harness asks a human anything. Under Claude + `bypassPermissions` — what `examples/native/agent-claude.kdl` ships — the + axis never fires. The field is still v1: a later-added axis decodes as + `unknown` in every pinned reader, the opposite of a conservative default. +- **OHS-T03 No history:** v1 records only the current state. Measured burst + coalescing (4 transitions per turn, 0.1–0.4 ms apart, #268) would erase any + state shorter than the coalescing window, so a v1 history could not be + truthful about dwell time. `transitions` and `sinceMs` keep a later history + additive. +- **OHS-T04 Ungraceful-death windows:** SIGKILL cannot be caught, so an + external forced kill can leave a live-state record whose writer is gone. + Same-host readers narrow that window with the liveness cross-check + (OHS-R07) where the session is PROVABLY dead — pidfile present, process + gone — while `pty kill` removes the pidfile and leaves the probe + indeterminate for the rest of the window; the next session's written + ownership claim supersedes the orphan at relaunch, and cross-host readers + wait out the staleness horizon. + +## Requirements + +### Must publish one observed envelope + +- **OHS-R01 Observed envelope:** Each agent has at most one observed-state + record, `/harness-state`, schema `st2.harness-state.v1`, written + only by the owning session's driver processes — the wrapper, its channel, + or its hooks; one logical owner per record, sharing one incarnation token, + and nothing outside the driver writes it. It carries the full v1 + tuple: `state ∈ idle | active | child | ended`, `blockedOn ∈ human | none` + (with `ask ∈ none | permission | question | review` naming the kind of + human ask machine-readably, so no consumer branches on `reason`; `review` + is reserved — no maintained producer emits it), + `inputBuffer ∈ empty | nonempty | unknown`, plus the observing harness, a + diagnostic `reason` no consumer branches on, and fencing/freshness fields. + `child` is reserved: part of the contract, decoded by v1 readers, no + producer yet (`DQ-H3`). The record is additive-tolerant on read; unknown + future enum words decode as indeterminate, never as any definite value. +- **OHS-R02 Derived-only unknown:** `unknown` is mandatory in the read + vocabulary, derived, and never written. One constructor produces every + indeterminate observation, each absence carries a distinct reason + (malformed, stale, future-skew, session-dead), and no path derives `idle` — + or anything else — from missing evidence. A missing record reads as "never + observed", which is distinct from `unknown`. +- **OHS-R03 Transport-safe freshness:** Freshness lives in the record bytes: + an embedded origin timestamp with its own staleness and future-skew + constants, deliberately not aliases of the presence constants. No read path + consults file mtime. Every write is byte-distinct, so a transport that + carries content but not metadata always carries a refresh. + +### Must never wake what it informs + +- **OHS-R04 Scoped delivery-input watching:** A write to the observed-state + record wakes no delivery pump, no reconciliation pass, and no watcher owned + by its own writer. Delivery pumps watch their inbox and the presence record, + not the agent directory wholesale. This is a prerequisite: the record sits + in a tree the Codex pump watches unfiltered today. + +### Must be produced by drivers under the evidence rule + +- **OHS-R05 Driver-owned projection:** Classification is driver work. The + Codex producer projects the existing control state with the corrected rows: + `ActiveWithoutTurn`, `ConflictingTurn`, and `Review` report `active` — + review's enter and exit are model-emitted items inside a running turn, so + nothing there awaits a human (matching the projection's + `Held { Review }` → `active` / `blockedOn: none` row) — while + `WaitingOnApproval` and `WaitingOnUserInput` report `active` with + `blockedOn: human`. `Compaction` reports `active`, and + `NotLoaded`/`SystemError`/`AwaitingStatus` withhold rather than write. + `Held` — a delivery predicate — never appears in the published vocabulary. +- **OHS-R06 Heartbeat only on evidence:** A writer re-stamps the record on the + presence cadence only while it still observes its harness, and stops on + evidence loss so the record ages to `unknown` instead of staying confidently + wrong. On teardown the wrapper writes its terminal record — carrying the + exit outcome — *before* any escalation that could take the wrapper itself, + and a terminal record is never re-stamped. +- **OHS-R07 Liveness cross-check:** The record names the pty session whose + liveness vouches for its live states. A same-host reader that positively + proves that session dead — its pidfile present, its process gone — reads + the record as `unknown` even while fresh; an indeterminate probe (an + unreadable registry, or a pidfile `pty kill` already removed) downgrades + nothing — unprovable evidence is never reported as death. The check is a + narrowing, not a closure: what it cannot prove, the relaunch-time written + claim supersedes and the staleness horizon bounds. A fresh `ended` + survives the check: a terminal record is supposed to outlive its writer. +- **OHS-R08 All-harness coverage:** Codex, Claude, pi, and OpenCode each ship + a producer. pi's is evented through the injected extension (the positive + idle signal root `DQ2` asks for). OpenCode reaches driver parity first — + typed driver, session wrapper owning the presence lease, then its producer + and native delivery transport. + +### Must be readable beside declared presence + +- **OHS-R09 Roster join:** `st2 agents --json` carries `observedState` beside + declared `status` in one payload — the wedged-agent comparison (declared + `busy`, observed `idle`) must not require joining two commands. Observed + state is a third independent axis: it never rewrites presence, desired + lifecycle, or `lastActivity`, and the pinned roster wire assertions change + deliberately, in the same change, with the new proof named. +- **OHS-R10 Doctor exposure:** Doctor surfaces observed state for agents it + owns as advisory output — a stale or session-dead record beside a `running` + desired state is worth a warning, never an exit-code failure in v1. + +## Evidence + +The measurements are #268's, taken 2026-08-16/17 on one host and carried with +their original caveats: 1298 presence files all legacy one-line records, 4 +transitions per turn 0.1–0.4 ms apart, Claude hook timelines (blocked entry in +2 of 9 captures, exit in 1), silent Claude death under SIGTERM/SIGKILL, and +the Codex `activeFlags` schema present on all supported codex-cli versions +(#268's first comment). The shipped code evidence is in-repo: the Codex state +machine and its hold reasons, the unfiltered agent-dir watch beside the +presence refresh that writes into it, and `src/harness_state.rs`, which +implements the envelope this file ratifies. diff --git a/docs/vrs/05-harness-state/spec.md b/docs/vrs/05-harness-state/spec.md new file mode 100644 index 00000000..9ad8dfeb --- /dev/null +++ b/docs/vrs/05-harness-state/spec.md @@ -0,0 +1,245 @@ +# Observed harness state specification + +This document specifies the observed-harness-state record. It builds on +[requirements.md](./requirements.md). Declared presence remains in the root +spec's R08 section; terminal delivery remains in +[`01-ding/spec.md`](../01-ding/spec.md) and reads none of this. + +## Status + +Draft. The envelope below is implemented (`src/harness_state.rs`); producers +and exposure land incrementally under +[`DELTA-005`](../.delta/DELTA-005-harness-state-specified-ahead-of-full-implementation.md). +Open questions are tracked in [open-questions.md](./open-questions.md). + +## Scope + +This specification defines the record, its freshness and derivation rules, the +per-harness producers, and the roster/Doctor exposure. It does not define: +transition history or a `--watch` surface (deferred, OHS-T03); idle thresholds, +escalation, or notification policy (#173's, per root `R20`); the withdrawn +host-local hot tier; the cut PTY screen observer; or any `AGENT-SPEC.md` +change (separate authority, root `DQ3`). + +## Overview + +```text + codex app-server claude hooks + pi extension opencode + control stream wrapper child poll (evented) server surface + | | | | + v v v v + [driver-owned projection: idle/active/child/ended × blockedOn (+ask) × inputBuffer] + | + v declared axis (unchanged) + /harness-state /status + st2.harness-state.v1 presence, agent-authored + transition writes + 5-min heartbeat | + | | + +--------------------+-----------------------------+ + v + st2 agents --json (status ∥ observedState) + st2 doctor (advisory) + downstream TUI (spinner, blocked-on-you) +``` + +## Record (OHS-R01, OHS-R03) + +One JSON object, atomically written (tmp sibling + rename), newline-terminated: + +```json +{ + "schema": "st2.harness-state.v1", + "agent": "", + "harness": "codex | claude | pi | opencode", + "state": "idle | active | child | ended", + "blockedOn": "none | human", + "ask": "none | permission | question | review (reserved; no producer emits it)", + "inputBuffer": "empty | nonempty | unknown", + "reason": "", + "exit": "", + "ptySession": "", + "sinceMs": 1787690000000, + "writtenAtMs": 1787690300000, + "transitions": 41 +} +``` + +Field rules, matching `src/harness_state.rs`: + +- `state` on disk is never `unknown` (OHS-R02) and never `child` today + (`DQ-H3`). Readers decode both, plus unrecognized future words as `unknown`. +- `blockedOn` unrecognized words decode indeterminate, never `none` — a v2 + axis value must not read as "not blocked". +- `ask` names the kind of human ask, machine-readably, while `blockedOn` is + `human` (`none` otherwise; unrecognized words decode indeterminate) — the + axis consumers filter on, so nothing branches on diagnostic `reason`. +- `incarnation` is the writing session's token and `seq` its monotonic + ownership sequence. A claim is a WRITTEN act under the record lock: the + session starting up writes an exitless `ended (superseded)` takeover + record carrying its token and a sequence one above the highest this seat + has seen — the maximum of the on-disk record's sequence and the + `.harness-state.seq` floor sidecar, a sibling file (written + stage-and-rename under the same lock on every claim) that keeps claims + monotonic even when the record itself is unreadable. Racing + claimers therefore mint distinct sequences, and a predecessor's + still-fresh live record is superseded at relaunch, where the + pty-name-based probe cannot tell sessions apart; the seat reads + `ended (superseded)` until the session's first real observation. Ownership + — coalescing, heartbeat eligibility, terminal suppression (which applies + only to exit-bearing terminal records, never the claim placeholder) — is + token equality with the claim's direction: a straggler whose claim is + below the on-disk sequence is refused in live and terminal paths alike, + and a token-only writer never claims — it adopts its own session's + records, starts virgin ones, and is refused against foreign tokens. + Sibling writer processes of one session share the claimer's exported + token and sequence; records predating either field decode with an empty + token and sequence zero, which no session owns and any claim supersedes. + The residual: with the record unreadable AND the floor sidecar missing or + damaged, a claim restarts at sequence one and a lingering predecessor + holding a higher sequence could fence it — accepted, because it takes + both files independently damaged, and refusing the claim instead would + wedge the seat permanently. + Every landed write carries a strictly monotonic per-record stamp (never + inherited from beyond the future-skew trust bound) so it stays + byte-distinct even against a same-millisecond predecessor. +- `reason` is diagnostic only; no consumer branches on it. +- `sinceMs` is when the current state was entered and survives heartbeat + re-stamps; `writtenAtMs` is the heartbeat. `transitions` is a monotonic + counter continued across writer restarts; with `writtenAtMs` it keeps every + write byte-distinct. +- Deserialization is additive-tolerant (no `deny_unknown_fields`): a reader + may be older than its writer. + +Constants, deliberately not aliases of the presence constants (OHS-R03): +`HARNESS_STATE_STALE` 15 min, `HARNESS_STATE_REFRESH` 5 min, +`HARNESS_STATE_FUTURE_SKEW` 60 s. + +## Derivation (OHS-R02, OHS-R07) + +What a reader reports, in evaluation order: + +| Evidence | Reads as | Reason | +| --- | --- | --- | +| No record file | no observation (`null`) | never observed ≠ `unknown` | +| Unparseable / non-v1-shaped bytes | `unknown` | `malformed-record`; never falls back to mtime | +| `writtenAtMs` > now + 60 s | `unknown` | `future-skew` | +| `writtenAtMs` ≤ now − 15 min | `unknown` | `stale` | +| Literal `unknown` state (never written by this crate) | `unknown` | `literal-unknown` | +| Live state, same-host probe proves `ptySession` dead | `unknown` | `session-dead` | +| Live state, probe indeterminate | the recorded state | unprovable evidence downgrades nothing | +| `ended`, any probe result | `ended` | a terminal record outlives its writer | +| Otherwise | the recorded tuple | — | + +Every `unknown` row routes through one constructor and blanks every axis; +there is no path from any absence to a definite state. + +## Codex producer (OHS-R05) + +The projection reads the state the control pump already maintains; it adds no +observation path. `Held` never enters the published vocabulary — it is the +complement of steerable, a delivery predicate (decision 0001's boundary). + +| `CodexObservedState` | state | blockedOn | ask | reason | +| --- | --- | --- | --- | --- | +| `AwaitingStatus` | *withhold* | — | — | no evidence yet; the record ages | +| `Idle` | `idle` | `none` | `none` | | +| `Active { turnId }` | `active` | `none` | `none` | | +| `TerminalError { systemError }` | `ended` | `none` | `none` | `systemError` | +| `Held { ActiveWithoutTurn }` | `active` | `none` | `none` | `activeWithoutTurn` — Codex said active; st2 merely cannot name a steerable turn | +| `Held { ConflictingTurn }` | `active` | `none` | `none` | `conflictingTurn` — two turns believed live is maximally active | +| `Held { Review }` | `active` | `none` | `none` | `review` — review's enter and exit are model-emitted items inside a running turn; nothing awaits a human | +| `Held { Compaction }` | `active` | `none` | `none` | `compaction` | +| `Held { WaitingOnApproval }` | `active` | `human` | `permission` | `waitingOnApproval` | +| `Held { WaitingOnUserInput }` | `active` | `human` | `question` | `waitingOnUserInput` | +| `Held { NotLoaded }` | *withhold* | — | — | thread not loaded proves nothing about work | +| `Held { SystemError }` | *withhold* | — | — | see #264's catch-all defect | + +`inputBuffer` is `unknown` from this producer: the control stream does not see +the composer. The projection test must be behavioral — a table that would pass +with every row mapped to `unknown` is not an oracle (#268 §B). + +## Claude producer (OHS-R05, OHS-R06) + +Two cooperating writers. The hook side classifies turn lifecycle: a submitted +prompt or tool activity writes `active`; `Stop` writes `idle`; +`PermissionRequest` writes `active` + `blockedOn: human` with its ask kind +classified from the payload's `tool_name` (`AskUserQuestion` → `question`, +anything else → `permission`) — (its meaning is +specifically "a human is about to be asked" — it fires only under permission +modes that ask). Events carrying `agent_id` are subagent-nested and never move +top-level state. The blocked *exit* edge under batched tool calls is +unspecified until a capture proves a rule (`DQ-H1`); until then the spec +states the limit rather than a rule that cannot hold. The wrapper side owns +liveness: it re-stamps the heartbeat on its existing presence cadence while +the child is alive, and writes the terminal record from its `try_wait` reap +and its SIGTERM path — before any SIGKILL escalation into its own process +group, which no in-process write survives (OHS-T04). + +## pi producer (OHS-R05, OHS-R08) + +The injected extension observes `agent_start`/`agent_end` — a positive, +evented, in-process signal — and reports transitions over the existing +channel; the wrapper writes the record and owns heartbeat and terminal writes. +This is the first producer that satisfies root `DQ2`'s "stronger evented +signal" clause for any harness. pi's composer offers nothing to scrape, so +`inputBuffer` stays `unknown`. + +## OpenCode producer (OHS-R08) + +OpenCode currently has only a DING composer adapter — no typed driver, no +session wrapper, no presence lease. Parity lands in dependency order: the +`Driver` variant and expansion, an `opencode-session` wrapper owning presence +and the observed-state record, the producer from whatever source experiments +verify (`DQ-H6` — the server/SDK event surface is the candidate; screen +observation with documented limits is the fallback), and a native delivery +transport designed against #242's contract shape. + +## Exposure (OHS-R09, OHS-R10) + +`st2 agents --json` (both forms) appends one field per row: + +```json +"observedState": { + "state": "active", + "blockedOn": "human", + "inputBuffer": "unknown", + "ask": "permission", + "harness": "codex", + "since": 1787690000000, + "reason": "waitingOnApproval", + "exit": null +} +``` + +`null` when no record exists. The derivation above is already applied — a +consumer never re-implements staleness. Roster reads pass the same-host +liveness probe only for agents whose resolved host is this host. `status`, +`desiredState`, and `lastActivity` keep their exact meanings; the three +full-string pinned assertions and the stable-roster invariant wording are +updated deliberately in the change that adds the field. Doctor prints an +advisory (not a failure) for an owned agent whose record is stale, +session-dead, or `ended` while desired state is `running`. + +## Verification plan + +The invariant rows this subsystem must add or amend when implementation lands, +each only once a real test proves it (per `CLAUDE.md`): + +- **Scoped delivery-input watching** — a write to `harness-state` does not + wake the Codex delivery pump; an inbox write still does. Test in the shape + of `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`. +- **Stable roster JSON** (existing row) — wording gains the third axis; + `src/agents.rs::agents_json_has_stable_wire_shape` is edited in the same + change and stays the proof. +- **Observed harness state discipline** — derived-only `unknown` with distinct + reasons, byte-distinct writes, no-mtime staleness, terminal-write-before- + escalation. Proving tests live in `src/harness_state.rs` today (11 tests) + plus the planned per-producer suites; the SIGKILL-mid-turn test (`ended`, + not `active`) gates the teardown row. + +## Open design questions + +Tracked with context in [open-questions.md](./open-questions.md): DQ-H1 Claude +blocked-exit edge, DQ-H2 transport cost, DQ-H3 `child` producer, DQ-H4 +ungraceful-death coverage, DQ-H5 supervisor-following, DQ-H6 OpenCode state +source. diff --git a/docs/vrs/ontology.md b/docs/vrs/ontology.md index 368626b6..33d90b66 100644 --- a/docs/vrs/ontology.md +++ b/docs/vrs/ontology.md @@ -117,6 +117,20 @@ st2's runtime observation of whether a task record is alive. Authority: [`reconcile::Session`](../../src/reconcile.rs#L16-L26) +### observed harness state + +The driver-written record of what a harness is seen doing: activity +(`idle`/`active`/`child`/`ended`, with `unknown` derived and never written), +who it is blocked on, and what its input buffer holds. The observed +counterpart of the declared axes: it is not [presence](#presence) (agent- +authored availability), not [session state](#session-state) (task-record +liveness), not R08's *declared activity status*, and not R09's *working +state* (restored context). + +Authority: [`harness_state`](../../src/harness_state.rs); +[05-harness-state requirements](05-harness-state/requirements.md); +[decision 0006](.decisions/0006-observed-harness-state-is-a-driver-written-catalog-record.md) + ### restart policy The declared rules that bound when and how an agent task may be relaunched @@ -335,6 +349,14 @@ explicit `unpark` recovery request. - Use [presence](../../src/status.rs#L24-L45) for the agent-authored signal and [session state](../../src/reconcile.rs#L16-L26) for runtime liveness. Avoid bare *agent status* when either could be meant. +- Use [observed harness state](05-harness-state/requirements.md) for the + driver-observed working signal. It is a third axis beside presence and + session state: R08's *activity status* stays the declared, agent-authored + signal, and neither axis rewrites the other. Bare *activity* does not + identify which is meant. +- *Working state* remains R09's restored durable context and is never a + liveness or activity term; the observed signal is **observed harness + state**, not *working state*. - Use **parked task** or **park decision** for the owning supervisor's policy decision. Do not use *parked* as a session state or replace the runtime observation with it. diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 5d8edd34..fa07bd69 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -1092,12 +1092,22 @@ the resident supervisor continues to reconcile the complete local catalog. This measured screen heuristic is still not an evented proof and renderer changes may defer delivery. Resolve the remaining gap with a stronger evented signal or other measured classifier; a small on-device model is an optional - experiment, not a required architecture. + experiment, not a required architecture. The + [05-harness-state](05-harness-state/spec.md) pi producer is the first + evented signal for any harness (the injected extension's positive idle + observation); it informs observability only — delivery keeps its own + evidence, and no DING path reads the observed-state record. - **DQ3 Remaining catalog agent state:** The R08 presence record above defines the presence path, schema, freshness, and atomic update rules. - Activity status, current plan, and current plan step remain undefined. Prove - their stale-state and supervisor-following behavior before adding their shape - to `AGENT-SPEC.md`. + The *observed* axis is now specified in + [05-harness-state](05-harness-state/requirements.md) (decision 0006): a + driver-written record with its own staleness rules, distinct from declared + presence and from R08's declared activity status. *Declared* activity + status, current plan, and current plan step remain undefined. The + stale-state gate is addressed for the observed axis; the + supervisor-following gate is not + ([DQ-H5](05-harness-state/open-questions.md)) — prove both before adding + any of these shapes to `AGENT-SPEC.md`. - **DQ4 Relaunch boundary (R29-R30):** Preserve R11's nondisruptive adoption while making launch drift visible. For each declared task, derive the desired launch fingerprint from a deterministic, versioned encoding of only: