From 666237af1674c90f89c02dc41fc8dfe3af4b3c4f Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Sun, 23 Aug 2026 18:54:01 +0200 Subject: [PATCH 1/8] docs(vrs): add the 05-harness-state subsystem intent layer Decision 0006 (observed harness state is a driver-written catalog record), the 05-harness-state requirements/spec/open-questions set, ontology terms with collision rules pinning the new axis against presence, session state, declared activity status, and working state, root DQ2/DQ3 updates, and DELTA-005 fencing the spec landing ahead of its producers. Co-Authored-By: Claude Fable 5 --- ...tate-is-a-driver-written-catalog-record.md | 110 +++++++++ ...-specified-ahead-of-full-implementation.md | 44 ++++ docs/vrs/05-harness-state/open-questions.md | 56 +++++ docs/vrs/05-harness-state/requirements.md | 144 ++++++++++++ docs/vrs/05-harness-state/spec.md | 209 ++++++++++++++++++ docs/vrs/ontology.md | 22 ++ docs/vrs/spec.md | 18 +- 7 files changed, 599 insertions(+), 4 deletions(-) create mode 100644 docs/vrs/.decisions/0006-observed-harness-state-is-a-driver-written-catalog-record.md create mode 100644 docs/vrs/.delta/DELTA-005-harness-state-specified-ahead-of-full-implementation.md create mode 100644 docs/vrs/05-harness-state/open-questions.md create mode 100644 docs/vrs/05-harness-state/requirements.md create mode 100644 docs/vrs/05-harness-state/spec.md 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..4f3d55a8 --- /dev/null +++ b/docs/vrs/05-harness-state/open-questions.md @@ -0,0 +1,56 @@ +# 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 close the window via the liveness cross-check; + cross-host readers wait out the fifteen-minute horizon. 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..8c3673a9 --- /dev/null +++ b/docs/vrs/05-harness-state/requirements.md @@ -0,0 +1,144 @@ +# 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, OpenCode's server surface. 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 writer is the session + wrapper that owns the presence lease; 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 — and nothing else — can leave a live-state record + whose writer is gone. Same-host readers close that window with the liveness + cross-check (OHS-R07); 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 driver wrapper owning the live session. It carries the full v1 + tuple: `state ∈ idle | active | child | ended`, `blockedOn ∈ human | none`, + `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` and `ConflictingTurn` report `active` (Codex positively + reported activity), `Review`, `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 reads the record as `unknown` even while fresh; an + indeterminate probe (unreadable registry) downgrades nothing — unprovable + evidence is never reported as death. 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..907f80ff --- /dev/null +++ b/docs/vrs/05-harness-state/spec.md @@ -0,0 +1,209 @@ +# 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 × 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", + "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". +- `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 all three axes; +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 | reason | +| --- | --- | --- | --- | +| `AwaitingStatus` | *withhold* | — | no evidence yet; the record ages | +| `Idle` | `idle` | `none` | | +| `Active { turnId }` | `active` | `none` | | +| `TerminalError { systemError }` | `ended` | `none` | `systemError` | +| `Held { ActiveWithoutTurn }` | `active` | `none` | `activeWithoutTurn` — Codex said active; st2 merely cannot name a steerable turn | +| `Held { ConflictingTurn }` | `active` | `none` | `conflictingTurn` — two turns believed live is maximally active | +| `Held { Review }` | `active` | `human` | `review` | +| `Held { Compaction }` | `active` | `none` | `compaction` | +| `Held { WaitingOnApproval }` | `active` | `human` | `waitingOnApproval` | +| `Held { WaitingOnUserInput }` | `active` | `human` | `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` (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", + "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: From b1bf9c531885698cdc2a72ba901500aeb305e1ab Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 24 Aug 2026 00:07:35 +0200 Subject: [PATCH 2/8] docs(vrs): the intent layer carries the ask axis, token ownership, and the real writer set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 contract states ask and incarnation beside the original tuple, the codex/claude producer sections name their ask kinds, and OHS-R01/OHS-A03 name the actual writers — the owning session's driver processes sharing one incarnation token — instead of the wrapper alone. Co-Authored-By: Claude Fable 5 --- docs/vrs/05-harness-state/requirements.md | 14 +++++-- docs/vrs/05-harness-state/spec.md | 46 ++++++++++++++--------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/docs/vrs/05-harness-state/requirements.md b/docs/vrs/05-harness-state/requirements.md index 8c3673a9..6e82fd3c 100644 --- a/docs/vrs/05-harness-state/requirements.md +++ b/docs/vrs/05-harness-state/requirements.md @@ -34,8 +34,10 @@ path reads this record. 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 writer is the session - wrapper that owns the presence lease; nothing verifies that claim. + 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 @@ -65,8 +67,12 @@ path reads this record. - **OHS-R01 Observed envelope:** Each agent has at most one observed-state record, `/harness-state`, schema `st2.harness-state.v1`, written - only by the driver wrapper owning the live session. It carries the full v1 - tuple: `state ∈ idle | active | child | ended`, `blockedOn ∈ human | none`, + 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`), `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 diff --git a/docs/vrs/05-harness-state/spec.md b/docs/vrs/05-harness-state/spec.md index 907f80ff..60408870 100644 --- a/docs/vrs/05-harness-state/spec.md +++ b/docs/vrs/05-harness-state/spec.md @@ -28,7 +28,7 @@ change (separate authority, root `DQ3`). control stream wrapper child poll (evented) server surface | | | | v v v v - [driver-owned projection: idle/active/child/ended × blockedOn × inputBuffer] + [driver-owned projection: idle/active/child/ended × blockedOn (+ask) × inputBuffer] | v declared axis (unchanged) /harness-state /status @@ -53,6 +53,7 @@ One JSON object, atomically written (tmp sibling + rename), newline-terminated: "harness": "codex | claude | pi | opencode", "state": "idle | active | child | ended", "blockedOn": "none | human", + "ask": "none | permission | question | review", "inputBuffer": "empty | nonempty | unknown", "reason": "", "exit": "", @@ -69,6 +70,14 @@ Field rules, matching `src/harness_state.rs`: (`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: ownership — coalescing, + heartbeat eligibility, terminal suppression — is token equality, never a + timestamp comparison. Sibling writer processes of one session share the + token; every landed write carries a strictly monotonic per-record stamp 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 @@ -97,7 +106,7 @@ What a reader reports, in evaluation order: | `ended`, any probe result | `ended` | a terminal record outlives its writer | | Otherwise | the recorded tuple | — | -Every `unknown` row routes through one constructor and blanks all three axes; +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) @@ -106,20 +115,20 @@ 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 | reason | -| --- | --- | --- | --- | -| `AwaitingStatus` | *withhold* | — | no evidence yet; the record ages | -| `Idle` | `idle` | `none` | | -| `Active { turnId }` | `active` | `none` | | -| `TerminalError { systemError }` | `ended` | `none` | `systemError` | -| `Held { ActiveWithoutTurn }` | `active` | `none` | `activeWithoutTurn` — Codex said active; st2 merely cannot name a steerable turn | -| `Held { ConflictingTurn }` | `active` | `none` | `conflictingTurn` — two turns believed live is maximally active | -| `Held { Review }` | `active` | `human` | `review` | -| `Held { Compaction }` | `active` | `none` | `compaction` | -| `Held { WaitingOnApproval }` | `active` | `human` | `waitingOnApproval` | -| `Held { WaitingOnUserInput }` | `active` | `human` | `waitingOnUserInput` | -| `Held { NotLoaded }` | *withhold* | — | thread not loaded proves nothing about work | -| `Held { SystemError }` | *withhold* | — | see #264's catch-all defect | +| `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` | `human` | `review` | `review` | +| `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 @@ -129,7 +138,9 @@ with every row mapped to `unknown` is not an oracle (#268 §B). 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` (its meaning is +`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 @@ -168,6 +179,7 @@ transport designed against #242's contract shape. "state": "active", "blockedOn": "human", "inputBuffer": "unknown", + "ask": "permission", "harness": "codex", "since": 1787690000000, "reason": "waitingOnApproval", From 6aeb7b3f2e1617cdef9fbd6e43b4839d1c654e72 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 24 Aug 2026 00:42:02 +0200 Subject: [PATCH 3/8] docs(vrs): the record example carries its ownership fields incarnation and seq appear in the wire example with the directional ownership rule and the legacy missing-field derivation stated beside them. Co-Authored-By: Claude Fable 5 --- docs/vrs/05-harness-state/spec.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/vrs/05-harness-state/spec.md b/docs/vrs/05-harness-state/spec.md index 60408870..a79da622 100644 --- a/docs/vrs/05-harness-state/spec.md +++ b/docs/vrs/05-harness-state/spec.md @@ -73,11 +73,18 @@ Field rules, matching `src/harness_state.rs`: - `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: ownership — coalescing, - heartbeat eligibility, terminal suppression — is token equality, never a - timestamp comparison. Sibling writer processes of one session share the - token; every landed write carries a strictly monotonic per-record stamp so - it stays byte-distinct even against a same-millisecond predecessor. +- `incarnation` is the writing session's token and `seq` its monotonic + ownership sequence: ownership — coalescing, heartbeat eligibility, terminal + suppression — is token equality, and only a session claim advances the + sequence (to the on-disk value plus one), which gives ownership a + direction: a straggler from a superseded session is refused in live and + terminal paths alike. Sibling writer processes of one session share the + claimer's exported token and sequence; records from writers predating + either field decode with an empty token and sequence zero, which no + session owns and any claim supersedes. 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 From bccdf08c1fec91defaa7a7204d4ecb4257722458 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 24 Aug 2026 01:21:15 +0200 Subject: [PATCH 4/8] docs(vrs): written claims in the contract, and an honestly scoped cross-check OHS-T04/OHS-R07 no longer overclaim: the probe narrows the ungraceful window only for provably dead sessions (pidfile present, process gone), pty kill's pidfile removal is named, and the relaunch-time written claim is what supersedes what the probe cannot prove. The ownership bullet carries the written-claim semantics. Co-Authored-By: Claude Fable 5 --- docs/vrs/05-harness-state/requirements.md | 21 ++++++++++------ docs/vrs/05-harness-state/spec.md | 30 ++++++++++++++--------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/vrs/05-harness-state/requirements.md b/docs/vrs/05-harness-state/requirements.md index 6e82fd3c..d41273c6 100644 --- a/docs/vrs/05-harness-state/requirements.md +++ b/docs/vrs/05-harness-state/requirements.md @@ -57,9 +57,13 @@ path reads this record. 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 — and nothing else — can leave a live-state record - whose writer is gone. Same-host readers close that window with the liveness - cross-check (OHS-R07); cross-host readers wait out the staleness horizon. + 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 @@ -115,10 +119,13 @@ path reads this record. 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 reads the record as `unknown` even while fresh; an - indeterminate probe (unreadable registry) downgrades nothing — unprovable - evidence is never reported as death. A fresh `ended` survives the check: a - terminal record is supposed to outlive its writer. + 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 — diff --git a/docs/vrs/05-harness-state/spec.md b/docs/vrs/05-harness-state/spec.md index a79da622..f01c479c 100644 --- a/docs/vrs/05-harness-state/spec.md +++ b/docs/vrs/05-harness-state/spec.md @@ -74,17 +74,25 @@ Field rules, matching `src/harness_state.rs`: `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: ownership — coalescing, heartbeat eligibility, terminal - suppression — is token equality, and only a session claim advances the - sequence (to the on-disk value plus one), which gives ownership a - direction: a straggler from a superseded session is refused in live and - terminal paths alike. Sibling writer processes of one session share the - claimer's exported token and sequence; records from writers predating - either field decode with an empty token and sequence zero, which no - session owns and any claim supersedes. 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. + 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 the on-disk sequence plus one — 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. + 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 From 19fcb67df86db698b0478699fe1575e129643120 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 24 Aug 2026 02:18:31 +0200 Subject: [PATCH 5/8] =?UTF-8?q?docs(vrs):=20review=20is=20a=20reserved=20a?= =?UTF-8?q?sk=20word=20=E2=80=94=20codex=20review=20holds=20nothing=20on?= =?UTF-8?q?=20a=20human?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/vrs/05-harness-state/requirements.md | 3 ++- docs/vrs/05-harness-state/spec.md | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/vrs/05-harness-state/requirements.md b/docs/vrs/05-harness-state/requirements.md index d41273c6..f2eab385 100644 --- a/docs/vrs/05-harness-state/requirements.md +++ b/docs/vrs/05-harness-state/requirements.md @@ -76,7 +76,8 @@ path reads this record. 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`), + 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 diff --git a/docs/vrs/05-harness-state/spec.md b/docs/vrs/05-harness-state/spec.md index f01c479c..60ea61c2 100644 --- a/docs/vrs/05-harness-state/spec.md +++ b/docs/vrs/05-harness-state/spec.md @@ -53,7 +53,7 @@ One JSON object, atomically written (tmp sibling + rename), newline-terminated: "harness": "codex | claude | pi | opencode", "state": "idle | active | child | ended", "blockedOn": "none | human", - "ask": "none | permission | question | review", + "ask": "none | permission | question | review (reserved; no producer emits it)", "inputBuffer": "empty | nonempty | unknown", "reason": "", "exit": "", @@ -138,7 +138,7 @@ complement of steerable, a delivery predicate (decision 0001's boundary). | `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` | `human` | `review` | `review` | +| `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` | From b11d339274b346fe5dc817b9562c2f7c10d14b6f Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 24 Aug 2026 02:45:08 +0200 Subject: [PATCH 6/8] docs(vrs): the OpenCode source stays conditional at this layer, and the ungraceful window is honest same-host too Co-Authored-By: Claude Fable 5 --- docs/vrs/05-harness-state/open-questions.md | 7 +++++-- docs/vrs/05-harness-state/requirements.md | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/vrs/05-harness-state/open-questions.md b/docs/vrs/05-harness-state/open-questions.md index 4f3d55a8..9e9d32ec 100644 --- a/docs/vrs/05-harness-state/open-questions.md +++ b/docs/vrs/05-harness-state/open-questions.md @@ -31,8 +31,11 @@ hypotheses. - **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 close the window via the liveness cross-check; - cross-host readers wait out the fifteen-minute horizon. Resolves by: either + 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 diff --git a/docs/vrs/05-harness-state/requirements.md b/docs/vrs/05-harness-state/requirements.md index f2eab385..5bd976e6 100644 --- a/docs/vrs/05-harness-state/requirements.md +++ b/docs/vrs/05-harness-state/requirements.md @@ -26,7 +26,10 @@ path reads this record. - **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, OpenCode's server surface. Where a source exists the driver + 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 From c49fceca6431db083d01dee4a355c310cb39cd4c Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Mon, 24 Aug 2026 06:25:59 +0200 Subject: [PATCH 7/8] docs(vrs): the claim sequence is floor-aware, and the sidecar and its residual are on the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec now states the shipped algorithm — a claim mints one above max(on-disk sequence, .harness-state.seq floor) — and documents the sidecar file and the accepted both-files-damaged residual instead of asserting a plain on-disk-plus-one. Co-Authored-By: Claude Fable 5 --- docs/vrs/05-harness-state/spec.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/vrs/05-harness-state/spec.md b/docs/vrs/05-harness-state/spec.md index 60ea61c2..9ad8dfeb 100644 --- a/docs/vrs/05-harness-state/spec.md +++ b/docs/vrs/05-harness-state/spec.md @@ -76,7 +76,11 @@ Field rules, matching `src/harness_state.rs`: - `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 the on-disk sequence plus one — racing + 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 @@ -90,6 +94,11 @@ Field rules, matching `src/harness_state.rs`: 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. From 789f91f2b2516b631d62d4dacbe72e3b2ee0e198 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:30:49 +0200 Subject: [PATCH 8/8] docs(vrs): review is not a human-blocked state in OHS-R05 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The normative requirement still grouped Review under blockedOn:human while the corrected projection maps Held{Review} to active with no block — a producer built from the requirement would report 'blocked on you' during autonomous review. The grouping now matches the projection. Co-Authored-By: Claude Fable 5 agent-identity: unknown agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.3 agent-runtime: OMP 18.0.3 tooling-profile: dotfiles@f33cd9c-dirty --- docs/vrs/05-harness-state/requirements.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/vrs/05-harness-state/requirements.md b/docs/vrs/05-harness-state/requirements.md index 5bd976e6..ee0834ef 100644 --- a/docs/vrs/05-harness-state/requirements.md +++ b/docs/vrs/05-harness-state/requirements.md @@ -110,9 +110,12 @@ path reads this record. - **OHS-R05 Driver-owned projection:** Classification is driver work. The Codex producer projects the existing control state with the corrected rows: - `ActiveWithoutTurn` and `ConflictingTurn` report `active` (Codex positively - reported activity), `Review`, `WaitingOnApproval`, and `WaitingOnUserInput` - report `active` with `blockedOn: human`, `Compaction` reports `active`, and + `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