Repository navigation
Add Discord monitoring to proactive loop - #34
Merged
Merged
Conversation
Checks all 5 Sutando Discord channels and forwards actionable messages to DM. No separate cron job needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
liususan091219
pushed a commit
to liususan091219/sutando-fork
that referenced
this pull request
May 28, 2026
qingyun-wu
added a commit
that referenced
this pull request
Jul 26, 2026
…or (std A P1) (#2328) * feat(runtime): run-dir + runtime-socket resolvers + additive descriptor (std A P1) Task A P1 of the runtime-standardization initiative (owner standard.md 2026-07-26; 001=A path+descriptor, air=B hook-contract #2327). Layer 1: give the shell a single canonical resolver for the runtime run-dir + runtime-api socket so scripts stop each inventing their own path — and make it agree BYTE-FOR-BYTE with the daemon. - `sutando-config.sh run-dir` / `runtime-socket`: mirror #2325's ag2space runtime-api `rundir.py` EXACTLY — run-dir = SUTANDO_RUN_DIR > darwin `~/Library/Application Support/space.ag2.app/run` > `$XDG_RUNTIME_DIR/sutando` > `~/.sutando/run` socket = SUTANDO_RUNTIME_SOCKET override, else `<run-dir>/sutando-runtime.sock` (filename is `sutando-runtime.sock`, NOT `runtime-api.sock` — #2325 ships that default; both ends interpret it here so shell + daemon can never disagree.) `SUTANDO_TMUX_SOCKET` KEEPS its `/tmp/sutando-tmux.sock` default (back-compat — moving it would strand running OSS cores); the new vars are additive. - `sutando-config.sh runtime` descriptor: **additive** fields (air-confirmed additive, desktop-safe, no migration) — `schemaVersion`, `runtimeId`, `runtimeRoot`, `runtimeSocket`, `backend{type,socket,session}`. Existing `socket`/`session`/etc. keys stay for desktop back-compat. The 4-window `components` map is deliberately NOT emitted yet — it lands in P2 with the session rename (`sutando-core` -> `sutando` + core/gateway/runtime-api/monitor); emitting it now would misrepresent the single current session. Test: `tests/runtime-rundir-resolver.test.sh` — 11 checks (override precedence, OS-default chain, socket derivation, additive-descriptor + back-compat keys). All green. Follow-ups (task #34): P1b correlation helper (runtimeId/agentId/ownerId/hostId/ executionId for task/result/hooks, matching the #2327 envelope); P2 tmux topology (session rename + TmuxBackend + window-scoped lifecycle verbs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2328): green CI + address air nits (XDG runtimeRoot, chain drift-guard) Two CI failures + air's two review nits, in the runtime rundir resolver: - runtime-rundir-resolver.test.sh: runtimeRoot is now asserted as an OS-independent prefix of runtimeSocket rather than a hardcoded parent, and a drift-guard check asserts the descriptor's own chain copy equals the `runtime-socket` subcommand (air nit b: the two in-file copies of the rundir.py chain can no longer diverge silently — de-dup by assertion, since a subprocess call from inside the python -c heredoc is fragile). - descriptor runtimeRoot: parent of run/ when run-dir is <root>/run (darwin App-Support, portable ~/.sutando), else the run-dir itself for the XDG case where XDG_RUNTIME_DIR/sutando IS the app dir (air nit a). - lint-sutando-home-path.sh: allowlist tests/runtime-rundir-resolver.test.sh (it necessarily names ~/.sutando/run to assert the fallback branch). - Removed shell-active chars ($-vars, backticks) from the python -c comment block — they were bash-expanded inside the double-quoted -c string (XDG_RUNTIME_DIR unbound under set -u truncated the program, emitting empty JSON). Verified: resolver test 12/12 green, lint self-test green, lint --diff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2328): remove backtick command-substitution in the runtime -c comment (Sutando-Pro security finding) The descriptor's python -c comment block wrote `components` in backticks. That block is a bash double-quoted `python3 -c "…"` string, so bash command- substitutes the backticks — running a `components` command on every `runtime` invocation: stderr noise ("components: command not found") and, if an attacker places a `components` binary earlier on PATH, arbitrary execution. Fix: drop the backticks (plain "The components field …") and add an explicit NOTE that this comment block must carry no backticks or $-vars, since bash would command-substitute / expand them (the same class already removed for the run-dir/runtime-socket/$XDG_RUNTIME_DIR references in the prior commit). Verified: `runtime` stderr is now clean (no command-not-found), stdout is still valid JSON (17 keys), resolver test 12/12 green. Reported by Sutando-Pro. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun@ag2.ai>
yixuan-ag2
added a commit
that referenced
this pull request
Aug 18, 2026
… surfaces) (#3131) Picks up the five merged bodhi PRs the voice context-budget design depends on (sonichi/bodhi_realtime_agent #33, #34, #31, #30, #32): - upstream send counters + getDiagnostics() (attempted/queued/skipped/threw, split raw/wire bytes, per-outcome timestamps; reset per transport generation) - typed connection-lifecycle events (attempt / setup-ok / setup-failed / attempt-close / generation-close, correlated by connectAttemptId) - onUsageMetadata (promptTokenCount et al), reachable from VoiceSession - mediaResolution passthrough (session-wide media token cost) - optional compressionConfig thresholds ({} = server defaults), sent as the int64-over-JSON strings the API expects Plus review hardening from the merge train: observer isolation on both new callback surfaces, reconnect replay counted AND moved off the deprecated `media` field onto the supported video/audio slots, and superseded-dial setup-failed fenced like every other stale-dial signal. Pin only; no sutando code change. Consumers land separately (Phase 1: ledger sampling, logicalSessionId, up=/ctx= health segments, shadow matrix). Evidence: Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped. Live round trip through the PINNED artifact (not the SDK directly) against gemini-2.5-flash-native-audio-preview-12-2025 — connect with mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out; every new surface read while doing it: connected in 304ms; lifecycle so far: attempt -> setup-ok text slot : {"attempted":1,"queued":1,"queuedRawBytes":26} generation : 1 audio chunks : 1 (first after 1442ms) usage samples : 1 promptTokenCount 375 -> 375 lifecycle full: attempt -> setup-ok -> generation-close LIVE ROUND TRIP: PASS Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
added a commit
that referenced
this pull request
Aug 18, 2026
…ow matrix (#3132) * chore(deps): bump bodhi pin ca0f61de -> 0d506ead (B1-B5 observability surfaces) Picks up the five merged bodhi PRs the voice context-budget design depends on (sonichi/bodhi_realtime_agent #33, #34, #31, #30, #32): - upstream send counters + getDiagnostics() (attempted/queued/skipped/threw, split raw/wire bytes, per-outcome timestamps; reset per transport generation) - typed connection-lifecycle events (attempt / setup-ok / setup-failed / attempt-close / generation-close, correlated by connectAttemptId) - onUsageMetadata (promptTokenCount et al), reachable from VoiceSession - mediaResolution passthrough (session-wide media token cost) - optional compressionConfig thresholds ({} = server defaults), sent as the int64-over-JSON strings the API expects Plus review hardening from the merge train: observer isolation on both new callback surfaces, reconnect replay counted AND moved off the deprecated `media` field onto the supported video/audio slots, and superseded-dial setup-failed fenced like every other stale-dial signal. Pin only; no sutando code change. Consumers land separately (Phase 1: ledger sampling, logicalSessionId, up=/ctx= health segments, shadow matrix). Evidence: Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped. Live round trip through the PINNED artifact (not the SDK directly) against gemini-2.5-flash-native-audio-preview-12-2025 — connect with mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out; every new surface read while doing it: connected in 304ms; lifecycle so far: attempt -> setup-ok text slot : {"attempted":1,"queued":1,"queuedRawBytes":26} generation : 1 audio chunks : 1 (first after 1442ms) usage samples : 1 promptTokenCount 375 -> 375 lifecycle full: attempt -> setup-ok -> generation-close LIVE ROUND TRIP: PASS Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(voice): P7 Tranche B — upstream ledger, lineage, ctx gauge, shadow matrix The health matrix's most common verdict on 2026-08-17 was insufficient-evidence (speech-without-model-event; upstream-hop-unobserved) — 28 times in one day. The right verdict, and a useless one: up=n/a on every line because the agent→Gemini hop had no counters. The bumped bodhi pin exposes them; this wires the sutando half, dark. Ledger (voice-audio-health.ts): - coverage becomes an ordered LedgerCoverage level with observesUpstreamSend(); the live snapshot stays 'session-only'. - samples bodhi's VoiceSession.getDiagnostics() on ledger ticks only (never the frame path; §D7.0b untouched). Absent or throwing ⇒ up=n/a — null means unobserved, never zero. - [Health] renders up={aQ/s,aKB/s,vQ/s,vKB/s,drop,buf:n/a} from per-tick deltas; a generation change renders that tick from zero rather than a negative rate. ctx={tok,age} appears once usage metadata is seen. - logicalSessionId/lineageState derived sutando-side from lifecycle events (design §1.1): minted on a handle-less setup-ok; resumed keeps it; suspected-sever (setup-failed with a handle, or handle-accepted 1008 close) is TERMINAL — only a fresh lineage exits it, by minting. Context occupancy belongs to the lineage and resets with it. - persisted rows (full and reduced) carry every evaluator input: counters, per-outcome timestamps, generation, lineage, contextTokens, echoSuppressed. Matrix (voice-health-matrix.ts): - structural guard BEFORE every delta row: a transport-generation change inside the window is insufficient-evidence — upstream counters reset per generation, so row 5's timestamps are as unusable across it as row 4's deltas. - row 4 upstream-send-dead: eligible ingress, zero attempts — a dead call path. Row 4b upstream-send-failing: attempts, zero queued, named by guard outcome. Both exclude echo-suppressed windows (suppression returns before sendAudio and would masquerade as a dead path). - row 5 renamed model-silent → post-sdk-silent, reachable only through the lossless-window proof: dAttempted >= dDelivered ∧ dQueued == dAttempted ∧ lastQueuedAt >= speech.lastAboveFloorAt. Aggregate counters cannot say WHICH frame queued; losslessness proves the speech-bearing ones necessarily did. A lossy window stays insufficient-evidence — at aggregate resolution it genuinely cannot distinguish "model went quiet" from "we dropped the speech". voice-agent.ts: - onUsageMetadata/onConnectionLifecycle wired into the VoiceSession config; getSessionDiagnostics injected into the ledger. - a SHADOW evaluator runs the 'session+egress' ruleset every health tick and logs [Matrix-shadow] on verdict change, telemetry-only. Canary 3a later compares live vs shadow on identical input; without the shadow, 3a has no baseline. The live verdict stream is byte-for-byte unchanged. - the [Health] line now carries vision={sent,defB,defG,disp} while streaming — whether the vision throttle engaged was previously unknowable from the log (deferredBudget existed but was never surfaced; investigation F2). Tests: 7 new ledger cases (lineage state machine incl. terminal suspected-sever, ctx latch+reset, up= rendering, generation-reset rendering, n/a on unobserved/throwing, replayable persisted row) and 8 new/updated matrix cases (rows 4/4b, structural guard, lossless/lossy/order-unproven, echo exclusion, live-at-session-only unchanged, row-5 rename). Full suite: 1109 pass / 0 fail / 2 skipped. tsc clean. prose-cap PASS. Stacked on voice/bodhi-pin-b1-b5 (#3131); merge order pin → this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(voice): P7 Tranche B round 2 — seven codex findings on #3132, all confirmed - Matrix: an unobserved→observed diagnostics transition could FABRICATE post-sdk-silent — the generation guard fired only when both generations were non-null, while unobserved counters baselined as placeholder zeros, so generation-lifetime totals diffed as one window (the review repro'd it). Upstream deltas now require a valid window: observed baseline, same generation. Rows 4/4b gate on it; row 5 downgrades to insufficient-evidence 'upstream-baseline-unobserved'. - Matrix: MatrixResult gains the §1.5 facts — attemptedAudioAdvanced, queuedAudioAdvanced, losslessWindowWithSpeechQueued, transportGenerationChanged, echoSuppressedAdvanced — computed before any row can return, so every result carries an honest set. The watchdog integration reads facts, never verdict strings; verdict-independence needs them to exist. - Matrix: row 4b named the guard outcome from generation-lifetime totals, letting earlier skips masquerade as the current failure mode. The baseline now carries skippedNoSession/threw; reasons are window deltas. - Ledger: up= rendered cumulative totals over one dt after a missed diagnostics sample (900 queues → "30.0/s" out of thin air). A tick with no adjacent observed baseline renders up=n/a(baselining) and establishes the window; rates resume next tick. A generation change WITH an adjacent baseline still renders from zero — the new generation's counters span at most the window. - Ledger: §1.1's generation→lineage reconciliation records now exist — every lineage commit/re-label persists a reason:'lineage' row carrying connectAttemptId, transportGeneration, resolved logicalSessionId, final state, so offline analysis re-attributes earlier rows instead of trusting a stale in-row id. A failed handle-less dial commits nothing and emits nothing. - Ledger: the reduced persistence row was NOT replayable — flattened upstream fields, missing speech/heartbeat/clientTotals/lastModelEventAt. It now carries every matrix-evaluator input SNAPSHOT-SHAPED (§1.6 option 1) plus the recorded decision: verdict, facts, reasons (§1.6 option 2). A row still over budget strips only the episode list and says so. Replay is pinned: the evaluator produces an identical baseline, verdict, and facts from the full and reduced forms of the same tick. - voice-agent: promptTokensDetails now reaches the ledger and both persistence payloads — §1.4's modality split is what separates "video filled the context" from "conversation did". Held and reset per lineage like the scalar it details. Tests: 9 new/extended cases pin the two review repros (null→observed fabrication, missed-sample rate), 4b window-delta naming, facts honesty at every coverage, reconciliation records, the modality breakdown, and full/reduced replay equivalence. Full suite green; tsc clean; eslint 0 errors (14 pre-existing warnings untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): matrix facts are data-level — coverage- and verdict-independent Codex round 3, two findings on the round-2 commit, both confirmed: 1. Facts depended on coverage and on the verdict path: identical diagnostics produced all-false upstream facts under 'session-only' but true facts under 'session+egress', and a CONNECTING tick returned before facts were derived. §1.5's obligation is the opposite — the watchdog reads facts and must behave identically under every coverage mode and verdict, so a future coverage flip must not change watchdog behaviour. Facts are now computed FIRST, from data validity alone (adjacent same-epoch baseline, both generations observed and equal): facts say what the data shows; coverage gates only which VERDICTS may be claimed. The live verdict stream is unchanged — facts never drive verdicts. 2. upstreamWindowValid required only the BASELINE generation to be non-null. A typed sample with counters present but a null CURRENT generation would diff counters across generations and could emit post-sdk-silent. The window now requires both ends observed; the row-5 downgrade reason is renamed 'upstream-baseline-unobserved' → 'upstream-window-unobserved' since it covers both directions. Tests: the session-only facts test inverted into the §1.5 pin (same input, same facts, different claim across coverage modes); observed→null direction pinned; a new verdict-independence case proves an early reconnect-window return carries the same facts as the ACTIVE evaluation of identical input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(voice): name why the clamp cannot hide a silent reset sonichi asked whether a counter reset without a generation bump would render aQ/s:0.0 indistinguishably from 'the agent stopped sending'. It cannot: at the pinned bodhi commit the reset and the generation bump are adjacent lines in one synchronous block, and each is the only such site in the file. Recording the commit so the next reader re-checks the coupling instead of re-deriving it. * docs(voice): the try/catch guards the TDZ, not the typeof qingyun-wu's review nit, verified: typeof on a let/const inside its temporal dead zone throws ReferenceError rather than yielding 'undefined', so the typeof check cannot be what makes a tick racing construction safe. Behaviour was already correct — only the comment credited the wrong mechanism. * fix(voice): an unobserved upstream baseline must not read as a real zero john-the-dev's review finding. makeBaseline collapsed the four upstream counters to 0 when `upstream` was absent, while upstreamWindowObserved inferred the previous tick's observedness from transportGeneration being non-null. Those are independent fields off the same diag, so a baseline with a real generation and absent counters passed the guard and the next tick diffed against placeholder zeros — turning lifetime totals into a "window" and letting losslessWindowWithSpeechQueued go true on a window that was never measured. The counters are now `number | null` with no `?? 0`, and the guard checks the counter itself rather than using generation-nullness as a proxy. Unobserved is now unrepresentable as zero instead of relying on two fields moving together. Defence-in-depth at the current pin, not a live bug: bodhi's getDiagnostics (0d506ead:575) returns upstream and transportGeneration unconditionally, and `upstream` is always an object — so the two cannot diverge today. That is an invariant held in another repo, which is exactly why the guard should not depend on it. Also amends the structural-guard comment: it is gated on observesUpstreamSend, so below session+egress row 5 is protected by its own honesty branch, not by that guard. Control: the added test fails without the fix (39/40) and passes with it (40/40). * fix(voice): echoSuppressed carries the same unobserved-vs-zero defect john-the-dev's follow-up to 2af342d: that fix made the four upstream counters nullable but left echoSuppressed on `?? 0`, which fires when the whole `diag` is null — reachable by construction, since sampleDiagnostics() returns null whenever getSessionDiagnostics is absent or throws, and the option's own doc says "Absent or throwing => upstream stays unobserved". So an unobserved tick recorded 0, and the next observed tick turned a generation-lifetime total into a one-window delta on echoSuppressedAdvanced, which the watchdog reads. Same violation of this module's stated contract ("Null fields mean UNOBSERVED ... never zero"), one field over; it escaped because the nullability lived in the container rather than the field. echoSuppressed is now `number | null` on both SessionDiagnosticsSample and AudioHealthSnapshot, the `?? 0` is gone, and dEchoSuppressed is gated on both ends being observed — the same shape as its siblings. Also drops a dead assertion they caught in the previous regression test: `r.facts.upstreamWindowObserved` is a local const, not a MatrixFacts member, so `?? false` made it unfailable. Replaced with two real members. Control: the added test fails without the fix (40/41) and passes with it (41/41). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sonichi
pushed a commit
that referenced
this pull request
Aug 21, 2026
… + arming) (#3135) * feat(voice): ACTIVE-silence watchdog — trigger facts + recovery reducer + Phase 0a shadow mode First implementation slice of the approved ACTIVE-silence recovery design (desktop docs/design-voice-active-silence-recovery.md; the 2026-08-17 incident: 3+ minutes of user speech into a dead ACTIVE session, recovery only by abandonment). This PR detects and records; it never touches the live session. - voice-health-matrix: MatrixResult.facts — typed, verdict-independent, fail-closed raw facts (formulas fixed: ingressAdvanced = delta(deliveredFrames) > 0; modelSilentFor15s null-safe), attached to every result. Truth-table tests incl. first-tick/epoch-boundary/ reconnect-window fail-closed rows. - voice-active-silence-watchdog: pure reduceRecovery event reducer implementing the design's normative transition table — latched speech evidence, continuous-silence anchor, epoch-fenced admission, episode attempt budget, waiting-retry with schedule-retry effects (settles design residue 5), fatal-backoff clear/park semantics (residue 6), validated VOICE_ACTIVE_SILENCE_MODE / _TICKS parsing. The 2026-08-17 incident is a named test (fires on the 3rd qualifying tick). - voice-watchdog-shadow: Phase 0a diagnostic observer — derives events from the health tick chronologically (epoch-change attach detection, no stale-speech relatch), epoch-keyed tool veto (background work excluded; first-edge adoption), would-restart / would-terminal / post-fire-progress evidence rows; armed degrades to shadow (no bodhi capability descriptor exists yet). - voice-watchdog-ledger: bounded, counted-drop, awaitable JSONL appender; flushed on shutdown. - voice-audio-health: SpeechEvidence.lastOnsetAt — the utterance onset retained past the hangover, so 30s ticks see the FIRST sample of a completed utterance (real-ledger integration test). - voice-agent: additive wiring only (tool hooks, tick-end observe, shutdown flush). The CONNECTING watchdog and CLOSED recovery guard are untouched; the coordinator refactor and armed mode land in PR 2 with the bodhi recovery surface, per the design's phasing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): shadow round-2 review — boundary-first replay, fenced tool settles, honest post-fire milestones Second Codex verification round, all findings: - client-epoch boundaries now apply BEFORE snapshot-carried events and rebase the speech watermarks, so fresh new-epoch speech latches instead of being cleared by its own reload (named test); - tool settles are fenced by originating epoch (a stale generation's settle emits no outcome and cannot advance the anchor; named test); - post-fire classification anchors to the episode's FIRST fire only, splits upstream progress from functional response (post-fire-progress vs post-fire-functional rows), and counts toolOutcome as progress; - meeting-mode mutations reach the shadow from every mutation site via noteMeetingMode (1s request path + summon/dismiss auto-switch), replayed chronologically — meeting-window speech between ticks never latches (named test); - ledger rows gain capabilitySetId + per-row monotonic offset; drop-counter changes are surfaced in the log; prefer-const lint fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): shadow observer cannot take the agent down Every public entry on VoiceWatchdogShadow sits on a live path, and voice-agent installs a crash-only uncaughtException handler (writeCrashRecordAndExit). observeTick is the last statement in the bare 30s setInterval callback with nothing wrapping it, and the module had zero containment across its 340 lines — so a null deref or an unexpected snapshot shape in the shadow reducer terminated the voice agent on a timer, taking the working paths down with it. Guard lives inside the module rather than at the call sites: there are seven of them (noteMeetingMode x4, noteToolCall, noteToolSettled, observeTick) and a call site added later would not inherit a wrapper. flush() is guarded against a SYNC throw too, since the shutdown path awaits it. Control, same tests, guard bypassed vs present: guard bypassed FAIL a throwing reducer never escapes the tick FAIL a throwing flush never escapes shutdown guard present both ok, 23/23 checks pass Raised by @sonichi. * test(voice): the containment ledger was constructed with the wrong signature WatchdogLedger takes one options object ({path, meta, onError?}); my containment block passed two positional args, so o.path and o.meta were both undefined, mkdirSync(dirname(undefined)) threw into the bare catch, and every append failed into an explicitly-noop onError. The object constructed fine and wrote nowhere. Eleven sibling sites in the same file use the object form; this was the lone outlier. It does not weaken what the block asserts today — guard() only calls this.log(), never the ledger, and the assertion is escaped === null. The reason to fix it is that it is a loaded trap: the moment someone extends this block to assert a degraded observation was RECORDED, that assertion passes against a ledger that wrote nothing, with the noop onError eating the evidence. Not caught by tsc because tsconfig is include:["src"], so no test file is in typecheck scope at all — a wrong-signature call in tests/ is structurally undetectable here. That is a repo-level gap, not this PR's. Found by @liususan091219. * feat(voice): ACTIVE-silence recovery coordinator — armed-mode driver The impure driver around PR 1's pure reducer, per the design doc's Phase 1: executes restart effects as injection-free held recoverUpstream calls with reducer<->dial attempt-epoch correlation via the lifecycle stream; owns the cooldown/backoff retry timer (re-validated at delivery); latches terminal into the durable voice-stalled push with attach-time resend on a freshly minted clientEpoch; serves the human retry wire (schema-faithful parse, {voiceSessionId,requestId} dedup replaying the original ack, accepted/stale/not-terminal dispositions, fatal-backoff parking); clears terminal only on client-confirmed delivery or an accepted retry. Structural session surface — compiles against any bodhi pin; recoverySurfaceSupported() is the runtime gate. 17 tests drive it end-to-end against a scripted fake session with a virtual clock and manual timers (the design's integration fake). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(voice): arm the coordinator in voice-agent behind VOICE_ACTIVE_SILENCE_MODE Armed only on explicit VOICE_ACTIVE_SILENCE_MODE=armed AND the full bodhi recovery surface (falls back to shadow with a loud line); VOICE_ACTIVE_SILENCE_TICKS=0 still disables. The Phase 0a shadow keeps running unchanged in every mode. Wiring: client command/attach/detach hooks and the connection-lifecycle stream (bodhi #34/#36), tool and meeting-mode taps beside the shadow's, fatal-backoff set/clear, a per-epoch delivered-audio baseline (client-confirmed playback first, server egress fallback) feeding functional-recovery clears, and the ordinary CLOSED guard gated while terminal — the design's no-uncounted- bypass rule. bodhi pin -> yixuan-ag2#b323779 (PR #36 head; interim — repin to the sonichi merge SHA when #35/#36 land). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): coordinator hardening — nine findings from the Codex round - capability gate requires version 1 + every normative capability - parked human retry: NO premature accepted ack — the ack waits for the authorization that mints the real epoch, and the deferred dial keeps its human-retry provenance; duplicates of the parked request stay silent until that ack exists - accepted acks are never evicted from the dedup cache (duplicates never redial twice has no expiry); the cap bounds only non-accepted entries - stop() is a terminal latch: late activated rejections, lifecycle events, timers and commands are inert after shutdown - model progress (turn.start) now feeds the reducer's silence anchor, generation-fenced by the event's own transport generation; pending tools are keyed by {generation,id} so a stale-generation completion can neither settle a reused current id nor advance the anchor - the delivered-audio baseline moved into the coordinator: a fresh epoch's reset-from-zero counters that are already positive count as delivery (a response landing before its epoch's first tick was lost) - the reducer clock domain is monotonic (performance.now) in the live wiring — wall-clock jumps can no longer strand waiting-retry or bypass cooldowns; snapshot speech timestamps translate by age; wire frames and ledger rows keep wall time - the legacy CLOSED guard stands down whenever the coordinator owns the episode (any non-idle phase or a recovered origin), and its cast-call fake attach no longer mints a coordinator client epoch - terminal reattach: bodhi's suppressClientAutoActions gates greeting / context replay / the CLOSED auto-reconnect (bodhi #36 amendment); pin -> yixuan-ag2#bce828f Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): second independent round — four blockers + canary telemetry 1. bodhi's attach auto-actions now gate on ownsRecovery, not just terminal: a reattach during waiting-retry started an uncounted CLOSED auto-redial racing the coordinator's own timer (the synthetic hold already gates injections; this gates the dial) 2. fatal backoff crossed clock domains — a wall-time deadline entered the monotonic reducer and never expired (with 1 fixed, the session would stay dead forever); the reducer now receives its own domain, the wall deadline stays for agent-state 3. server egress without heartbeat confirmation can no longer clear terminal: sent is not delivered — egress advancement now counts only as model activity (anchor), and only client-confirmed playback (chunksEnded) dispatches userVisibleResponse; the test that pinned the old fallback as intended now pins the opposite 4. tool settle resolves by STORED epoch (oldest first) instead of prefer-current, so a late old-generation completion can neither settle a reused current id nor advance the current anchor; every admitted successor generation prunes stranded entries, so they cannot veto recovery forever (the old test's observable was vacuous — toolOutcome produces no ledger row; anchors are asserted now) Canary telemetry majors: incumbent-closed outcome rows (closed vs forced), first-upstream-progress per restart, first-user-visible- response on the clearing delivery — the Phase 1 gates are computable from the recovery ledger now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: repin bodhi to the sonichi merge SHA c726e1d (#35+#36 landed) The interim fork pin is gone. The merged main additionally hardened the surface beyond the reviewed branch heads: STT capture-generation fencing (a pre-recovery external transcript can no longer cross the boundary as fresh evidence) and turn.start now carries attemptEpoch alongside transportGeneration. Both additive; coordinator suite and full suite green against the merged build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(voice): state the consumers-own-structural-gating contract on MatrixFacts Review ask from @qingyun-wu on #3130: the safety of the narrowed `factsAvailable` rests on a contract that until now lived implicitly in the one existing consumer — voice-active-silence-watchdog's tick branch requires `ev.state === 'ACTIVE' && s.clientAttached` before the facts can matter. `factsAvailable` answers "did the data exist this tick?", not "is the session fit to act?". A second consumer reading it as safe-to-act would fire without the structural gate. Writing the contract on the family doc is the cheapest place to stop that, since it is where the field's meaning is defined. Comment-only: no behavior change, no type change. * fix(voice): a stopped recovery coordinator owns nothing ownsRecovery was `phase !== 'idle' || origin === 'active-silence'` and never consulted the stop() latch, so stopping mid-episode left it true. voice-agent stands the legacy F5 redial down on exactly that value, so a coordinator that can no longer dial would also be suppressing the path that could. Latent today: stop() has one caller, shutdown(), which ends in process.exit(0), so nothing outlives it. The safety therefore rests on "stop() implies the process is exiting" -- true, but stated nowhere and not enforced. A future caller disarming mid-session or restarting would get no dial from either side. Reported by sonichi on 0eea97b. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BSd8tgpzEMbH2nURksZwG --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bassilkhilo-ag2
added a commit
that referenced
this pull request
Sep 23, 2026
…back; every read is a snapshot user feedback, 2026-09-20: "When I ask Sutando to edit a Google Doc, the document sometimes gets unexpectedly cleared, or unrelated content is inserted or rewritten." (#34 on the tracker, the one data-loss item on the list.) Root cause: the Station's Google Docs connector has GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN, whose contract is "replaces the entire content of an existing document". It is the obvious pick for "update the doc", so a model that has the document in a partial memory rewrites all of it, and the parts it did not remember are gone. Nothing in the engine named that action, warned about it, or kept a copy. Partial-edit actions (INSERT_TEXT_ACTION, REPLACE_ALL_TEXT, INSERT_TEXT_IN_TABLE_CELL) exist and were never preferred. hooks/gdocs-write-guard.py, one script on two events, registered by build-core-settings.mjs (new arg 5) on the composio_exec tool for every core session, the way gmail-write-guard.py is: - PreToolUse: a body-replacing action (UPDATE_DOCUMENT_MARKDOWN, UPDATE_EXISTING_DOCUMENT, REPLACE_DOCUMENT, DELETE_CONTENT_RANGE) on toolkit googledocs is denied unless a snapshot of THAT document younger than 15 min (SUTANDO_GDOCS_BACKUP_MAX_AGE_S) exists. The reason names the read to do first and the partial-edit actions to prefer; an intentional full rewrite is read-then-replace, and SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE=1 lifts the guard. - PostToolUse: a read (GET_DOCUMENT_PLAINTEXT / GET_DOCUMENT_BY_ID) is written to <workspace>/data/gdocs-backups/<doc id>/<epoch>.md (atomic, newest 20), so a wrong rewrite can be restored from the last thing the owner had. A document id may arrive as a bare id or a docs.google.com URL; the snapshot is keyed by the id either way, and one document's read never vouches for another. Both halves ride one matcher (mcp__.*__composio_exec) on both events so they can never be registered apart — a deny with no snapshot writer would be a guard the model could never satisfy. Every other tool, toolkit and action is a no-op; fail-open on any hook error, as with the other guards. docs/built-in-tools.md (connected apps) and skills/connect-apps/SKILL.md (rules) now say: read first, edit in place, the full replace only for a rewrite the owner asked for. hooks/README.md documents the hook. Tests: tests/gdocs-write-guard.test.py (deny without a read; allow after a snapshot; a stale snapshot does not count and the window is configurable; URL ids; the twenty-newest cap; partial edits, reads, other toolkits and tools pass; escape hatch; unnamed document denied; garbage fails open; the launcher passes the hook), build-core-settings.test.ts (both events registered on composio_exec, other guards kept; the four-arg shape untouched). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
bassilkhilo-ag2
added a commit
that referenced
this pull request
Sep 23, 2026
…back; every read is a snapshot user feedback, 2026-09-20: "When I ask Sutando to edit a Google Doc, the document sometimes gets unexpectedly cleared, or unrelated content is inserted or rewritten." (#34 on the tracker, the one data-loss item on the list.) Root cause: the Station's Google Docs connector has GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN, whose contract is "replaces the entire content of an existing document". It is the obvious pick for "update the doc", so a model that has the document in a partial memory rewrites all of it, and the parts it did not remember are gone. Nothing in the engine named that action, warned about it, or kept a copy. Partial-edit actions (INSERT_TEXT_ACTION, REPLACE_ALL_TEXT, INSERT_TEXT_IN_TABLE_CELL) exist and were never preferred. hooks/gdocs-write-guard.py, one script on two events, registered by build-core-settings.mjs (new arg 5) on the composio_exec tool for every core session, the way gmail-write-guard.py is: - PreToolUse: a body-replacing action (UPDATE_DOCUMENT_MARKDOWN, UPDATE_EXISTING_DOCUMENT, REPLACE_DOCUMENT, DELETE_CONTENT_RANGE) on toolkit googledocs is denied unless a snapshot of THAT document younger than 15 min (SUTANDO_GDOCS_BACKUP_MAX_AGE_S) exists. The reason names the read to do first and the partial-edit actions to prefer; an intentional full rewrite is read-then-replace, and SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE=1 lifts the guard. - PostToolUse: a read (GET_DOCUMENT_PLAINTEXT / GET_DOCUMENT_BY_ID) is written to <workspace>/data/gdocs-backups/<doc id>/<epoch>.md (atomic, newest 20), so a wrong rewrite can be restored from the last thing the owner had. A document id may arrive as a bare id or a docs.google.com URL; the snapshot is keyed by the id either way, and one document's read never vouches for another. Both halves ride one matcher (mcp__.*__composio_exec) on both events so they can never be registered apart — a deny with no snapshot writer would be a guard the model could never satisfy. Every other tool, toolkit and action is a no-op; fail-open on any hook error, as with the other guards. docs/built-in-tools.md (connected apps) and skills/connect-apps/SKILL.md (rules) now say: read first, edit in place, the full replace only for a rewrite the owner asked for. hooks/README.md documents the hook. Tests: tests/gdocs-write-guard.test.py (deny without a read; allow after a snapshot; a stale snapshot does not count and the window is configurable; URL ids; the twenty-newest cap; partial edits, reads, other toolkits and tools pass; escape hatch; unnamed document denied; garbage fails open; the launcher passes the hook), build-core-settings.test.ts (both events registered on composio_exec, other guards kept; the four-arg shape untouched). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
bassilkhilo-ag2
added a commit
that referenced
this pull request
Sep 26, 2026
…back; every read is a snapshot (#4663) * feat(hooks): a whole-document Google Docs replace needs a fresh read-back; every read is a snapshot user feedback, 2026-09-20: "When I ask Sutando to edit a Google Doc, the document sometimes gets unexpectedly cleared, or unrelated content is inserted or rewritten." (#34 on the tracker, the one data-loss item on the list.) Root cause: the Station's Google Docs connector has GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN, whose contract is "replaces the entire content of an existing document". It is the obvious pick for "update the doc", so a model that has the document in a partial memory rewrites all of it, and the parts it did not remember are gone. Nothing in the engine named that action, warned about it, or kept a copy. Partial-edit actions (INSERT_TEXT_ACTION, REPLACE_ALL_TEXT, INSERT_TEXT_IN_TABLE_CELL) exist and were never preferred. hooks/gdocs-write-guard.py, one script on two events, registered by build-core-settings.mjs (new arg 5) on the composio_exec tool for every core session, the way gmail-write-guard.py is: - PreToolUse: a body-replacing action (UPDATE_DOCUMENT_MARKDOWN, UPDATE_EXISTING_DOCUMENT, REPLACE_DOCUMENT, DELETE_CONTENT_RANGE) on toolkit googledocs is denied unless a snapshot of THAT document younger than 15 min (SUTANDO_GDOCS_BACKUP_MAX_AGE_S) exists. The reason names the read to do first and the partial-edit actions to prefer; an intentional full rewrite is read-then-replace, and SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE=1 lifts the guard. - PostToolUse: a read (GET_DOCUMENT_PLAINTEXT / GET_DOCUMENT_BY_ID) is written to <workspace>/data/gdocs-backups/<doc id>/<epoch>.md (atomic, newest 20), so a wrong rewrite can be restored from the last thing the owner had. A document id may arrive as a bare id or a docs.google.com URL; the snapshot is keyed by the id either way, and one document's read never vouches for another. Both halves ride one matcher (mcp__.*__composio_exec) on both events so they can never be registered apart — a deny with no snapshot writer would be a guard the model could never satisfy. Every other tool, toolkit and action is a no-op; fail-open on any hook error, as with the other guards. docs/built-in-tools.md (connected apps) and skills/connect-apps/SKILL.md (rules) now say: read first, edit in place, the full replace only for a rewrite the owner asked for. hooks/README.md documents the hook. Tests: tests/gdocs-write-guard.test.py (deny without a read; allow after a snapshot; a stale snapshot does not count and the window is configurable; URL ids; the twenty-newest cap; partial edits, reads, other toolkits and tools pass; escape hatch; unnamed document denied; garbage fails open; the launcher passes the hook), build-core-settings.test.ts (both events registered on composio_exec, other guards kept; the four-arg shape untouched). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(hooks): gdocs-write-guard — a failed read is not a snapshot; repo root configured, workspace via the resolver Review findings on the guard (user feedback: a Google Doc "gets unexpectedly cleared" when the model picks the whole-document replace action): 1. A failed read still vouched for a whole-document replace. Reproduced by both reviewers: a PostToolUse for GET_DOCUMENT_PLAINTEXT whose tool_response was {"successful": false, "error": "403 ..."} wrote a snapshot whose content was the error JSON, and the next UPDATE_DOCUMENT_MARKDOWN on that document was allowed. response_text() and handle() never looked at successful/error, so any non-empty response counted as a backup — on an ordinary transient 403 or timeout the guard disarmed itself and the unbacked replace went through, which is the exact failure the hook exists to close. Now a read is a snapshot only when it succeeded and carried text: snapshot_text() refuses an envelope with successful/success False, an error / is_error / isError set (at the MCP wrapper level or inside the JSON the wrapper carries), an empty data stanza, and a blank body. When the envelope carries the document text (plain_text / text / markdown at the top level or under data) the snapshot is that text rather than the envelope, so a restore is the document, not a JSON dump around it. 2. `refuse new direct workspace-path resolution` was red: the hook read SUTANDO_WORKSPACE_DIR and walked up from __file__ to put src/ on sys.path. The repo root is now CONFIGURED, never discovered — the same rule result-file-marker-guard.py follows: build-core-settings.mjs registers the hook as `python3 <hook> --repo <checkout>` (the checkout is the hook file's grandparent, embedded at registration time), $SUTANDO_REPO_ROOT is the fallback, and the workspace comes from workspace_default.resolve_workspace. Without a root there is no workspace, so no snapshot can be recorded and a whole replace stays denied, with a stderr line naming the missing configuration. 3. The two hook settings (SUTANDO_GDOCS_BACKUP_MAX_AGE_S, and the operator override SUTANDO_ALLOW_GDOCS_WHOLE_REPLACE) are read through sutando_config — the `env` stanza of sutando.config.local.json, with the environment as the documented fallback — instead of ad-hoc os.environ reads. The override is env-first (that is what an override is for); the max age is config-first. The deny reason now names the in-session way past the block (the read itself) and renders the window actually in force instead of the hard-coded "15 minutes" the reviewer saw with the override set to 60. 4. Diff coverage was 0%: the test only drove the hook as a subprocess. The suite now loads the module with importlib and calls handle() in-process with the workspace redirected through the real resolver (SUTANDO_TEST_MODE + SUTANDO_WORKSPACE), keeps one subprocess case that runs the script over stdin the way Claude Code does, and adds the failed-read cases (403 envelope, MCP is_error, success False, error with successful true, empty data, blank body, no content), every successful response shape, the configured-root rule (argv, argv=, env, none), a broken resolver/config (fail-safe, escape hatch still readable), main() over stdin, the window label, an emptied backup folder, and best-effort pruning. Changed-line coverage of hooks/gdocs-write-guard.py: 100% (was 0%). Also trimmed the dated incident narration out of the module docstring, the builder comment and the TS test comment (that context lives here and in the PR body), and hooks/README.md documents the configured root, the failed-read rule and where the settings live. Not changed: DELETE_CONTENT_RANGE stays classed as body-removing (a range can be the whole body; the read the deny asks for is the one that yields the indexes anyway), and build-core-settings.mjs still takes the hook as argv[6] — whichever of this PR and the other open builder change lands second renumbers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every loop pass now checks Discord channels for actionable messages.