Skip to content

Commit 285d1ff

Browse files
schicklingclaude
andcommitted
docs(vrs): session-owned writes, fenced live records, and the ask axis
Spec, requirements, and ontology align with cycle 2: the session-takeover write-through rule, ptySession required for live states (unfenced-record derivation), unreadable-record and unsupported-schema rows, the ask kind across the vocabulary and all four producer tables, stop-aware codex startup, question re-seeding on reconnect, and the probe root resolving exactly as the runner does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 75f9443 commit 285d1ff

3 files changed

Lines changed: 71 additions & 33 deletions

File tree

docs/vrs/05-harness-state/requirements.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ path reads this record.
6666
- **OHS-R01 Observed envelope:** Each agent has at most one observed-state
6767
record, `<agent-dir>/harness-state`, schema `st2.harness-state.v1`, written
6868
only by the driver wrapper owning the live session. It carries the full v1
69-
tuple: `state ∈ idle | active | child | ended`, `blockedOn ∈ human | none`,
69+
tuple: `state ∈ idle | active | child | ended`, `blockedOn ∈ human | none`
70+
(with `ask ∈ none | permission | question | review` naming the kind of human
71+
ask machine-readably, so no consumer branches on `reason`),
7072
`inputBuffer ∈ empty | nonempty | unknown`, plus the observing harness, a
7173
diagnostic `reason` no consumer branches on, and fencing/freshness fields.
7274
`child` is reserved: part of the contract, decoded by v1 readers, no

docs/vrs/05-harness-state/spec.md

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ One JSON object, atomically written (tmp sibling + rename), newline-terminated:
5555
"harness": "codex | claude | pi | opencode",
5656
"state": "idle | active | child | ended",
5757
"blockedOn": "none | human",
58+
"ask": "none | permission | question | review",
5859
"inputBuffer": "empty | nonempty | unknown",
5960
"reason": "<diagnostic, optional>",
6061
"exit": "<ended only: e.g. 'exit 0', 'signal 9', optional>",
61-
"ptySession": "<session name for the liveness cross-check, optional>",
62+
"ptySession": "<the wrapper's runtime/task ID; required for live states>",
6263
"sinceMs": 1787690000000,
6364
"writtenAtMs": 1787690300000,
6465
"transitions": 41
@@ -71,11 +72,29 @@ Field rules, matching `src/harness_state.rs`:
7172
(`DQ-H3`). Readers decode both, plus unrecognized future words as `unknown`.
7273
- `blockedOn` unrecognized words decode indeterminate, never `none` — a v2
7374
axis value must not read as "not blocked".
75+
- `ask` names what kind of human ask holds the harness, machine-readably —
76+
the axis consumers filter on (`reason` stays diagnostic). Meaningful only
77+
while `blockedOn` is `human`; writers emit `none` otherwise, records from
78+
writers predating the axis default to `none`, and unrecognized future words
79+
decode indeterminate.
7480
- `reason` is diagnostic only; no consumer branches on it.
81+
- `ptySession` fences every live state: a writer refuses a live observation
82+
that names no session (only `ended` may omit it), because a live record the
83+
probe cannot check would stay definite straight through an external SIGKILL.
7584
- `sinceMs` is when the current state was entered and survives heartbeat
7685
re-stamps; `writtenAtMs` is the heartbeat. `transitions` is a monotonic
7786
counter continued across writer restarts; with `writtenAtMs` it keeps every
7887
write byte-distinct.
88+
- Writes are session-owned. A restatement is a no-op only against a record
89+
the same session already wrote: a new session's first observation always
90+
writes through a matching fresh predecessor (otherwise the takeover never
91+
claims the record and the heartbeat-eligibility gate lets it quietly age
92+
out mid-turn), and every producer marks its session boundary discontinuous
93+
— a long-lived writer starts interrupted, Claude's `SessionStart` hook
94+
interrupts — so `sinceMs` never spans a restart. Heartbeats and coalescing
95+
never touch a record whose schema or session the writer does not own; a
96+
foreign-schema record is left byte-identical by heartbeats and replaced
97+
wholesale by a genuine observation.
7998
- Deserialization is additive-tolerant (no `deny_unknown_fields`): a reader
8099
may be older than its writer.
81100

