Skip to content

voice: crash-only fatal handlers, exit-7 duplicate-lock contract, guarded PID-lock transactions (phase 1) - #2684

Merged
yixuan-ag2 merged 20 commits into
sonichi:mainfrom
yixuan-ag2:voice/phase1-lock-and-crash
Aug 7, 2026
Merged

yixuan-ag2 merged 20 commits into
sonichi:mainfrom
yixuan-ag2:voice/phase1-lock-and-crash

Conversation

@yixuan-ag2

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

Copy link
Copy Markdown
Collaborator

Phase 1 / PR group A of the desktop voice-reliability plan (design + plan: ag2-space/ag2space-cinny-desktop#281 → merged to main there; desktop companion: ag2-space/ag2space-cinny-desktop#282). Structured per CONTRIBUTING.md ("The PR body should answer").

What changed, and why?

Follow-up to the 2026-08-04 incident: a voice-agent wedged in an uncaught-exception loop held :9900 and its PID lock for ~5 hours; supervisor restarts lost the duplicate-lock race forever, and the crash-reporting path itself was the spin (profile: TriggerUncaughtException → InspectorConsoleCall/ErrorStackGetter). This PR makes the class survivable:

  • Crash-only fatal handlers (src/crash-only.ts): bounded primitive-only crash record via raw fs.writeSync (no logger/recorder/SQLite, never err.stack), process.exit in the outermost finally; both fatal paths (uncaught handler and main().catch) obey the same rules.
  • Exit code 7 = duplicate lock/port on both fatal paths via one centralized classifier — the supervisor can tell an expected race loser from a crash.
  • scripts/voice-lock.py: the single guarded (fcntl.flock) implementation of every PID-lock transaction — acquire/read/release/steal/guard-hold and a full takeover (validate → TERM → wait → KILL → revalidate → unlink under one held guard); structured {v, lockId, pid, startTimeMs, entry, workspace} lock; adopted mode cross-checks the :9900 listener PID + realpath'd entry; owned mode kills the process group. Interpreter via sutando-config.sh python-bin, now smoke-testing execute+import fcntl and failing closed.
  • Complete kill-path inventory: restart-voice-agent.sh, src/restart.sh's pkill, startup.sh's :9900 reap, voice-config-switch.ts's kickstart -k, health-check.py's launchd repair — all through the guarded transaction, plus a grep CI gate (tests/voice-agent-kill-path-gate.test.py) against new unguarded kills.

What files should reviewers look at first?

scripts/voice-lock.py (the transaction semantics), src/crash-only.ts + the voice-agent.ts handler/acquirePidLock rewrite, then the call-site migrations (scripts/restart-voice-agent.sh, src/startup-runtime.sh, src/voice-config-switch.ts, src/health-check.py).

What user behavior or bug does this prove?

A wedged/killed voice-agent no longer bricks voice until a human intervenes: fatal errors terminate promptly (supervisor restarts), a duplicate instance exits 7 cleanly, and every kill path validates identity before signaling (never kills a stranger on the port; never deletes a live lock).

Feature/documentation consistency

N/A for docs/ canonical documents: docs/catalog.json has no canonical doc for PID-lock/supervision/exit-code semantics (closest: design-credential-capability-resolver.md — untouched by this PR — and voice-agent-test-framework.md — unaffected). The exit-7 contract is documented in the acquirePidLock doc comment and the module headers here; the cross-repo supervision contract is canonically documented in the desktop repo (docs/design-voice-reliability-and-onboarding.md, merged), which governs this work.

Before/after evidence

Before (the incident, 2026-08-04, unpatched): voice-agent pid 14059 wedged for ~5 h; supervisor log repeated FATAL: voice-agent already running (pid 14059) on every restart attempt; recovery required manual SIGKILL + pid-file removal.

After (2026-08-05, owner-run on a local desktop DMG bundling this branch): kill -9 of the live voice-agent (pid 84771) → supervised replacement (pid 97888) serving within moments, structured lock re-acquired cleanly, and the desktop ledger recorded it:

"recoveryBudget": { "used": 1, "entries": [ { "at": 1785963702307, "kind": "crash-restart" } ] }

Bot-verify (new-behavior suites, do not exist at the merge base — all green at HEAD):

python3 tests/voice-lock.test.py                      → All voice-lock tests passed (45+ checks)
npx tsx --test tests/voice-agent-crash-only.test.ts   → 16 pass / 0 fail
python3 tests/restart-voice-agent-lock.test.py        → all pass (incl. live-lock-never-removed)
python3 tests/voice-agent-kill-path-gate.test.py      → clean (fails on injected violations — verified)

What tests did you run?

Above, plus after updating to latest main (merge commit on the branch): tests/src-map.test.py 25 ✓, full tsx suite 128 files / 746 pass / 0 fail, npm run typecheck clean, scripts/check-python39-compat.py clean. Known env-specific failures tests/core-input-watch.test.py (1) and tests/runtime-health.test.py (4) fail identically at origin/main with byte-identical sources — pre-existing, not introduced.

Live-path / voice manual evidence (owner, 2026-08-05, on the DMG bundling this branch)

Voice exercised over both entry points — the rail mic and the room-header Start Call → Voice dropdown (force-tier menu rendering T0-a/T0-b, T1–2 n/a, T3 rows) — with real work round trips. Transcript excerpt from data/conversation.sqlite (voice table, sessions session_1785963296069/…703658):

ts                   kind       text
2026-08-05 20:57:05  tool_call  work
2026-08-05 20:57:07  user       what is in my workspace?
2026-08-05 20:57:07  agent      Working on it.
2026-08-05 20:59:11  tool_call  get_core_status
2026-08-05 20:59:16  agent      The core agent is currently idle. Is there anything I can help you with?
2026-08-05 20:59:30  tool_call  get_task_status
2026-08-05 20:59:37  agent      It looks like the task is still pending and hasn't produced a result yet. I'll let you know…
2026-08-05 21:00:48  agent      Your workspace features two main spaces: code is in the `engine/sutando` checkout, while p…

(The 21:00:48 turn is the completed work task speaking its result — the voice→core→voice loop with this branch's lock/crash machinery active. Idle-teardown then closed the Gemini transport normally.)

Edge cases / non-happy paths checked

Throwing message/stack getters in the fatal path; injected fd-open failure; EADDRINUSE through both fatal paths; concurrent lock acquisition (exactly-one-winner); PID-reuse steal refusal; TERM-ignored KILL escalation; spaced "Application Support" entry paths; owned-mode kill of a non-listening parent/worker process group; malformed/legacy/absent lock forms; missing interpreter ⇒ fail-closed with no bare-pid fallback.

Migrations, config, rollback risks

Legacy bare-pid locks readable for one release (documented in the helper). No config changes. Rollback = revert; the lock file format is forward-written only after this code runs.

Known gaps / follow-ups

The desktop supervisor's exit-7 adoption + takeover orchestration lands in ag2-space/ag2space-cinny-desktop#282 (approved there); probe isolation activates later via sonichi/bodhi_realtime_agent#27 + the group-E pin bump.


Suggested merge order: #2684 → #2689 → bodhi #27 → desktop #282 (then the group-E pin bump activates the probe). CLA: signed (bot confirmed).

🤖 Generated with Claude Code

yixuan-ag2 and others added 7 commits August 5, 2026 11:31
… test

scripts/voice-lock.py is the single implementation of the voice-agent
PID-lock transaction (design 1b; impl plan WS1 Step 3, amendments
R3/S4/U1/Z1): every create/validate/replace runs under an advisory
fcntl.flock on <workspace>/.voice-agent.lock.guard, the JSON lock
({v:1, lockId, pid, startTimeMs, entry, workspace}) is owner metadata
only, legacy bare-pid locks stay readable for one release, and a live
lock is never removed. The takeover subcommand owns the whole
kill-and-replace transaction (validate -> TERM -> wait -> KILL ->
revalidate -> lockId-compared unlink) under the held guard, with an
adopted mode (full U1 identity check incl. the :9900-listener
cross-check) and an owned mode (supervisor-recorded pid/pgid + start
time + entry, whole-process-group kill, no listener requirement).

sutando-config.sh python-bin now prints an ABSOLUTE interpreter path
only after executing it and importing fcntl, else exits non-zero with
an actionable fix (amendment T1) — callers of the lock helper must
never receive an unverified interpreter.

Tests: tests/voice-lock.test.py (fresh/held/stale/PID-reuse/unknown
acquire, one-release legacy fallback, concurrent-acquire single
winner, steal semantics, guard-hold kernel release, graceful-TERM /
KILL-escalation / concurrent / blocked takeovers, owned process-group
kill) and tests/sutando-config-python-bin.test.py (explicit, broken,
bundled, PATH, absent tiers).

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

Crash-only conversion (design 1d; impl plan WS1 Steps 1-2, amendments
R1/R2): uncaughtException/unhandledRejection now TERMINATE instead of
logging err and staying alive (the 2026-08-04 incident spun inside the
old reporting path itself). The handler writes a bounded primitive-only
crash record to <workspace>/logs/voice-agent.crash.json (constructor
name <=64 + String(message) <=512, each extracted in its own try/catch;
no logger/recorder/SQLite/err.stack) and exits in the outermost
finally. One-shot guard: a second fatal goes straight to
process.exit(1). recorder.flush() stays only in graceful shutdown.

Exit classification is centralized (src/crash-only.ts): EADDRINUSE
reaches exit 7 on BOTH paths — the uncaught handler and main().catch —
matching the duplicate-lock exit 7 the supervisor's grace window keys
on (Step 2 / R2).

acquirePidLock delegates the whole lock transaction to the guarded
voice-lock.py helper (Step 4): entry realpath'd from argv[1],
interpreter resolved once via sutando-config.sh python-bin (R4/T1,
injectable for tests); helper held -> two FATAL lines + exit 7;
interpreter/helper unavailable -> FAIL CLOSED with an actionable error
(R3 — no unguarded legacy bare-pid writer). Exit-time release is a
non-blocking detached spawn and is suppressed entirely on the fatal
path (R1/S4) — the stale lock is left for the supervisor to replace
under the guard.

Tests: tests/voice-agent-crash-only.test.ts — crash-record unit cases
(throwing getters, failing fs write, plain Error, bounds), R2
classification, one-shot guard, R1 release suppression, resolver
failure mapping; integration: duplicate spawn (loser exits 7 with the
FATAL line, winner keeps its structured lockId'd lock), EADDRINUSE ->
exit 7 via main().catch, and fail-closed boot with a broken SUTANDO_PY.

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

scripts/restart-voice-agent.sh (impl plan WS1 Step 5, amendment S4):
the stale-pid block no longer cats a bare int, greps only the dev argv
shape, kills from the shell, or rm -f's the lock. The whole
kill-and-replace transaction is one 'voice-lock.py takeover' invocation
under the held fcntl guard, validating dev (src/voice-agent.ts) and
packaged (dist/voice-agent.js) entry shapes plus the :9900-listener
cross-check (U1). takeover-blocked / unknown lock -> WARN and proceed
to kickstart WITHOUT touching the lock (a live lock is never removed);
interpreter unavailable -> fail closed (R3) instead of racing the lock.

src/restart.sh (amendment U2): the broad 'pkill -f voice-agent' is
replaced by the same guarded takeover; identity mismatch means nothing
is signaled, and a missing interpreter skips the stop fail-closed
rather than killing blindly.

Tests: tests/restart-voice-agent-lock.test.py — legacy dead-owner
cleared; structured dev- and packaged-shape owners killed + stolen;
malformed lock left in place with a WARN; live unvalidated owner never
signaled, lock never removed (launchctl/lsof PATH shims).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_argv_entry_matches tokenized `ps -o args=` on whitespace, so an entry
path containing spaces (e.g. a packaged install under
'~/Library/Application Support/...') could NEVER match a live argv: the
path split into fragments, every adopted takeover of a legitimate owner
became takeover-blocked, and the identity check that exists to protect
bystanders instead wedged real restarts.

Candidates are now every whitespace-boundary-to-boundary substring of
the full args string: absolute candidates match on realpath equality
with the (realpath'd) lock entry, relative candidates as a path suffix —
a superset of the old single-token behavior, so all existing shapes
still validate.

Test: adopted takeover of a stand-in listener whose entry lives under an
'Application Support' directory (spaced path) — replaced, owner
terminated, lock unlinked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…RINUSE path (R1/R2/1d)

main().catch was the one fatal path still outside the crash-only rules:
it never set the fatal flag (so the exit-time Python lock release could
run — and block on the guard — from a fatal exit) and its generic branch
logged `console.error('Fatal:', err)`, the exact object-inspection
reporting shape the 2026-08-04 incident spun inside.

Both fatal paths now obey identical rules: markFatalExit() first (R1
release suppression + one-shot guard — a second fatal goes straight to
process.exit(1)), EADDRINUSE still classifies to exit 7 (R2), and the
generic branch writes the bounded primitive-only crash record via
writeCrashRecordAndExit with a static-string log line — never err
inspection. The crash-record path is hoisted to a module const shared by
the uncaught handler and main().catch.

Two test-only fault-injection seams (SUTANDO_TEST_MODE gated) drive the
real paths end-to-end: SUTANDO_TEST_FAIL_MAIN throws after the lock is
acquired; SUTANDO_TEST_RAISE_UNCAUGHT throws asynchronously after the
fatal handlers are installed.

Tests: EADDRINUSE loser via main().catch now also asserts its own lock
stays in place (release suppressed on THIS path too); a generic main()
failure exits 1 with the bounded crash record, the static [FATAL] line,
no object-inspected dump, and the lock left for the supervisor; and a
direct uncaughtException EADDRINUSE case exits 7 through onFatal (not
main().catch) with no crash record and the lock left in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s guarded + grep gate

The three remaining direct kill/kickstart call sites now route through
the guarded 'voice-lock.py takeover' transaction (impl plan amendment
T4), and a grep-based CI gate keeps the inventory closed:

* startup.sh listener reap (:9900): the generic reap_wedged_listener's
  `lsof -ti | xargs kill` no longer applies to voice-agent. A new
  reap_wedged_voice_agent (src/startup-runtime.sh, sourceable for
  tests) keeps the same real-HTTP wedge probe but delegates the
  kill-and-replace to ONE guarded takeover: identity mismatch or an
  unknown/absent lock => takeover-blocked, nothing signaled, lock
  untouched; interpreter unavailable => fail closed (skip the reap).

* voice-config-switch.ts: the detached `launchctl kickstart -k
  gui/<uid>/com.sutando.voice-agent` spawn is replaced by the guarded
  wrapper scripts/restart-voice-agent.sh (exported fireGuardedRestart,
  spawn injectable for tests).

* health-check.py fix_launchd: the voice-agent label's repair now runs
  the guarded wrapper instead of the direct kickstart; the launchd
  `bootstrap` fallback stays (it loads a job, signals nothing);
  web-client keeps the direct kickstart (never names voice-agent).

Kickstart semantics for the last two sites (T4 note): `launchctl
kickstart -k` of a launchd job is a kill-and-RESTART, not a raw kill, so
the guarded transaction wraps the PRE-kickstart validation — the
identity of the running job pid is validated and any orphan/stale lock
resolved as one voice-lock.py takeover under the held fcntl guard
(validate -> TERM -> wait -> KILL -> revalidate -> unlink), and only
then does the wrapper kickstart (which launchd itself scopes to its own
job's pid) and verify the restart really happened via the fresh-listener
etime check. Identity mismatch => takeover-blocked, no signal; no usable
interpreter => the wrapper fails closed (exit 6) before touching lock or
process.

New wrapper precondition: with those call sites routed through it, the
wrapper now aborts (exit 5) BEFORE the takeover when the launchd job is
not loaded — on a plain dev checkout the old direct kickstart silently
no-op'd, and killing a startup.sh-launched agent with nothing to respawn
it would turn a working voice stack into an outage. health-check's
bootstrap fallback still handles the plist-present-but-unloaded case.

CI gate: tests/voice-agent-kill-path-gate.test.py greps every tracked
source file for unguarded shapes (`kickstart -k ... voice-agent`,
`pkill ... voice`, kill+9900, generic reap of 9900) outside the two
allowlisted helpers (scripts/voice-lock.py — the transaction itself —
and scripts/restart-voice-agent.sh — the guarded wrapper), skipping
comments and substitution-free advisory echoes; known documentation
strings are allowlisted per line so any new hit is a conscious edit.
Verified to fail on injected kickstart-k and pkill violations.

Tests: startup wedge recovery (validated owner killed under the guard;
non-listener lock blocked with no signal; no-lock never kills the
unvalidated port owner; responsive listener untouched; no interpreter
fails closed; startup.sh wiring asserted), config switch (wrapper
spawned detached, no launchctl argv anywhere in the tool), health-check
repair (wrapper invoked, never direct kickstart; wrapper failure falls
back to bootstrap, not kickstart; plist-less host stays advisory and
signals nothing; web-client parity), the no-job abort (signals nothing,
leaves the lock, no kickstart), and the gate itself.
tests/health-check-fix-launchd-label.test.py's pinned expectation
updated to the guarded contract (still proves issue-sonichi#1888's --fix
actually restarts the service).

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

CLAassistant commented Aug 5, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@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 end-to-end at head c64c7a5e. Cloned the PR, installed deps, and ran the full added/changed test surface: voice-lock.test.py (~45 checks incl. concurrent-acquire single-winner, PID-reuse steal refusal, TERM→KILL escalation, spaced-path, owned process-group kill), restart-voice-agent-lock.test.py, startup-voice-wedge-reap.test.py, sutando-config-python-bin.test.py, voice-agent-kill-path-gate.test.py, health-check-voice-guarded-fix.test.py (4/4), health-check-fix-launchd-label.test.py — all pass; plus voice-agent-crash-only.test.ts 16/16 (incl. the real-spawn cases: loser exits 7 keeping the winner's lock, EADDRINUSE→7 through both main().catch and the uncaughtException handler, fail-closed boot on a broken interpreter, release suppressed on fatal) and voice-config-switch-guarded-restart.test.ts 3/3. tsc --noEmit clean; src-map.test.py 25/25.

Audited the load-bearing logic:

  • crash-only.ts: exit() is in the outermost finally; name/message are each extracted in their own try/catch (throwing-getter safe); a one-shot markFatalExit guard routes a second fatal straight to exit(1).
  • voice-lock.py: the guarded-transaction invariants hold — "a live lock is never removed," fail-closed with no unguarded fallback, identity validation before any signal. Confirmed 3.9-safe (no 3.10+ syntax, from __future__ import annotations, py_compile OK on the bundled floor).

The dev-checkout behavior change (restart-voice-agent.sh exit-5 abort when the launchd job isn't loaded) is a correct safe tradeoff, not a regression vs. the old silent-no-op — aborting beats "kill with nothing to respawn." Net: this strictly improves crash/lock/exit recoverability over the pre-PR spinning-wedge behavior. LGTM.

Minor non-blocking nits (no need to hold): _argv_entry_matches is O(n²)+realpath per candidate but runs once per takeover; src/restart.sh adopted-mode no-ops on an absent lock (recovery falls to startup.sh wedge-reap, acceptable). Branch is BEHIND main — needs an update-branch before merge.

@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 diff at the current head (21 files, +3126/−111). I did not run the test suite and did not reproduce the 08-04 incident — everything below is read from the diff, and I say so per REVIEW.md ("if you did not verify a claim, say so explicitly").

What I checked against REVIEW.md

6 — hardcoded absolute paths: PASS. Scanned every added line for /Users/…, /home/…, C:\Users. Zero hits.

4 — destructive / auto-remediating actions, default state + blast radius. This is the part that deserved the most attention: the PR adds two process-group kills (os.killpg(pgid, sig) in voice-lock.py, process.kill(-child.pid, 'SIGKILL') in the TS path). A group kill with a wrong gid is unbounded, so I traced how pgid is established. The guard chain before the kill is genuinely strong, and each failure is a distinct blocked code rather than a fallthrough:

  • start-time must match the supervisor-recorded value → takeover-blocked (this is the PID-reuse defence, and it's the right one)
  • argv must match an expected voice-agent entry → takeover-blocked
  • pgid is None → takeover-blocked
  • a descendant lock holder must be in the same group → takeover-blocked
  • post-kill, the pidfile is unlinked only after re-validating lock_id → takeover-blocked on concurrent replacement

Replacing the old blind pkill -f voice-agent with this is a clear net reduction in blast radius.

3 — proving the fix by exercising the failure mode: PASS. 24 added test cases, and they hit the failure paths rather than the happy path — injected fdOpen failure with exit still called, EADDRINUSE → exit 7, duplicate-spawn loser exits 7 while the winner keeps the lock, non-EADDRINUSE main() failure still producing a bounded crash record, unusable interpreter failing closed with no bare-pid fallback lock. The fault injection on the crash-record writer is the right instinct, since the 08-04 spin was in the reporting path.

1 — bug exists on main. The body cites the incident with a profile trace (TriggerUncaughtException → InspectorConsoleCall/ErrorStackGetter), which is concrete. I did not independently reproduce it.

One hardening nit (non-blocking)

voice-lock.py takes --pgid as type=int, default=None and rejects only None. killpg(0, sig) signals the caller's own process group, so a --pgid 0 invocation would have the supervisor kill itself. No caller in this diff passes --pgid (the internal path uses pid_pgid(root), which never yields 0 for a live process), so this is not reachable as written — but since this file is explicitly "the single guarded implementation of every PID-lock transaction" and is meant to be called by other things later, I'd reject pgid <= 1 alongside the is None check. One line, and it closes the hazard before a future caller can find it.

Merge-readiness

From a static read, I see no blocker. The guard chain, the exit-7 contract, and the failure-mode tests all look right, and the hardcoded-path check passes. I am explicitly not recording this as a formal approval, because I have not run the suite or exercised the wedge path on a real host — for a change that kills process groups on a live supervisor, someone should confirm the tests green and do one real wedge-and-reap round trip before merge. If a maintainer has already done that, treat my note as one more pair of eyes on the kill path rather than an independent verification.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Independent execution pass at head c64c7a5e (worktree, this host — the machine that runs a live voice stack):

All five python suites green by my own run: voice-lock.test.py, restart-voice-agent-lock.test.py, startup-voice-wedge-reap.test.py, voice-agent-kill-path-gate.test.py, health-check-voice-guarded-fix.test.py. This is the independent test-green confirmation susan's static review asked for. (I deliberately did NOT run a live wedge-and-reap on my host's voice agent — the owner was using voice within the hour and a supervisor kill-cycle is user-visible; john's end-to-end run already covers that leg.)

One check neither review pinned — the exit-7 CONSUMER side: the code comments repeatedly key on "the supervisor's exit-7 grace window", but no shell supervisor in this diff implements it. It lives in desktop#282 (exit-7 grace window + two-ledger budget, already reviewed) — so the producer/consumer contract is coherent ACROSS repos, and the owner's merge ordering (this first, desktop pin-bump after) is exactly right. Worth one line in the PR body naming desktop#282 as the consumer so the contract isn't archaeology later.

Susan's --pgid 0 nit is still open at this head (voice-lock.py rejects only None; killpg(0, sig) signals the caller's own group). Unreachable today, one line to close, fine as a fast-follow.

No blockers from me — with john's run-verified approval this is merge-ready.

— 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 per REVIEW.md, with the verification gap the prior two reviews flagged now closed: I ran the suite locally on the PR head (c64c7a5, the current merge-of-main head).

What I ran (all green)

tests/voice-lock.test.py                    -> All voice-lock tests passed.
tests/restart-voice-agent-lock.test.py      -> All restart-voice-agent lock-migration tests passed.
tests/startup-voice-wedge-reap.test.py      -> All startup wedge-recovery tests passed.
tests/health-check-voice-guarded-fix.test.py -> OK
tests/voice-agent-kill-path-gate.test.py    -> voice-agent kill-path gate: clean.
tests/sutando-config-python-bin.test.py     -> All python-bin contract tests passed.
tests/health-check-fix-launchd-label.test.py -> all passed
npx tsx --test tests/voice-agent-crash-only.test.ts tests/voice-config-switch-guarded-restart.test.ts
  -> # pass 19 / # fail 0
git diff origin/main...HEAD | bash scripts/review-checks.sh -> PASS (hardcoded-paths clean)

Independent read of the kill paths (REVIEW.md lessons 2/4/5)

I traced both takeover modes rather than re-reading the PR body. Adopted mode: unparseable lock → blocked (a live lock is never removed); dead owner / PID-reuse cleared only via _unlink_after_revalidate; U1 holds — the lock pid must equal the :port LISTEN pid before anything is signaled; entry + workspace validated for structured locks; final unlink re-validates lockId. Owned mode: supervisor-recorded start-time must match, argv entry must match, pgid is None → blocked, and a descendant lock holder outside the owned group → blocked before the killpg. The startup integration replaces the old port-match lsof | xargs kill with this guarded transaction, fails closed when no usable interpreter is found, and only fires on a real liveness-probe timeout (curl rc 28 specifically — other probe errors leave the listener untouched). Worst-case-disruption question (lesson 5): the reap is on by default at startup, but every kill is identity-validated where the old path signaled on a port match alone — strictly narrower blast radius than what ships today.

Seconding one nit + one owner-actionable note

  • @liususan091219's --pgid hardening nit is correct and correctly non-blocking: I verified the diff's only --pgid occurrence is the argparse definition — no caller passes it — so killpg(0, …) is unreachable as written. Rejecting pgid <= 1 is a worthwhile one-liner here or in the stacked follow-up.
  • Full CI has not run on this head: the head's workflow runs are all action_required (fork PR awaiting a maintainer's "Approve and run") — only recheck and license/cla have reported. My local runs above cover the new suites, but a maintainer should approve the workflow runs so the required checks report before merge.

Approving on the strength of the local runs + the traced guard chain.

@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.

Follow-up to my earlier static review — I reproduced the failure mode this PR targets, and ran the detection logic against three different wedge shapes. Both were done in isolation (scratch port 9978, simulated process in its own process group); no real voice-agent was touched.

1. The failure mode reproduces exactly as the body describes

A process that binds the port, accepts the connection, and never answers:

port 9978 IS held by the wedged process
liveness probe -> curl_rc=28

Three simulated supervisor restarts against it:

restart attempt 1: FAILED — Address already in use (EADDRINUSE)
restart attempt 2: FAILED — Address already in use (EADDRINUSE)
restart attempt 3: FAILED — Address already in use (EADDRINUSE)

That is the 08-04 shape: the process never dies, so the port is never released, and restarting is useless however many times you try. After a guarded kill of the wedge's process group, the port binds immediately — so takeover is the right shape of fix, and "just restart harder" provably is not.

Caveat on what this does and does not show: I used equivalent kill logic, not this PR's scripts/voice-lock.py takeover (which wants a properly formed pidfile + recorded start time). So the failure chain and the value of takeover are reproduced; the implementation in this PR is still unexecuted by me.

2. reap_wedged_voice_agent only recognises ONE shape of wedge

[ "$rc" -eq 28 ] || return 0 means only a curl timeout counts. I ran the same two lines against three wedge shapes, with a control:

simulated failure curl rc reaped?
hangs, never answers (control) 28 yes — takeover proceeds
answers HTTP 500 immediately, forever 0 no — returns early
accepts then resets the connection 56 no — returns early

The control row matters: it confirms the harness can detect the positive case, so the two negatives are meaningful rather than a dead probe.

Consequence: an agent that is fast but useless is never reaped. curl without -f treats a 500 as success (exit 0), and a reset gives 56 — neither is 28, so reap_wedged_voice_agent returns before the guarded takeover it exists to gate. Discord's equivalent doesn't ask why a peer stopped being useful; here only one manner of death counts.

I'd treat this as a follow-up rather than a blocker: the 08-04 incident was a starved event loop, which genuinely does time out and is caught by the current check. The gap is that nothing covers the fast-garbage case, and the threshold errs toward under-reaping (which is the safe direction — a transient stall can't cause a wrong kill).

Small hardening note, repeated from my earlier review

voice-lock.py accepts --pgid (type=int, default=None) and rejects only None. killpg(0, sig) signals the caller's own process group. No caller in this diff passes --pgid, so it isn't reachable as written — but building the reproduction above, I had to guard my own harness with pgid != mine && pgid > 1 or it would have killed my own shell. For a file described as the single implementation of every PID-lock transaction, rejecting pgid <= 1 next to the existing is None check is one line and closes it before a future caller finds it.

@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 1 — first of the sutando chain (#2684 → #2689 → #2690/#2691). Please use a merge commit, not squash: the three later PRs' branches contain this branch's history, so a merge commit makes each child's diff collapse to its own commits after the parent lands, while squash would leave every child's diff polluted with the parent's changes (or force rebases that invalidate the approvals).

🤖 Generated with Claude Code

auto-merge was automatically disabled August 7, 2026 00:28

Head branch was pushed to by a user without write access

@yixuan-ag2
yixuan-ag2 force-pushed the voice/phase1-lock-and-crash branch from 6484bda to 43d39d1 Compare August 7, 2026 00:28
@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 2a34ae0db6bdaa8b457ddd15a41f79767b687be9. The available recheck and CLA checks are green; GitHub reports MERGEABLE/APPROVED but BLOCKED. The missing workflow cannot be rerun because there is no failed run to rerun. Please push a new commit (an empty CI-trigger commit is sufficient) to re-fire the pull-request workflows, then re-check the required gate. 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 2a34ae0db6bdaa8b457ddd15a41f79767b687be9. Required CI is now running; current state remains MERGEABLE/APPROVED but BEHIND. I will not bypass the branch rules—once the checks pass, the branch still needs an update to current main before merge.

@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 77%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • scripts/voice-lock.py (99.3%): Missing lines 332,370,397
  • src/health-check.py (100%)

Summary

  • Total: 415 lines
  • Missing: 3 lines
  • Coverage: 99%

scripts/voice-lock.py

Lines 328-336

  328             record = _create_lock(args.pidfile, args.pid, args.entry, args.workspace)
  329         except OSError as e:
  330             if e.errno == errno.EEXIST:
  331                 _emit({"code": "held", "holder": read_lock(args.pidfile)}, 7)
! 332             raise
  333         _emit({"ok": True, "lock": record})
  334 
  335 
  336 def cmd_read(args):

Lines 366-374

  366         if pid_alive(lock["pid"]):
  367             actual = pid_start_time_ms(lock["pid"])
  368             expected = None
  369             if args.expect_start_time_ms is not None:
! 370                 expected = args.expect_start_time_ms
  371             elif lock["kind"] == "structured":
  372                 expected = lock["startTimeMs"]
  373             if expected is None or actual is None or start_times_match(actual, expected):
  374                 # Alive with no disproving start-time evidence → a live lock is

Lines 393-401

  393     # Hold until stdin EOF; the kernel releases the flock when we die with our
  394     # parent (long-lived hold, auto-release on crash).
  395     try:
  396         while sys.stdin.read(4096):
! 397             pass
  398     except (OSError, KeyboardInterrupt):
  399         pass
  400     sys.exit(0)

@john-the-dev

Copy link
Copy Markdown
Collaborator

Required CI did not pass on head 2a34ae0db6bdaa8b457ddd15a41f79767b687be9: 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.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Addressed on head 1770747:

  • Reject caller-supplied PGIDs less than or equal to zero before killpg; the regression test isolates the helper process group and proves the target receives no signal.
  • Instrument voice-lock helper subprocesses in the Python coverage gate and add fail-closed branch tests. Combined helper coverage is now 99% (404/406 statements), up from the reported 0%.
  • Allow the established SUTANDO_TEST_MODE seam through the macOS startup guard so the Linux runner can execute the crash-only integration suite. Non-test Linux startup still exits 1 and has an explicit regression case.
  • Merge current upstream main while preserving merge-based stack history.

Local verification: voice-lock functional suite green; 14 branch tests green; tsc clean; Ruff and ShellCheck clean. The PR body already names desktop #282 as the exit-7 consumer. Required fork workflows are currently action_required and await maintainer approval.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@yixuan-ag2
yixuan-ag2 merged commit 1a3733a into sonichi:main Aug 7, 2026
18 checks passed
yixuan-ag2 added a commit that referenced this pull request Aug 7, 2026
… stacked on #2684) (#2689)

* feat(voice-agent): agent.state v1 emitter + lifecycle snapshot + Z3 idle 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>

* test(agent-state): use ephemeral ports for spawned-agent integration 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>

* docs: regenerate source map after stack rebuild

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@yixuan-ag2
yixuan-ag2 deleted the voice/phase1-lock-and-crash branch August 7, 2026 20:34
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.

5 participants