Skip to content

Observed harness state: the activity-lease slice of #162, specified and made readable #268

Description

@schickling

This is a child of #162 — the activity-lease slice of that boundary, measured and made readable. Driver-side classification stays where #162 assigns it; this asks only for the generic envelope and its exposure.

Context

We are a downstream consumer of st2 running one catalog with 627 seats. This is a suggestion from
that vantage point, not a request; the shape below is offered only so there is something concrete to
react to.

#62 draws a boundary this proposal has to satisfy, and states it directly:

A structured durable-state inventory is a separate consumer contract and should be proposed
separately only when a concrete consumer needs it.

The concrete consumer is the same one named in #261: our TUI renders a seat's working state. It
can render what a seat declares. It cannot render what a seat is observed to be doing, because
nothing outside the Codex wrapper can read that. This proposal is the separate one #62 invites, with
the consumer named.

Relationship to #261, which is a sibling and not a competitor. #261 asks for the declared
working-state document to have a Resource identity. This asks for an observed harness signal to
have an envelope and a reader. They are complementary and deliberately separate: one is what the
agent says about itself, the other is what the harness is seen doing, and §E below exists precisely
so an operator can see the two disagree. Neither subsumes the other, and neither should be blocked on
the other.

One measurement that motivates the separation, scoped narrowly. Across every catalog on this host
there are 1298 status files — 1176 of them in the live default catalog — and zero contain
a version 1 record. All are legacy one-line values: 1217 available, 63 busy, 8 away, 7 dnd,
3 offline. Read this only as what it is: on this host, the presence path is entirely legacy
records, so either the merged version 1 code is not deployed here or these seats have not been
restarted onto it. It is not a claim about the fleet, and it is not a defect in #254. What it
does support is narrower and sufficient: declared presence is a cooperative signal on a five-minute
timer, and a reader cannot currently assume even its version. An observed signal is a different axis,
not a better spelling of this one.

Goal

#162 lists, under "Generic protocols st2 may consume", activity leases
(idle, active, child, unknown)
and composer guards (empty, nonempty, unknown),
and it assigns "provider-specific idle/active/child/unknown classification" to drivers while
keeping the generic envelope and fail-closed consumption of driver evidence in st2 core. This issue
is the activity-lease half of that, specified and made readable outside the wrapper that owns it.

Driver-side ownership is yours and is not in question here. What this asks for is the generic
envelope, its fencing, and its exposure — which #162 already places in core.

Name. The signal is observed harness state, deliberately not "activity status".
docs/vrs/requirements.md:120-122 (R08) reserves "declared activity status" for an
agent-authored signal, and src/agents.rs:98-99 already ships lastActivity in the roster. Three
meanings of "activity" in one payload would be the terminology defect #84 drew CHANGES_REQUESTED for.
The ontology entry and a ## Collision rules line separating it from presence and session state
ship with it.

Where it belongs in the VRS. It is the observed half of DQ3 Remaining catalog agent state
(docs/vrs/spec.md:957+, "Activity status, current plan, and current plan step remain undefined.
Prove their stale-state and supervisor-following behavior before adding their shape"
) and the evented
signal DQ2 asks for. DQ3's supervisor-following half is not proved by anything below — see
## Limits.

Implementation fact: the concept is already half-built and locked in one wrapper

src/codex_app_server.rs:160-189 defines CodexObservedState with a doc comment that already reads
as an observability contract. It is persisted as st2.codex-control-state.v1
(src/codex_app_server.rs:41) at
$XDG_STATE_HOME/st2/codex/<runtime_key(catalog_root, identity)>/control-state.json (:1082,
:2108). runtime_key is a hash; the directory is host-local, outside the catalog, and not
replicated. So the state exists, is typed, and is durable — and is invisible to R08 catalog
observability, to st2 agents, to Doctor, and to every remote supervisor. Claude has no counterpart
at all: src/claude_mcp.rs and src/claude_session.rs contain no observed-state type.

Preferred delivery path

Five separable pieces, in dependency order. Each is a - [ ] block that can be lifted into its own
issue unchanged.