@@ -90,11 +109,14 @@ What a reader reports, in evaluation order:
90109
| Evidence | Reads as | Reason |
91110
| --- | --- | --- |
92111
| No record file | no observation (`null`) | never observed ≠ `unknown` |
112+
| File exists but cannot be read | `unknown` | `unreadable-record`; an IO error is indeterminate, never absence |
93113
| Unparseable / non-v1-shaped bytes | `unknown` | `malformed-record`; never falls back to mtime |
114+
| `schema` is not `st2.harness-state.v1` | `unknown` | `unsupported-schema`; a future schema's words may be spelled like this version's while meaning something else |
94115
| `writtenAtMs` > now + 60 s | `unknown` | `future-skew` |
95116
| `writtenAtMs` ≤ now − 15 min | `unknown` | `stale` |
96117
| Literal `unknown` state (never written by this crate) | `unknown` | `literal-unknown` |
97118
| Live state, same-host probe proves `ptySession` dead | `unknown` | `session-dead` |
119+
| Live state, probe available, record names no `ptySession` | `unknown` | `unfenced-record`; nothing to check is not the same as checked |
98120
| Live state, probe indeterminate | the recorded state | unprovable evidence downgrades nothing |
99121
| `ended`, any probe result | `ended` | a terminal record outlives its writer |
100122
| Otherwise | the recorded tuple ||
@@ -116,24 +138,29 @@ The projection reads the state the control pump already maintains; it adds no
116138
observation path. `Held` never enters the published vocabulary — it is the
117139
complement of steerable, a delivery predicate (decision 0001's boundary).
118140

119-
| `CodexObservedState` | state | blockedOn | reason |
120-
| --- | --- | --- | --- |
121-
| `AwaitingStatus` | *withhold* || no evidence yet; the record ages |
122-
| `Idle` | `idle` | `none` | |
123-
| `Active { turnId }` | `active` | `none` | |
124-
| `TerminalError { systemError }` | `ended` | `none` | `systemError` |
125-
| `Held { ActiveWithoutTurn }` | `active` | `none` | `activeWithoutTurn` — Codex said active; st2 merely cannot name a steerable turn |
126-
| `Held { ConflictingTurn }` | `active` | `none` | `conflictingTurn` — two turns believed live is maximally active |
127-
| `Held { Review }` | `active` | `human` | `review` |
128-
| `Held { Compaction }` | `active` | `none` | `compaction` |
129-
| `Held { WaitingOnApproval }` | `active` | `human` | `waitingOnApproval` |
130-
| `Held { WaitingOnUserInput }` | `active` | `human` | `waitingOnUserInput` |
131-
| `Held { NotLoaded }` | *withhold* || thread not loaded proves nothing about work |
132-
| `Held { SystemError }` | *withhold* || see #264's catch-all defect |
141+
| `CodexObservedState` | state | blockedOn | ask | reason |
142+
| --- | --- | --- | --- | --- |
143+
| `AwaitingStatus` | *withhold* || | no evidence yet; the record ages |
144+
| `Idle` | `idle` | `none` | `none` | |
145+
| `Active { turnId }` | `active` | `none` | `none` | |
146+
| `TerminalError { systemError }` | `ended` | `none` | `none` | `systemError` |
147+
| `Held { ActiveWithoutTurn }` | `active` | `none` | `none` | `activeWithoutTurn` — Codex said active; st2 merely cannot name a steerable turn |
148+
| `Held { ConflictingTurn }` | `active` | `none` | `none` | `conflictingTurn` — two turns believed live is maximally active |
149+
| `Held { Review }` | `active` | `human` | `review` | `review` |
150+
| `Held { Compaction }` | `active` | `none` | `none` | `compaction` |
151+
| `Held { WaitingOnApproval }` | `active` | `human` | `permission` | `waitingOnApproval` |
152+
| `Held { WaitingOnUserInput }` | `active` | `human` | `question` | `waitingOnUserInput` |
153+
| `Held { NotLoaded }` | *withhold* || | thread not loaded proves nothing about work |
154+
| `Held { SystemError }` | *withhold* || | see #264's catch-all defect |
133155

134156
`inputBuffer` is `unknown` from this producer: the control stream does not see
135157
the composer. The projection test must be behavioral — a table that would pass
136-
with every row mapped to `unknown` is not an oracle (#268 §B).
158+
with every row mapped to `unknown` is not an oracle (#268 §B). The wrapper
159+
honors st2's stop signal through every startup phase (socket connect,
160+
initialize, thread binding), not only the bound monitor loop: a stop before
161+
the TUI exists ends the launch gracefully and leaves no record — nothing was
162+
observed — while a stop after it exits through the ordinary terminal-write
163+
path.
137164

138165
## Claude producer (OHS-R05, OHS-R06)
139166

@@ -143,7 +170,8 @@ Two cooperating writers. The hook side (`claude-observe` →
143170
`PreToolUse`, `PostToolUse`) writes `active`; `Stop` writes `idle`;
144171
`PermissionRequest` writes `active` + `blockedOn: human` (its meaning is
145172
specifically "a human is about to be asked" — it fires only under permission
146-
modes that ask). Events carrying `agent_id` are subagent-nested and never move
173+
modes that ask), classifying its ask kind from the payload's `tool_name`:
174+
`AskUserQuestion` is a `question`, anything else a `permission`. Events carrying `agent_id` are subagent-nested and never move
147175
top-level state. The blocked *exit* edge is the next `PreToolUse`,
148176
`PostToolUse`, or `Stop` — measured-correct, not merely conservative: the
149177
2026-08-23 batched-permission capture (`DQ-H1`) shows tool execution
@@ -188,23 +216,25 @@ allocates a loopback port and a per-seat password, launches the TUI bound to
188216
them, owns the presence lease and the observed-state record, and projects the
189217
`/event` SSE stream:
190218

191-
| Signal | state | blockedOn | reason |
192-
|---|---|---|---|
193-
| `session.status {type: busy}` | `active` | `none` ||
194-
| `session.status {type: retry}` | `active` | `none` | `retry` |
195-
| `session.status {type: idle}` / `session.idle` | `idle` | `none` ||
196-
| `permission.asked``permission.replied` (same ask id; spelled `id` on entry, `requestID` on exit — measured) | `active` | `human` | `permission` |
197-
| `question.asked``question.replied\|rejected` (same ask id, same spelling split) | `active` | `human` | `question` |
198-
| `session.error {ProviderAuthError}` | `ended` | `none` | `providerAuth` |
199-
| `session.error` (other arms) | `idle` | `none` | `error:<name>` |
200-
| child exit / stop path | `ended` | `none` | exit status |
219+
| Signal | state | blockedOn | ask | reason |
220+
|---|---|---|---|---|
221+
| `session.status {type: busy}` | `active` | `none` | `none` | |
222+
| `session.status {type: retry}` | `active` | `none` | `none` | `retry` |
223+
| `session.status {type: idle}` / `session.idle` | `idle` | `none` | `none` | |
224+
| `permission.asked``permission.replied` (same ask id; spelled `id` on entry, `requestID` on exit — measured) | `active` | `human` | `permission` | `permission` |
225+
| `question.asked``question.replied\|rejected` (same ask id, same spelling split) | `active` | `human` | `question` | `question` |
226+
| `session.error {ProviderAuthError}` | `ended` | `none` | `none` | `providerAuth` |
227+
| `session.error` (other arms) | `idle` | `none` | `none` | `error:<name>` |
228+
| child exit / stop path | `ended` | `none` | `none` | exit status |
201229

202230
A dedicated seat aggregates across the server's sessions: any busy session is
203231
activity, any open ask is a human block, and idle requires positive level
204232
evidence (`/session/status` omits idle sessions, so an empty map over a live
205-
server is the idle proof, re-read on every SSE (re)connect). A dropped stream
206-
stops the heartbeat; `inputBuffer` stays `unknown` — the `/tui/*` surface is
207-
write-only.
233+
server is the idle proof, re-read on every SSE (re)connect). Asks open across
234+
a reconnect are recovered from both pending listings — `GET /permission` and
235+
`GET /question`, each measured on 1.18.19 — with their ids kept so the
236+
id-matched exits still release them. A dropped stream stops the heartbeat;
237+
`inputBuffer` stays `unknown` — the `/tui/*` surface is write-only.
208238

