Skip to content

voice: agent.state v1 protocol emitter + lifecycle snapshot (phase 1, stacked on #2684) - #2689

Merged
yixuan-ag2 merged 3 commits into
sonichi:mainfrom
yixuan-ag2:voice/phase1-agent-state
Aug 7, 2026
Merged

yixuan-ag2 merged 3 commits into
sonichi:mainfrom
yixuan-ag2:voice/phase1-agent-state

Conversation

@yixuan-ag2

@yixuan-ag2 yixuan-ag2 commented Aug 5, 2026 •

Copy link
Copy Markdown
Collaborator

Phase 1 / PR group C-partial of the desktop voice-reliability plan (ag2-space/ag2space-cinny-desktop#281, merged there). Structured per CONTRIBUTING.md ("The PR body should answer").

Stack status

Parent #2684 merged as 1a3733aa. This branch was rebuilt on that merge and current main, so the PR diff is now the child-only agent.state/lifecycle layer. Rebuild commits: 675c8b3d (feature), 3daddc22 (ephemeral-port test fix), and ea8a00c1 (regenerated source-map count). Intended next order: this → sonichi/bodhi_realtime_agent#27 → desktop #282.

What changed, and why?

The desktop supervisor needs a truthful, streaming view of the agent's state (initialized? upstream live/idle/backoff/failed? which credential loaded?) to supervise without guessing — and the incident showed "port is open" proves nothing. This adds:

  • src/voice-agent-state.ts — buildAgentState(): versioned frame {type:'agent.state', v:1, initialized, upstream, reason?, category?, clientAttached, credentialSource?, credentialGeneration?, launchdContract?:1}. credentialSource from the existing resolver result (voiceApiKey() signature untouched); credentialGeneration is reported, never minted (opaque, host-supplied); launchdContract:1 echoed only under the env marker.
  • Classifier mapper — extends src/voice-error-classifier.ts (auth/quota/credits/network → {upstream:'failed', reason, category}), persisting the last terminal classification; deliberately no second classifier.
  • state/voice-lifecycle.json — atomic (temp+rename, unique temp names) crash-safe snapshot on every relevant transition; the desktop's call-aware decisions read this, not voice-state.json's plain writeFileSync.
  • Z3 idle fence — verifier/probe-role closes with no real client re-arm the isolated idle-teardown timer, fenced against a later real connection.

What files should reviewers look at first?

src/voice-agent-state.ts, the voice-error-classifier.ts mapper additions, then the voice-agent.ts wiring (frame emission on connect + upstream transitions; lifecycle publisher; probeState behind a feature-detect).

What user behavior or bug does this prove?

The supervisor/UI can distinguish "agent up but upstream dead (bad key/quota)" from "healthy idle" from "wedged" — the confusions that made the incident invisible.

Feature/documentation consistency

N/A for docs/ canonical documents: docs/catalog.json has no canonical doc for the agent wire protocol; the protocol's canonical spec is the desktop design doc (docs/design-voice-reliability-and-onboarding.md, 1a′, merged in the desktop repo) which governs this cross-repo feature; module-level docs are in the source headers here. design-credential-capability-resolver.md is unaffected (this PR reads the resolver's result; resolution semantics unchanged).

Before/after evidence

Before: no agent.state frame exists (a probe/client learns only "socket opened"); voice-lifecycle.json doesn't exist; classifier verdicts die in the log.
After (bot-verify — suites don't exist at base, green at HEAD):

npx tsx --test tests/agent-state-protocol.test.ts     → 20 pass / 0 fail
  (frame vectors; S3 generation echo incl. legacy omission; lifecycle atomicity
   incl. cross-process torn-read; Z3 arm/fence; spawned-agent integration:
   immediate frame on connect, injected terminal close → failed/quota transition)
npx tsx --test tests/voice-error-classifier.test.ts   → 15 pass / 0 fail

What tests did you run?

Post-rebuild on current main: npx --no-install tsx --test tests/agent-state-protocol.test.ts tests/voice-error-classifier.test.ts → 35/35; python3 tests/src-map.test.py → 25/25; npx --no-install tsc --noEmit → clean; git diff origin/main...HEAD | bash scripts/review-checks.sh → PASS. Integration tests use ephemeral ports (3daddc22).

Live-path / voice manual evidence (owner, 2026-08-05)

This branch was the engine/sutando ref bundled into the desktop phase-1 verification DMG. Voice exercised over the rail mic and the room-header Start Call → Voice dropdown (force-tier menu rendered: T0-a/T0-b local rows, T1–2 direct n/a, T3 relay rows); real work round trips completed — transcript excerpt in #2684's evidence section (same live sessions, data/conversation.sqlite). Lifecycle transitions observed in the logs: state=ACTIVE client=false → 60 s idle → transport closed → state=CLOSED (clean idle teardown with the emitter active).

Edge cases / non-happy paths checked

Legacy no-generation spawn (fields correctly absent, not undefined); torn-read race on the lifecycle file from a second process; terminal-close during an attached client (failed/quota frame streamed); probeState feature-detect is false at the current bodhi pin (no dead wiring).

Migrations, config, rollback risks

Additive protocol + one new state file; older clients ignore unknown frames (documented fallback). No config changes; rollback = revert.

Known gaps / follow-ups

Probe-frame + live-call acceptance tests activate with the bodhi pin bump (PR group E, sonichi/bodhi_realtime_agent#27); documented in the test header.


CLA: signed. 🤖 Generated with Claude Code

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and verified at head ab6d75ee. The unique agent.state work (v1 protocol emitter, atomic crash-safe voice-lifecycle.json, classifier→protocol mapper, Z3 idle-restore fence) is clean and well-tested: I ran agent-state-protocol.test.ts 20/20 (incl. the cross-process torn-read race, Z3 arm/fence, and the spawned-agent integration driving a real transport close → failed/quota frame + lifecycle tracking) and voice-error-classifier.test.ts 15/15; tsc --noEmit clean. The credential path only reports an opaque credentialGeneration (never mints/derives/logs the actual key), the lifecycle write is atomic temp+rename with unique temp names, and the wire contract is additive + feature-detected behind the not-yet-landed bodhi pin.

Two process notes before merge (important):

  1. This is a cross-fork stack that physically includes all of #2684's commits, so it must land after #2684 — merging this first would pull the whole parent surface in one shot. (I've separately reviewed + approved #2684; its tests pass and its crash/lock/exit invariants hold.)
  2. Branch is BEHIND main — needs an update-branch.

Non-blocking nits: the String(VoiceSession).includes('probeState') feature-detect is a little fragile under a minified bundle (fails safe → probe wiring omitted, which is the current pin state); the SUTANDO_TEST_MODE-gated fault-injection seams live in main() (inert in prod, consistent with repo pattern). No blocking code concerns.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Child-layer review at head ab6d75ee (diff vs parent #2684's c64c7a5e: +1301/−27, 7 files — the agent.state emitter + classifier layer only), with independent execution:

  • Both new TS suites green by my own run (worktree + tsx): agent-state-protocol.test.ts 20/20, voice-error-classifier.test.ts 15/15.
  • Amendment A9's one-writer claim verified by grep at head: voice-lifecycle.json is written only inside publishLifecycleSnapshot() (voice-agent.ts imports and calls it; no second writer anywhere in src/ or scripts/). The atomic pattern is right — unique temp name (pid + counter) then renameSync, so the cross-process WS2 reader can't see a torn write, and the header explicitly rejects the writeVoiceState plain-write pattern rather than silently diverging from it.
  • The createIsolatedIdleRestore fence design (probe-observed wake can't strand the upstream connected; real connection invalidates a pending restore) matches its stated Z3 semantics in the test cases exercising it.
  • Wire-contract discipline is right: versioned frame, "do not add/rename without a version bump" pinned in the header.

No blockers on the child layer; with john's approval this is merge-ready after #2684 lands (merge order as the owner listed).

— qingyun-sutando (001)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the child-only layer (pr2684...head: 7 files, +1301) plus the cumulative stack interaction, per the stacked-PR rule.

What I ran on the child head (ab6d75e, all green)

npx tsx --test tests/agent-state-protocol.test.ts tests/voice-error-classifier.test.ts
  -> # pass 35 / # fail 0
# cumulative — the parent's suites still green on THIS head:
tests/voice-lock.test.py                 -> All voice-lock tests passed.
tests/voice-agent-kill-path-gate.test.py -> voice-agent kill-path gate: clean.
npx tsx --test tests/voice-agent-crash-only.test.ts -> # pass 16 / # fail 0
git diff pr2684...HEAD | bash scripts/review-checks.sh -> PASS (hardcoded-paths clean)

Child-layer read

  • No secret on the wire or on disk: the agent.state v1 frame carries credentialSource + an opaque credentialGeneration only — I grepped the emitter for key references: zero. The resolver change reports a Rust-minted generation verbatim and never derives anything from the secret.
  • Snapshot atomicity is right: publishLifecycleSnapshot writes state/voice-lifecycle.json via unique temp name (-tmp-<pid>-<counter>) + renameSync, explicitly diverging from writeVoiceState's plain writeFileSync because a cross-process consumer reads it — the comment says why, and the path comes from statusPath()/workspace helpers, no hardcoded paths.
  • Wire contract discipline: the v1 frame schema is marked verbatim-contract with a version-bump requirement — the right shape for a protocol PR.
  • The isolated idle-restore fence (Z3) addresses a real leak (probe wakes upstream after the one-shot idle timer already fired → upstream connected forever) and agent-state-protocol.test.ts exercises the fence + invalidation paths rather than just the happy path.

Same owner-actionable note as the parent: this head's full CI is also gated on a maintainer's "Approve and run" (fork PR). Merge order stands: #2684 first, then this (its branch already contains the parent; after the parent's squash lands, this branch will need the same rebuild-onto-main treatment #2686 just went through — cherry-pick the child commits, don't rebase the stale stack).

Approving the child layer on the local runs + the reads above.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static review of the child-only layer (this diff includes #2684's commits; I reviewed #2684 separately and looked here at what 2689 adds on top). Suite not run — anything unverified is called out.

Hardcoded absolute paths: clean — zero hits across added lines.

Credential exposure — checked, and it's done right. A state frame that reports "which credential is loaded" is the obvious place to leak a secret, so I traced credentialGeneration. The resolver's contract is explicit: "This resolver only REPORTS it — never mints, never derives it from the secret", the value is an opaque cg1-<UUID> minted by the Rust host, and legacy credentials without one simply omit the field. So the frame carries an identifier that is useless to an attacker, not a fingerprint of the key. That's the right design and worth keeping that comment exactly as written.

Stacking. Per CONTRIBUTING.md the merge order needs stating on the PR: #2684 lands first, then this is rebased and its full checks re-run. Worth confirming that's the plan, since the cross-fork base means CI here is exercising parent+child together and a green run now is not evidence for the child alone after the parent moves.

Merge-readiness: no blocker found in the child layer from a static read. My reservation is inherited rather than new — I did not run this suite, and #2684's own verification (a real wedge-and-reap round trip) is what I'd want green before either lands. If a maintainer has already done that end-to-end, treat this as a second pair of eyes on the state-frame and credential surface specifically.

One cross-PR note: bodhi sonichi/bodhi_realtime_agent#27 adds a ?probe=1 role that returns a JSON state frame. If the desktop supervisor ends up using that instead of the HTTP __liveness_probe__ curl, it would close the gap I raised on #2684 — where only a curl timeout (rc 28) counts as wedged, so an agent answering a fast HTTP 500 is never reaped. A state frame can express "answering but not usable" in a way an exit code cannot. Worth deciding deliberately which probe is authoritative rather than ending up with both.

auto-merge was automatically disabled August 6, 2026 13:38

Pull request was closed

@john-the-dev john-the-dev reopened this Aug 6, 2026
@john-the-dev
john-the-dev enabled auto-merge (squash) August 6, 2026 13:38
@john-the-dev

Copy link
Copy Markdown
Collaborator

@yixuan-ag2 this is 2-approved + mergeable but a required check (refuse new inline CLAUDE_CONFIG_DIR fallback) never ran — the update-branch merge commit didn't re-fire the workflow, and I can't re-trigger it from outside your fork. Any push (even an empty commit: git commit --allow-empty -m ci && git push) re-runs CI, and auto-merge is already armed so it'll land immediately once green. (Do #2684 first — #2689 is stacked on it.)

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Merge position: Stage 2 — after #2684 (merge commit, not squash). Once #2684 lands via merge commit, this PR's diff collapses to its own commits. #2690 and #2691 both stack on this branch and follow it.

🤖 Generated with Claude Code

@john-the-dev

Copy link
Copy Markdown
Collaborator

Double-check after the GitHub outage: the prior required check refuse new inline CLAUDE_CONFIG_DIR fallback still has no run on head 15331d38a2d50095c61b7a19940df1d2b5176bd2. The available recheck and CLA checks are green; GitHub reports MERGEABLE/APPROVED but BLOCKED. This child is stacked behind #2684, so re-fire/clear #2684 first, then refresh/re-check this child. I did not mutate the branch or bypass protection.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Approved the fork workflow runs for the current head 15331d38a2d50095c61b7a19940df1d2b5176bd2. Required CI is now running; current state remains MERGEABLE/APPROVED but BEHIND. I will not bypass the branch rules—once the checks pass, this stacked branch still needs its base/stack updated before merge.

@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@john-the-dev

Copy link
Copy Markdown
Collaborator

Required CI did not pass on head 15331d38a2d50095c61b7a19940df1d2b5176bd2: tsc + tests (clean install) failed 5 voice-agent duplicate-instance/fail-closed-lock integration subtests (timeouts/crash-handler assertions), and diff coverage >= 95% (python) failed. No merge or protection bypass.

auto-merge was automatically disabled August 7, 2026 01:56

Head branch was pushed to by a user without write access

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Addressed on head 7f67198 by merging the fixed #2684 head into this stacked branch, preserving the required merge-based history.

The parent fix lets the existing SUTANDO_TEST_MODE spawned agents run on Linux, which directly resolves this PRs two agent.state integration timeouts and the five inherited crash-only failures. The subprocess coverage fix and 99% voice-lock coverage are inherited as well; current upstream main is included through the parent merge.

Local verification: agent-state-protocol.test.ts passes 20/20, including both spawned-agent integrations; tsc is clean; targeted ESLint has zero errors. Required fork workflows are currently action_required and await maintainer approval.

yixuan-ag2 and others added 3 commits August 7, 2026 12:12
…dle fence

agent.state v1 protocol (design 1a', impl plan WS1 Step 12, amendments
R8/A9/A10/S3/Z3): a new src/voice-agent-state.ts owns the pure pieces —
createAgentStateProvider assembles {type:'agent.state', v:1, initialized,
upstream: live|idle|connecting|backoff|failed, reason?, category?,
clientAttached, credentialSource?, credentialGeneration?, launchdContract?}
from late-bound getters; voice-agent wires it to bodhi's probeState option
behind a feature-detect (the Step-11 pin lands separately), sends an
immediate frame on every accepted real connection, and repeats on every
upstream transition (session.stateChange subscription + the classifier's
non-retryable close path; reason/category changes within 'failed' count
as transitions).

R8: the failure classification extends voice-error-classifier.ts with a
stable protocol mapper (auth_invalid -> failed/auth/auth-invalid,
quota_exceeded -> failed/quota/quota-exceeded, credits_depleted ->
failed/quota/credits-depleted, model_not_found -> failed/other;
retryable -> null) and persists the last terminal classification for
buildAgentState (cleared on ACTIVE recovery). The startup /credit|quota/
regex stays a logging hint; startup failures route through the same
classifier — no second classifier.

A10/S3: credentialSource comes from the EXISTING resolveCredential result
(voiceApiKey() signature untouched; env maps to 'byok' on the wire).
resolveCredential now also REPORTS an opaque Rust-minted generation —
managed entries' `generation` field or the injected
SUTANDO_VOICE_CREDENTIAL_GENERATION for the voice env tier; legacy keys
stay generationless (field omitted). launchdContract:1 echoed only under
SUTANDO_VOICE_LAUNCHD_CONTRACT=1 (R17).

A9: this emitter is the ONE writer of state/voice-lifecycle.json
{at, clientAttached, initialized, upstream, category, credentialSource,
credentialGeneration}, published on client attach/detach, the initialized
flip, and upstream changes via temp+rename with unique per-write temp
names (writeVoiceState's plain writeFileSync is not the pattern).

Z3: createIsolatedIdleRestore arms an isolated idle-teardown timer on
probe close with no real client attached (restoring the prior idle
state), fenced by any later real connection; until the bodhi pin exposes
a probe/verifier-close hook, the probeState callback is the in-repo arm
point (seam documented).

Tests: tests/agent-state-protocol.test.ts (frame shape/vectors, resolver
generation reporting incl. legacy omission, lifecycle-file atomicity with
a cross-process torn-read race, Z3 arm/fence semantics, and spawned-agent
integration: immediate frame on connect, injected terminal close ->
failed/quota transition frame through the real transport.onClose seam,
lifecycle attach/detach tracking) plus classifier mapper/persistence
cases in tests/voice-error-classifier.test.ts. Existing TS suite green
(772/772) and voice-adjacent python suites pass; the state-paths gate
drove voiceLifecyclePath through the canonical statusPath resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests

Fixes the review nit that 19981-19984 were hardcoded — parallel runners or
a stray listener could collide. Each spawn now binds :0 and reads back the
assigned port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yixuan-ag2
yixuan-ag2 force-pushed the voice/phase1-agent-state branch from fb91559 to ea8a00c Compare August 7, 2026 19:14
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Stack rebuild complete at ea8a00c1 after #2684 merged. The branch is now the child-only layer on current main; git range-diff preserves the two reviewed implementation/test commits, with only the generated source-map count updated for current main. Local validation: protocol + classifier 35/35, src-map 25/25, tsc --noEmit clean, hardcoded-path gate PASS. CI is running. Prior approvals predate the rebuilt head, so please re-review exact head ea8a00c1 once checks are green.

@yixuan-ag2
yixuan-ag2 merged commit ffc0e0a into sonichi:main Aug 7, 2026
17 checks passed
@yixuan-ag2
yixuan-ag2 deleted the voice/phase1-agent-state branch August 7, 2026 20:34
yixuan-ag2 added a commit to yixuan-ag2/sutando that referenced this pull request Aug 7, 2026
…e mirrors the LATEST failed classification

The integration test pinned category === 'quota' on the lifecycle FILE, but
the fake key's own startup auth close arrives through the same wrapped
onClose seam and can land after the injected quota close, legitimately
overwriting the category (R8: reason/category changes within 'failed' are
meaningful transitions — recordTerminalClassification → emit → publish runs
for both). The file only holds the LAST state, so a 150ms poll for 'quota'
misses forever once 'auth' lands. Captured live under parallel load: at
timeout the file read failed/auth while the quota frame sat in the frames
array; reproduced 2/10 sequential and ~1/9 parallel on the pre-fix
assertion. The quota mapping stays pinned end-to-end by the FRAME
assertions; the file's real invariant — mirrors the latest failed
classification — is what the wait now asserts. 15/15 parallel runs green
post-fix.

Pre-existing since the test landed (sonichi#2689); surfaced on the PR head run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Aug 8, 2026
…marker (activation switch) (#2713)

* chore(voice): group E — bodhi probe-isolation pin + capability marker (activation switch)

Bumps bodhi-realtime-agent to the PR-27 head (probe/verify/takeover
connection roles, dist rebuilt) and publishes the capability marker
state/voice-agent.capabilities.json {probeIsolation:true} once at
voice-agent startup — the strict-true flag the desktop supervisor's
ws-upgrade probe battery and exit-7 holder checks gate on. Pin and
marker land in the same change on purpose: the marker is the activation
switch and must never precede the capability.

NOT READY until sonichi/bodhi_realtime_agent#27 merges — the pin then
repoints to the post-merge sonichi main SHA (fork SHA is a draft-only
stand-in) and the lock regenerates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(voice): repoint bodhi pin to the post-#27 sonichi main SHA

bodhi #27 merged via merge commit — 25cc6e8 is the canonical main head
containing the probe/verify/takeover roles + dist rebuild. Replaces the
draft-only fork stand-in; lock regenerated; installed dist re-verified
to carry the role code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice-agent): bind the capability marker to its publishing process (review)

The marker was a one-way latch: only ever {probeIsolation: true}, nothing
deletes or invalidates it, and older builds don't write it at all. After an
engine rollback below the capable pin, the stale true on disk would keep the
desktop supervisor's ws-upgrade probe battery armed against a build without
probe isolation — a ?probe=1 upgrade could then steal a live call, the exact
failure the isolation exists to prevent.

Fix: the marker now records pid (the publisher's own). Publication runs
strictly after acquirePidLock() — a lock loser exits 7 first — so marker.pid
equals the live lock holder's pid exactly when the marker describes the
running build. The desktop reader (paired in the group-H activation PR)
requires that equality plus holder liveness and fails closed on mismatch.

Also: package-lock.json rebuilt from main + the pin change only — the
previous regen dropped six unrelated "peer": true flags (npm-version
churn); the lock diff is now exactly the bodhi pin (spec/resolved/integrity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice-agent): bind the capability marker to the acquisition token, not just pid (review)

pid alone is not stale-marker-proof: OS pids are reused, so after a rollback
to a build without probe isolation a later run can land on the stale marker's
pid and the reader's pid equality would incorrectly arm the probe battery.

The structured lock already carries the right token: lockId (vl1-<uuid4>),
unique per acquisition — the same class of defense voice-lock.py uses
(startTimeMs matching) against pid reuse on release, with exact string
equality instead of clock-tolerance semantics. acquireVoiceLock now surfaces
the helper's emitted lock record's lockId; voice-agent threads it into the
marker. A marker published without a token (helper output unparseable) is
unbound: the desktop reader requires the token and fails closed (dormant).

The paired desktop reader requires marker.pid AND marker.lockId to equal the
current structured lock holder's, plus liveness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(agent-state): fix a pre-existing race — assert the lifecycle file mirrors the LATEST failed classification

The integration test pinned category === 'quota' on the lifecycle FILE, but
the fake key's own startup auth close arrives through the same wrapped
onClose seam and can land after the injected quota close, legitimately
overwriting the category (R8: reason/category changes within 'failed' are
meaningful transitions — recordTerminalClassification → emit → publish runs
for both). The file only holds the LAST state, so a 150ms poll for 'quota'
misses forever once 'auth' lands. Captured live under parallel load: at
timeout the file read failed/auth while the quota frame sat in the frames
array; reproduced 2/10 sequential and ~1/9 parallel on the pre-fix
assertion. The quota mapping stays pinned end-to-end by the FRAME
assertions; the file's real invariant — mirrors the latest failed
classification — is what the wait now asserts. 15/15 parallel runs green
post-fix.

Pre-existing since the test landed (#2689); surfaced on the PR head run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(agent-state): activate the group-E probe-vs-live-client acceptance test

The file's Step-11 note scheduled the probe-frame + live-call acceptance
test for the group-E pin bump — which this PR is, making the note stale and
the promised test due. Against the pinned bodhi, with a real client
attached: a ?probe=1 upgrade is intercepted before client attachment, gets
exactly its agent.state frame + close(1000), and the client keeps its slot
(socket stays OPEN, lifecycle still counts it attached) — the steal the
isolation exists to prevent, asserted end-to-end. The same spawn also proves
the capability marker in vivo: published (capable pin) and BOUND to the
structured lock — marker.pid and marker.lockId equal the lock file's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice-agent): gate the capability marker on the probeState feature-detect (review)

The marker publish was unconditional while bodhiSupportsProbeState sat in
scope ~100 lines earlier — the never-precede-the-capability invariant held
by shipping discipline alone. It breaks the moment npm resolves a bodhi
other than the lockfile's (failed or cached install, hand-edited pin): the
marker would advertise probeIsolation to a build that routes probes down
the normal attach path, and a supervisor probing on a timer could preempt
a live call. Gate publication on the same feature-detect that wires
probeState; log the dormant branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice-agent): never publish an unbound marker; prove both gate branches (review round 5)

Two findings, one contract change:

1. The unbound marker failed closed only in another repository: a missing
   acquisition token still published {probeIsolation, at, pid}, and the
   entire defense was the desktop reader requiring the token — a safety
   property living across a repo boundary on a one-way-latch file. An
   unbound marker has no consumer that benefits from it, so skip
   publication instead: lockId is now a REQUIRED parameter of
   publishCapabilitiesMarker and the caller gates on having one. Fail
   closed at the writer; a marker on disk is always fully bound. The
   desktop reader's token requirement remains as a second fence (it still
   guards files left by older builds).

2. The gate was the safety property and nothing tested it — deleting
   if (bodhiSupportsProbeState) left the suite green. New integration row
   spawns the agent with a SUTANDO_TEST_MODE-only detect-override seam
   (same pattern as the injected-close seam), proves the wiring init ran
   past the marker site (first frame observed), and asserts NO marker file
   plus the dormant-branch log line. The gate now regresses loudly, on the
   real caller, for both branches (the TRUE branch is pinned by the probe
   acceptance test's in-vivo marker-binding assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(voice): condense group-E comments to the 2-line policy cap

CLAUDE.md: comments state the constraint or non-obvious reason in at most
two lines; design rationale, incident history, and review references live
in the PR body and commit messages. Five blocks added across this PR's
review rounds exceeded the cap (marker docblock, marker-call gate, detect
seam, token assignment, acquire parse) — each condensed to its invariant.
The acquire-parse comment also dropped a stale claim: since lockId became
required, no unbound marker is ever published.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants