Skip to content

Add Discord monitoring to proactive loop - #34

Merged
sonichi merged 1 commit into
mainfrom
add-discord-monitoring
Mar 29, 2026
Merged

sonichi merged 1 commit into
mainfrom
add-discord-monitoring

Conversation

@sonichi

@sonichi sonichi commented Mar 29, 2026

Copy link
Copy Markdown
Owner

Every loop pass now checks Discord channels for actionable messages.

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>
@sonichi
sonichi merged commit e784904 into main Mar 29, 2026
@sonichi
sonichi deleted the add-discord-monitoring branch March 29, 2026 12:55
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
)

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

1 participant