209239
The native delivery transport mirrors the Codex FIFO discipline: an
210240
`Attempted` receipt persisted before transport, `POST
@@ -228,6 +258,7 @@ yet created one waits rather than creating sessions itself.
228258
"state": "active",
229259
"blockedOn": "human",
230260
"inputBuffer": "unknown",
261+
"ask": "permission",
231262
"harness": "codex",
232263
"since": 1787690000000,
233264
"reason": "waitingOnApproval",
@@ -237,7 +268,11 @@ yet created one waits rather than creating sessions itself.
237268

238269
`null` when no record exists. The derivation above is already applied — a
239270
consumer never re-implements staleness. Roster reads pass the same-host
240-
liveness probe only for agents whose resolved host is this host. `status`,
271+
liveness probe only for agents whose resolved host is this host, resolving
272+
the pty root exactly as the runner does (`PTY_ROOT`, else the catalog's own
273+
pty root; the legacy `PTY_SESSION_DIR` is deliberately not honored — the
274+
runner never uses it, and probing a directory st2-managed sessions never
275+
touch turns provable deaths into indeterminate reads). `status`,
241276
`desiredState`, and `lastActivity` keep their exact meanings; the three
242277
full-string pinned assertions and the stable-roster invariant wording are
243278
updated deliberately in the change that adds the field. Doctor prints an

docs/vrs/ontology.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ Authority: [`reconcile::Session`](../../src/reconcile.rs#L16-L26)
121121

122122
The driver-written record of what a harness is seen doing: activity
123123
(`idle`/`active`/`child`/`ended`, with `unknown` derived and never written),
124-
who it is blocked on, and what its input buffer holds. The observed
124+
who it is blocked on — and, when blocked on a human, what kind of ask holds
125+
it (`permission`/`question`/`review`) — and what its input buffer holds. The observed
125126
counterpart of the declared axes: it is not [presence](#presence) (agent-
126127
authored availability), not [session state](#session-state) (task-record
127128
liveness), not R08's *declared activity status*, and not R09's *working

0 commit comments

Comments
 (0)