§ A — Publish an observed-harness-state record, reusing the DING-R12 vocabulary

The state words are #123's, not new ones. idle | active | child | unknown crossed with
inputBuffer ∈ empty | nonempty | unknown is DING-R12 from PR #123, which #162 lists as a generic
protocol st2 consumes. This publishes that same tuple for non-delivery consumers, spelled the way
#123 spells it. Nothing about the vocabulary is re-proposed.

The one deliberate difference is direction of use. #123 ingests a lease to authorize a PTY write:
strict, validForMs capped at two seconds, fails closed. This publishes for a roster, Doctor and a
CLI: durable, with a staleness horizon instead of a lease, and additive-tolerant rather than
deny_unknown_fields, because a reader pinned to an older crate may be older than the binary. #123
is strict correctly because it authorizes a write.

child is reserved, and no producer emits it yet. The Codex projection in §B yields only idle,
active and unknown across all six hold reasons and the three non-held arms; Claude's hooks give
turn boundaries and permission requests and nothing that marks "a twenty-minute test run is blocking
the pane". The producer that would have supplied child was the PTY screen observer, which is cut
(see ## Limits). The word is kept because it is #123's, and because §C's argument leans on the
reasoning that kept it — not because anything writes it today. Say so in the spec rather than
shipping a four-value vocabulary that only ever emits three.

unknown is mandatory, derived, and never written, mirroring src/status.rs and R23
("unprovable evidence … is never reported as absence"). Enforce it structurally: one constructor for
an indeterminate observation, so no path can produce idle from missing evidence.

Write-side self-wake — a prerequisite, not a footnote. src/codex_app_server.rs:326 arms an
unfiltered recursive watcher on the whole agent directory
(watch_recursive_mutations(&config.agent_dir, wake_tx)). Any record placed under <agent_dir>
makes the writer wake its own delivery pump. The Mutation-only filesystem wakeups invariant does
not cover this: is_mutation (src/watch.rs:67-73) filters access events, which stops a read
self-wake; a write is a genuine mutation. The supervisor already does the safe thing —
watch_catalog_declarations (src/run.rs:1995) filters to _templates/** and files named
agent.kdl (src/watch.rs:52-65), with the doc comment "Runtime state … must never wake
reconciliation"
. Scope the Codex watcher, or exclude the record's directory, before any record
lands beside the inbox. Related: src/ding/mod.rs:909-915 falls back to inbox_dir.parent() — the
agent directory — when the inbox does not yet exist.

  • One record type carrying the DING-R12 state and input-buffer words verbatim, with a test
    pinning the reuse so a future drift from feat(ding): add harness-neutral guarded adapter seam #123's words fails the suite.
  • unknown is derived and never written, with a distinct reason per absence, and no absence
    derives idle.
  • Staleness derives from an embedded timestamp. No read path calls metadata().modified() — a
    file written on 2026-08-10 arrives in a fresh clone with the checkout mtime, measured seven days
    later, and docs/vrs/spec.md:776-777 already states this.
  • The record is byte-distinct on every write. Rewriting identical bytes leaves zero staged
    changes — git commit reports "nothing to commit" — so the transport never carries it. Measured.
  • A write into the record's directory does not wake the Codex delivery pump; an inbox write still
    does. Regression test in the shape of src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads.

§ B — The Codex producer reads the existing control-state.json, with the two wrong active rows fixed

The Codex arm needs no new observation path: CodexControlState is already written on every
transition. It needs a projection.

Held must not enter the contract. Its own doc comment defines Active as "the only state that
permits turn/steer"
, which makes Held the complement of steerable — a delivery predicate.
A signal whose consumers include the roster and Doctor cannot carry a delivery gate inside it. That is
structurally the error docs/vrs/.decisions/0001 corrected when it stopped one harness's rendering
from governing another harness's safety gate.

Two rows of the obvious decomposition are wrong and are corrected here. An earlier draft of this
work claimed all six CodexHoldReason values decompose "with no residue". An independent adversarial
review refuted that: a total function into a four-element set always exists, and the meaningful
property is faithfulness. Two rows report unknown for states Codex positively reported as
active
:

CodexHoldReason Naive projection Correct projection Why
Compaction active active The agent is working
Review active active + blocked axis (§C) A human is the thing that unblocks it
NotLoaded unknown unknown Correct
SystemError unknown unknown Correct; see #264 for the catch-all defect behind it
ActiveWithoutTurn unknown active Minted inside the "active" arm at :782-786. Codex said active; st2 merely cannot name a turn to steer
ConflictingTurn unknown active Two turns believed live at once (:818-821, :845-848) — maximally active

The residual information — which turn, or that no turn is attributable — belongs in the diagnostic
reason, not in the machine-readable state.

  • The projection reads the existing control-state.json; no new Codex observation path.
  • ActiveWithoutTurn and ConflictingTurn project to active, not unknown.
  • The reason string is diagnostic only and no consumer branches on it.
  • A test enumerating the projection is behavioral, not a restatement of its own match arms. The
    earlier version of that test would have passed if every row mapped to unknown; that is not
    acceptable as an oracle.
  • Held does not appear in the published vocabulary.

Depends on Issues 1 and 2: both fix states that this projection would otherwise faithfully report
as wrong.


§ C — A first-class blocked-on-human axis, with both producers wired

This is the part that is genuinely new capability rather than a nicer rendering of something that
already exists, and it is the only part with a closing window.

Why it must not be a per-driver arm. Four independent reasons. All four were raised by an
independent adversarial reader, not by the author of the proposal; an earlier draft of this work
called blocked-on-human a defensible open boundary, and that reader refuted it.

  1. It is a category error against the tuple's own semantics. Blocked-on-human is neither working
    nor idle: the model is stopped, and the thing that restarts it is a person. Folding it into
    active erases the distinction between "wait" and "act" at the only point where a reader could
    have acted on it.
  2. The Child precedent decides it. child was kept first-class on the argument that both hold
    delivery, so collapsing them is invisible to the gate, "but they are different situations for an
    operator and for Doctor"
    — different expected durations, different interventions. Every clause
    holds here, and this adds one child does not: the intervention is yours, and no amount of
    waiting substitutes for it.
  3. It fails Define Axe harness drivers and extract all harness-specific behavior from st2 core #162's own generic-consumption test. A roster, Doctor, or TUI answering "is this agent
    blocked waiting on me" must otherwise branch on a driver-specific detail type — the anti-pattern
    Define Axe harness drivers and extract all harness-specific behavior from st2 core #162 exists to eliminate ("produced by a driver and consumed generically").
  4. Deferring is not free, and this is the sequencing argument. The state enum uses unknown as
    its #[serde(other)] catch-all, so a reader pinned to v1 decodes a later blocked as unknown
    not as active. A deferred axis therefore degrades to indeterminate in every older reader, which
    is the opposite of the conservative default the rest of the design chooses. The window in which
    this is cheap is before any reader is pinned.

Shape. Either a fifth state, or a blockedOn: human | none field alongside the input-buffer axis.
The field composes better: an agent can be blocked while a child command runs.

Both producers exist today, and entry is proved on both harnesses.

Codex: activeFlags (waitingOnApproval, waitingOnUserInput) is level-triggered and on the wire;
the ten */requestApproval and */requestUserInput server→client requests are edge-triggered with a
serverRequest/resolved exit edge. Both are discarded today — see #265, which is a prerequisite.
Both were read from codex-cli 0.147.0, which SUPPORTED_CODEX_CLI_VERSIONS
(src/codex_app_server.rs:38) rejects, and neither is verified on 0.145.0 or 0.146.0.
So the Codex
producer is gated on #267 or on a capture from a supported version, and this section does not
assume the field exists where st2 can currently reach it. A populated activeFlags has never been
observed on any version; see the inference in #265.
The retention defense that "the driver arm keeps the flags" is currently vacuous: CodexControlState
(src/codex_app_server.rs:189-201) has no field for them, so a mirror reading them with
#[serde(default)] reads an empty vector forever.

Claude: PermissionRequest fires in exactly 2 of 9 captures — --permission-mode manual
(tool_name="Bash") and --permission-mode plan (tool_name="AskUserQuestion") — and in zero
bypassPermissions and auto runs. Its meaning is therefore specifically "a human is about to be
asked", not "a tool ran". Measured timeline: the hook fires at 8.067 s and an independent screen
observer sees the prompt at 8.068 s; a Notification with
notification_type="permission_prompt" follows at a fixed +6.01 s (6.014 s and 6.010 s across two
runs) and is corroboration, too slow to be primary. Stop marks idle at 40.626 s. The binary's master
hook enum carries 31 event names against the ten its own documentation lists;
PermissionRequest, PermissionDenied, MessageDisplay, PostToolBatch, SessionEnd,
SubagentStop and CwdChanged were all observed dispatching from a plain settings.local.json.

Open question, and it is not solved: how the blocked state is left.
An earlier draft specified "blocked is left only by a resolution of the same tool_use_id". The
event that enters blocked carries no tool_use_id
— verified across both occurrences in the
corpus. PreToolUse and PostToolUse carry it; PermissionRequest carries tool_name,
tool_input and permission_suggestions and no call identity. So the rule degrades to matching on
tool name, and Claude batches tool calls (PostToolBatch carries a plural tool_calls): two Bash
calls in one batch with the second needing permission means the first call's PostToolUse clears
blocked while the human is still looking at the prompt for the second. No key in the payload
prevents this. Correlating through the immediately preceding PreToolUse is a temporal heuristic that
fails for exactly the parallel case it would need to handle.

The evidence base for the exit edge is also thin by construction: blocked is entered in 2 of 9
captures and exited in 1, with one tool, one prompt, and no batching. A rule validated on a single
exit path is not validated.

So: entry is proved on both harnesses; exit is an open question on the Claude side and needs a
capture with batched tool calls before it is specified.
The Codex side has a real exit edge
(serverRequest/resolved) and does not share this problem.

One more producer-side hazard, on the Claude side. SubagentStop arrives 1.5-2.9 s after
Stop in every completed-turn run and carries agent_id, agent_type and agent_transcript_path,
where top-level Stop carries none of them. Counted naively as activity, a session reads active
forever after every normal turn. Nesting on the presence of agent_id fixes it — but note
agent_type is the empty string in all three runs and no subagent ran in any of them, so the fix
rests on an undocumented, unversioned emergent property: that a phantom SubagentStop populates
agent_id. If a future Claude build omits it, the naive failure returns silently and nothing in the
corpus would catch it.

  • Blocked-on-human is a generic axis. A reader answers "is this agent blocked waiting on me"
    without branching on a driver-specific detail type.
  • The axis exists before any reader is pinned to v1 of the state enum.
  • Codex entry from activeFlags; exit from serverRequest/resolved.
  • Claude entry from PermissionRequest, corroborated but not driven by Notification/permission_prompt.
  • Claude exit is specified only after a capture with batched tool calls. Until then the record
    states the limit rather than a rule that cannot hold.
  • Any event carrying agent_id is nested and never moves top-level state, with the emergent-property
    caveat written into the spec.
  • The spec states that blocked is vacuous under bypassPermissions, which is what
    examples/native/agent-claude.kdl:13 ships today. A fleet running entirely on
    bypassPermissions gains nothing here until that changes.

Depends on #265 for the Codex producer.


§ D — A wrapper-owned terminal write, plus a PTY-correct generation cross-check

This section extends #260's line of investigation to the Claude wrapper; it does not restate it.
#260 establishes that the Codex wrapper installs no SIGTERM handler and leaks its app-server group,
and contrasts it with the Claude wrapper, which "catches SIGTERM, leaves its poll loop, and calls
stop_provider_group before exit."
That contrast is #260's, it is correct, and the Claude side is
the better-behaved one on the path #260 is about.

The finding below is about what happens after that path. #260 draws its own boundary explicitly:

SIGKILL cannot run in-process cleanup and is outside this guarantee.

That is the right scope for #260, which asks for in-process cleanup. It is also exactly the case a
liveness record cannot leave outside its guarantee, because a record that stops being written is
still being read. So the two are complementary: #260 fixes the graceful path in-process; this section
argues that the ungraceful path needs an out-of-process check, since by #260's own correct reasoning
no in-process write can cover it.

Claude death is silent. Two trials started claude -p on a long-running Bash call, waited until
the hook log showed a turn genuinely in flight, then signalled the process group:

sigterm    last-event-before=PostToolBatch  post-signal=NONE  SessionEnd=NO
sigkill    last-event-before=PostToolBatch  post-signal=NONE  SessionEnd=NO

Nothing fires — not on SIGKILL, and not on SIGTERM, which is the signal st2 itself sends. The
SessionEnd reason set is closed and contains no death value:
["clear","resume","logout","prompt_input_exit","other","bypass_permissions_disabled"]. A hooks-only
record therefore sticks on active forever.

The wrapper-owned write works, and it is not sufficient. A prototype mirroring run_provider's
existing loop (src/claude_session.rs:93-107 — spawn, poll try_wait at 250 ms) wrote a terminal
state when the child was reaped: hooks-only said active, wrapper-owned said ended, detection
latency 0.25 s, one tick of a poll the wrapper already runs.

It covers exactly the case where only the child dies:

  • SIGTERM: covered. The wrapper installs a handler (src/claude_session.rs:29-40), so it survives
    stop_provider_group's kill(-getpgrp(), SIGTERM) (src/claude_session.rs:114-121) and can write.
  • SIGKILL escalation: not covered. After STOP_GRACE (5 s, src/claude_session.rs:19) the same
    function sends kill(-getpgrp(), SIGKILL) (:130-132) into its own process group. SIGKILL
    cannot be caught, so the wrapper dies with the provider and the following let _ = child.wait()
    (:133) never runs. A Claude session with a modal open or a long tool in flight plausibly exceeds
    5 s, so this is a routine path on st2's own teardown, not an edge. This is the complement of
    driver codex leaves its app-server process group alive on SIGTERM #260's Codex finding: there the wrapper exits before cleanup runs, here the wrapper is inside the
    group it is killing — and it is the case driver codex leaves its app-server process group alive on SIGTERM #260 correctly places outside its own guarantee.
  • External forced kill: not covered. Invariant row 11 names SIGKILL explicitly
    (tests/nomad_survival.rs::forced_kill_and_binary_replacement_adopt_pty_unchanged_without_duplicate),
    as does pty kill on the pane. Both take the wrapper and the provider together.

So a cross-check is a shipping requirement, not an experiment bullet. In every uncovered case the
only fallback is staleness decay, and if the horizon is inherited from presence that is fifteen
minutes of a dead agent reading active.

And the cross-check must be specified against PTY, not exec. An earlier draft proposed reading the
record's runtimeIncarnation against ExecGeneration / observe_generation
(src/exec_backend.rs:34-48, :276-294) — the exec backend. That is the wrong backend: a
driver-declared agent lowers to TaskKind::Pty (crates/agent-spec/src/spec.rs:908, :917), so the
Claude and Codex wrappers that write these records are PTY tasks. The PTY liveness primitive is
ding::session_alive — pidfile plus kill(pid, 0) at src/ding/mod.rs:692-703. The defense named
for the exact failure mode was written against the wrong backend; an independent reviewer caught that,
not the author.

Two limits of the cross-check, stated up front. It is host-local by construction, so a cross-host
reader cannot evaluate it and is left with the staleness horizon alone. And a heartbeat that blindly
re-stamps a belief keeps a wrong state fresh indefinitely — staleness catches a writer that stopped;
it cannot catch a writer that is confidently wrong. So the writer rule is: stop heartbeating on
evidence loss.
An observer that loses contact with its harness lets the record age to unknown
rather than refreshing a state it is no longer watching.

One constant to choose rather than inherit. If the staleness horizon is aliased to
status::STATUS_STALE, a future presence-tuning change silently retunes observed harness state with
nothing failing. Row 22 pins those constants with named tests; the alias is invisible to them. One
decay horizon for an operator is a good rationale — it should be a documented constant with its own
value, not a re-export.

  • The wrapper writes a terminal record where try_wait returns Some(exit)
    (src/claude_session.rs:97-99) and on the stop_provider_group path, carrying the exit status.
  • A generation cross-check specified against the PTY liveness primitive
    (src/ding/mod.rs:692-703), not ExecGeneration. A record whose generation is dead reads as
    unknown even while fresh.
  • The observer stops heartbeating on evidence loss rather than preserving a state it cannot see.
  • The staleness horizon is its own documented constant, not an alias of status::STATUS_*.
  • Named test: SIGKILL the provider mid-turn, the record reads ended rather than active. Per
    CLAUDE.md, the INVARIANTS.md row is added only once that test is green.

§ E — One join into agents --json: declared presence beside observed state

The value of the surface is the comparison. A declared busy beside an observed idle is the
wedged-agent signal, and it is not derivable from anything shipping today. Making a caller join two
commands invites them to skip it.

What the declared side looks like on this host today, as a snapshot and nothing more: of 1298
status files, 1217 read available, 63 busy, 8 away, 7 dnd, 3 offline. That distribution
is consistent with agents declaring busy correctly and with agents never declaring it at all, and
the presence file cannot distinguish those two readings. That is the gap the join closes — not by
correcting presence, which stays exactly as it is, but by putting a second, independently produced
axis next to it. This is a census, not a divergence measurement: it does not show declared
presence diverging from observed state, because there is no observed state to compare it against yet.
That measurement becomes available once §A ships, and it is the one worth taking.

The cost, stated rather than discovered in review. The roster wire shape is pinned three times by
full-string assert_eq!:

  • src/agents.rs::agents_json_has_stable_wire_shape — two full-string asserts, on
    to_json(&rows, false) and to_json(&rows, true). The literal is at src/agents.rs:226.
  • src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors — a third.

and by INVARIANTS.md row 21 (Stable roster JSON), whose guarantee ends "…and inbox counts.
Presence remains independent from desired lifecycle."
Observed harness state is a third
independent axis, so that wording changes and needs a new proof.
(tests/status_agents.rs asserts by field index and is additive-safe either way.)

Approach Pinned full-string edits INVARIANTS.md
Field added to the roster JSON (what this asks for) 3 literal edits Row 21 wording changes; new proof needed
--observed flag with a third serializer struct 0 Wording must name the new shape
A sibling st2 activity --json, precedent st2 tasks --json 0 No change

The sibling command is the cheaper option and it defers the wire commitment until real producers
exist. The join is the more useful one. This is a maintainer call and is flagged as one rather than
resolved here.

  • Declared presence and observed harness state appear side by side in one payload.
  • Whichever surface is chosen, the three pinned full-string assertions and row 21's wording are
    updated deliberately, in the same change, with the new proof named.
  • lastActivity keeps its existing meaning and is not conflated with the new axis.

Explicitly out of scope, with the reason

Named here so the boundary is visible rather than discovered.

  • A durable JSONL transition history, and --watch. Deferred, not deleted. Burst coalescing — 4
    transitions per turn arriving 0.1-0.4 ms apart, measured — is required for the record to be
    truthful, and it defeats the dwell-time analysis the history was justified by: a blocked state
    shorter than the coalescing window is erasable by construction. A streaming watch has the same
    problem from the other side: re-projecting on wakeup reports the latest state, never each
    intermediate, and two transitions between wakeups collapse with nothing to recover the dropped one
    from. History earns its keep once §C exists and dwell time is the thing being recorded.
  • The host-local hot tier with a throttled catalog projection. Withdrawn, not deferred. It was
    designed to keep the delivery gate from reading a state older than one minute. The layer boundary
    says the delivery gate does not read this state at all, so the tier's stated justification is gone.
    Any future version needs a fresh one.
  • The PTY screen observer. Cut. See ## Limits.
  • is_interruptible and the routing story. Cut. It serves a router that does not exist: st2 has
    no router today, and every call site of the prototype's routing predicates is #[cfg(test)].
  • Thresholds, escalation, and notification policy. These are Notify a supervisor when a managed child remains idle past a configured threshold #173's and downstream's, per R20
    ("The generic envelope does not imply resolution, access, readiness, or lifecycle semantics").
    st2 owns observation, envelope, fencing, freshness, and exposure.
  • Any AGENT-SPEC.md change. That file lives in compoundingtech/evals with separate authority.

Limits

Stated before anyone has to find them.

  • The PTY screen observer is broken for one of the two shipped harnesses. A prototype implemented
    it as classify_composer(screen, ""), mapping only EmptySafe to a proven idle. That reasoning is
    Codex-specific: Codex has distinct empty and typed markers, but the Claude arm extracts composer
    text and string-compares, so for an empty Claude composer the empty expected notice matches as an
    exact match and the observer returns unproven. Measured against the repo's own fixtures, a used
    Claude pane reads unproven while a fresh one reads idle — so the observer proves idle for a Claude
    pane exactly once, before anyone types into it. The root cause is a sentinel collision, not a
    missing match arm: expected = "" collides with the Claude arm's representation of an empty
    composer. That is the same shape of error 0001 corrected. The failure direction is safe — it
    under-reports and never produces a false idle — but the observer is advertised as the only path
    covering hand-authored and unmanaged PTY agents, and it delivers nothing for one of two harnesses.
    It is cut for that reason. Found by an independent adversarial reader; the prototype's own test hid
    it by fixturing Codex in the positive slot.
  • The wrapper-owned death write does not cover SIGKILL escalation or an external forced kill.
    §D above states this in full; repeated here because it is the limit most likely to be assumed away.
  • The transport cost model is not identified. No measurement here establishes what a per-turn
    replicated write costs on a real catalog under a real transport. Presence refreshes every five
    minutes; turn boundaries are far more frequent. The catalog exposure in §E is a read of a
    host-local record, and how — or whether — the record itself replicates is deliberately left open
    rather than answered with a shape that cannot be defended.
  • DQ3's supervisor-following half is unproven. docs/vrs/spec.md:957+ sets two gates:
    stale-state behavior and supervisor-following behavior. The stale-state half is addressed
    thoroughly above. The supervisor-following half is not addressed anywhere in this work. Half
    the repo's own acceptance criterion for this exact feature is unmet, and that is a reason the
    decision record ships Status: draft.
  • All Codex wire evidence is codex-cli 0.147.0, which SUPPORTED_CODEX_CLI_VERSIONS
    (src/codex_app_server.rs:38) rejects, so every Codex protocol fact here is unverified on the
    versions st2 supports
    — including activeFlags, on which §C's Codex producer depends. st2 refuses the locally installed codex-cli, and the version pin is now behind by one release #267
    covers the drift itself. All Claude evidence is from one bundle version, on Linux. CI is
    ubuntu-latest only, so nothing here says anything about Darwin.
  • The Codex control protocol is expected to be replaced. The note Add native delivery for Codex and Claude #254 added to
    docs/vrs/01-ding/02-codex/spec.md says so directly. This is an argument for the generic
    envelope: the producer changes, the contract does not.
  • The VRS is behind the implementation in this whole region. docs/vrs/ contains no match for
    app-server, claude-mcp, or driver expand, and 01-ding/requirements.md still opens by calling
    DING a sidecar. PR Specify native DING and Codex app-server delivery #242 is the only written contract for the delivery layer this observes, and it is
    still open. Landing Specify native DING and Codex app-server delivery #242 first is more valuable than landing this, and help rebasing it is offered.

Measurement provenance

Following #261's practice of naming where the numbers came from.

Related



Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dingDING delivery: inbox notice into a running agent · Set: manualarea:driverHarness drivers: launch, MCP, app-server, native delivery · Set: manualarea:presencePresence, status records, and heartbeats · Set: manualharness:neutralHarness-neutral core that must not encode provider specifics · Set: manualstate:open-design-questionUnresolved design choice requiring explicit alignment before implementation · Set: manualtype:featureNew user-visible or system capability · Set: manual

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions