switchroom 0.18.30 → 0.18.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -38,14 +38,13 @@
38
38
  *
39
39
  * The gateway-internal turn/poke effects (setTurnStarted,
40
40
  * clearTurnStarted, noteOutbound, firePoke) map to gateway-scope state
41
- * (`claudeBusyKeys`, the silence-poke ladder) that this decoupled module
41
+ * (the silence-poke ladder, turn markers) that this decoupled module
42
42
  * cannot reach directly, so they are dispatched through OPTIONAL
43
43
  * callbacks on the ctx. When a callback is absent the effect logs an
44
44
  * `unwired` trace rather than silently no-opping — the trace is the gate.
45
45
  *
46
- * Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
47
- * dispatcher execution and the gateway falls back to imperative-only.
48
- * Default is ON — this PR is the cutover.
46
+ * The `SWITCHROOM_DELIVERY_MACHINE_CUTOVER` kill switch was removed in
47
+ * #2996 P1 after the cutover baked the dispatcher always executes.
49
48
  */
50
49
 
51
50
  import type {
@@ -80,10 +79,10 @@ export interface DispatchCtx {
80
79
  */
81
80
  readonly onUserInboundDelivered?: (merged: InboundMessage) => void
82
81
  // ── Gateway-internal turn/poke effect callbacks ──────────────────
83
- // These effects map to gateway-scope state (`claudeBusyKeys`, the
84
- // silence-poke ladder) that this decoupled module cannot reach. The
85
- // gateway supplies them when it flips inbound routing through the
86
- // machine (PR3c). Absent → the effect logs an `unwired` trace.
82
+ // These effects map to gateway-scope state (the silence-poke ladder,
83
+ // turn markers) that this decoupled module cannot reach. The
84
+ // gateway supplies them at the live inbound call site. Absent → the
85
+ // effect logs an `unwired` trace.
87
86
  readonly onSetTurnStarted?: (key: ChatKey, at: number) => void
88
87
  /**
89
88
  * Optional: observe the outcome of a `deliverToBridge` effect. The
@@ -101,12 +100,6 @@ export interface DispatchCtx {
101
100
  readonly onFirePoke?: (key: ChatKey, level: 'soft' | 'firm' | 'fallback') => void
102
101
  }
103
102
 
104
- const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
105
-
106
- export function isDispatchEnabled(): boolean {
107
- return enabled
108
- }
109
-
110
103
  /**
111
104
  * Execute the effects returned by `transition()`. Pure imperative
112
105
  * driver: side-effects only, no machine state held here.
@@ -117,7 +110,6 @@ export function isDispatchEnabled(): boolean {
117
110
  * already up).
118
111
  */
119
112
  export function dispatchEffects(effects: readonly Effect[], ctx: DispatchCtx): void {
120
- if (!enabled) return
121
113
  for (const effect of effects) {
122
114
  dispatchOne(effect, ctx)
123
115
  }
@@ -1,32 +1,30 @@
1
1
  /**
2
- * InboundDeliveryStateMachine — SHADOW MODE wiring (Phase 2b PR 2).
2
+ * InboundDeliveryStateMachine — live machine state + event driver.
3
3
  *
4
- * Per RFC `reference/rfcs/inbound-delivery-state-machine.md` Phase 2b PR 2:
5
- * the state machine runs ALONGSIDE the existing imperative gateway
6
- * code, recording predicted effects to a structured trace. Behavior
7
- * is unchanged every existing code path still executes the actual
8
- * I/O. This module's job:
4
+ * Historically the "shadow mode" wiring (RFC
5
+ * `reference/rfcs/inbound-delivery-state-machine.md` Phase 2b PR 2): the
6
+ * machine ran ALONGSIDE the imperative gateway code, predicting effects
7
+ * to a structured trace. The #3012 cutover made this state AUTHORITATIVE
8
+ * for the turn-in-flight gate and the deliver-vs-buffer inbound routing,
9
+ * and #2996 P1 removed the kill switches (`SWITCHROOM_DELIVERY_MACHINE_SHADOW`,
10
+ * `SWITCHROOM_DELIVERY_MACHINE_CUTOVER`) — the machine is now the sole
11
+ * delivery authority. (The `shadowEmit` name is kept for its ~15 gateway
12
+ * call sites; the `gw-trace shadow` trace prefix is kept for log-parser
13
+ * continuity.) This module's job:
9
14
  *
10
15
  * 1. Own the module-scope machine state.
11
- * 2. Expose `shadowEmit(event)` that runs `transition()` + logs the
12
- * predicted effects via `gw-trace shadow ...` stderr lines.
13
- * 3. Provide test hooks for resetting + inspecting state.
16
+ * 2. Expose `shadowEmit(event)` that runs `transition()`, advances the
17
+ * state, logs via `gw-trace shadow ...` stderr lines, and RETURNS
18
+ * the effects for the gateway to execute (`dispatchEffects`).
19
+ * 3. Expose `isMachineInTurn()` — the authoritative gate read.
20
+ * 4. Provide test hooks for resetting + inspecting state.
14
21
  *
15
- * After PR 2 bakes on the fleet for 24+ hours, PR 3 will wire the
16
- * effects to drive ACTUAL behavior (the cutover), at which point the
17
- * imperative paths get deleted in PR 4.
18
- *
19
- * Telemetry approach: each `shadowEmit` writes a single stderr line
20
- * with the event kind + emitted effect kinds. Operators can then:
22
+ * Telemetry: each `shadowEmit` writes a single stderr line with the
23
+ * event kind + emitted effect kinds. Operators can then:
21
24
  *
22
25
  * docker exec switchroom-<agent> sh -lc 'grep "gw-trace shadow" \
23
26
  * /var/log/switchroom/gateway-supervisor.log | tail -50'
24
27
  *
25
- * to see what the state machine PREDICTS the gateway should do for
26
- * each event. Comparing against the actual log lines (`pending-inbound-
27
- * buffer: agent=X buffered ...` etc.) is the validation that the
28
- * machine is bit-identical with reality.
29
- *
30
28
  * Trace verbosity (#3025): the TTL `tick` event fires every ~30s and on a
31
29
  * healthy idle agent emits a zero-signal `event=tick effects=[]
32
30
  * global=bridge_alive_idle` line. Those no-op ticks are SUPPRESSED by
@@ -36,10 +34,6 @@
36
34
  * in `../shared/gw-trace-gate.ts` and also governs the `tg-post` poll
37
35
  * heartbeats. Real events, turn-expiring ticks, and in-turn ticks always
38
36
  * log regardless of the flag.
39
- *
40
- * Toggle off via `SWITCHROOM_DELIVERY_MACHINE_SHADOW=0` — a kill
41
- * switch for the case where the shadow emits prove problematic
42
- * (e.g., trace volume too high). The default is ON.
43
37
  */
44
38
 
45
39
  import {
@@ -52,58 +46,36 @@ import {
52
46
  import { shouldEmitShadowTrace } from '../shared/gw-trace-gate.js'
53
47
 
54
48
  let state: State = initialState()
55
- const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== '0'
56
-
57
- // Phase 2b PR 3 — STAGED CUTOVER. When enabled, the gateway's
58
- // "is a turn in flight?" gate reads this machine's global state
59
- // instead of the PR3b `claudeBusyKeys` set. The machine tracks ONE
60
- // `activeTurn` (single bridge) plus TTL `tick` expiry, so — unlike a
61
- // per-delivery key set — it cannot accumulate orphan keys and wedge
62
- // the gate "in-flight forever" (the gymbro/clerk 5-min dangle of
63
- // 2026-05-28). Scope is the turn-in-flight GATE only; the poke ladder
64
- // and perm-verdict effects stay imperative for a follow-up PR.
65
- //
66
- // Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` reverts every
67
- // gate to the legacy claudeBusyKeys read (zero behaviour change).
68
- // Requires shadow mode ON — with shadow off the machine state is
69
- // frozen and must NOT be read as authoritative.
70
- const cutoverEnabled = enabled && process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
71
-
72
- /**
73
- * True when the kill-switch leaves the delivery machine authoritative
74
- * for the turn-in-flight gate. Gateway gate sites branch on this.
75
- */
76
- export function isDeliveryCutoverEnabled(): boolean {
77
- return cutoverEnabled
78
- }
79
49
 
80
50
  /**
81
51
  * Authoritative "is a turn currently in flight?" read for the gate.
82
- * Maps the machine's global state to the boolean the legacy
83
- * `claudeBusyKeys.size > 0` gate produced. `bridge_dead` and
84
- * `bridge_alive_idle` are both "not in flight".
52
+ * The machine tracks ONE `activeTurn` (single bridge) plus TTL `tick`
53
+ * expiry, so unlike the deleted per-delivery `claudeBusyKeys` set —
54
+ * it cannot accumulate orphan keys and wedge the gate "in-flight
55
+ * forever" (the gymbro/clerk 5-min dangle of 2026-05-28). `bridge_dead`
56
+ * and `bridge_alive_idle` are both "not in flight".
85
57
  */
86
58
  export function isMachineInTurn(): boolean {
87
59
  return state.global.kind === 'bridge_alive_in_turn'
88
60
  }
89
61
 
90
62
  /**
91
- * Run an event through the state machine in shadow mode. The machine
92
- * state advances, the predicted effects are LOGGED, but no I/O fires.
93
- *
94
- * Returns the effects for callers that want to inspect them inline
95
- * (e.g., the eventual PR 3 cutover will replace the return-and-ignore
96
- * pattern here with return-and-execute).
63
+ * Run an event through the state machine: the machine state advances,
64
+ * the effects are logged and RETURNED for the caller to execute
65
+ * (`dispatchEffects` at the live inbound/bridgeUp call sites; the
66
+ * lifecycle emits turnStart/turnEnd/modelOutbound/tick advance state
67
+ * only and ignore the return, their real-world work still being
68
+ * imperative per the RFC's open PR3b checklist).
97
69
  */
98
70
  export function shadowEmit(event: Event): readonly Effect[] {
99
- if (!enabled) return []
100
- // Shadow mode MUST NEVER break the gateway. The state machine is
101
- // pure (no I/O, no async) and property-tested over 5000 schedules,
71
+ // The machine driver MUST NEVER break the gateway. The state machine
72
+ // is pure (no I/O, no async) and property-tested over 5000 schedules,
102
73
  // so transition() won't throw on well-formed input. But event
103
74
  // construction at the call site could mis-shape inputs; the
104
- // try/catch is belt-and-braces so a shadow bug never wedges a real
105
- // turn. The catch logs and bails — the imperative gateway code
106
- // below the emit point still runs.
75
+ // try/catch is belt-and-braces so a driver bug never wedges a real
76
+ // turn. The catch logs and bails — the gateway code below the emit
77
+ // point still runs (falling back to the buffer-everything semantics
78
+ // of an un-advanced machine).
107
79
  try {
108
80
  const result = transition(state, event)
109
81
  state = result.state
@@ -164,8 +136,3 @@ export function __shadowResetForTests(): void {
164
136
  export function __shadowGetStateForTests(): State {
165
137
  return state
166
138
  }
167
-
168
- /** Test hook: check if shadow mode is enabled (mirrors the env-var). */
169
- export function __shadowEnabledForTests(): boolean {
170
- return enabled
171
- }
@@ -26,6 +26,28 @@
26
26
 
27
27
  import type { InboundMessage } from './ipc-protocol.js'
28
28
 
29
+ /**
30
+ * #3282 — the durable per-chunk snapshot of a PARTIALLY delivered backstop
31
+ * answer, attached to the obligation so the represent can resume the exact
32
+ * captured answer's non-`landed-confirmed` tail (byte-identical) instead of
33
+ * regenerating it (which re-posts chunks that already landed). Rides the
34
+ * obligation ledger's atomic snapshot, so it survives a gateway/container
35
+ * restart. See captured-answer-resume.ts for the resume mechanism.
36
+ */
37
+ export interface CapturedDeliverySnapshot {
38
+ /** The byte-identical captured answer, split into chunks (the SAME split the
39
+ * original backstop delivery used). Re-delivered verbatim on resume. */
40
+ readonly chunks: string[]
41
+ /** Per-LANDED-chunk state: the landed message id(s) + whether a read-back
42
+ * confirmed the message exists. A chunk absent here never landed → the resume
43
+ * treats it as `unsent` and re-sends it. */
44
+ readonly chunkStates: Array<{
45
+ readonly index: number
46
+ readonly messageIds: number[]
47
+ readonly confirmed: boolean
48
+ }>
49
+ }
50
+
29
51
  export interface Obligation {
30
52
  /** deriveTurnId(chat, thread, messageId) — the stable identity. */
31
53
  readonly originTurnId: string
@@ -61,6 +83,12 @@ export interface Obligation {
61
83
  * re-present/escalate when the sweep fires < 5s later. Durable (part of the
62
84
  * snapshot) so the grace window survives a restart. */
63
85
  lastRepresentedAt?: number
86
+ /** #3282 — durable captured-answer snapshot for a PARTIALLY delivered backstop
87
+ * answer. Present ⇒ the represent branch drives a byte-identical captured-answer
88
+ * RESUME of the non-confirmed tail (never a regeneration); absent ⇒ the genuine
89
+ * "model wrote nothing" represent falls through to fresh generation. Dropped
90
+ * with the obligation on close/escalate (bounded lifetime, design A6). */
91
+ capturedDelivery?: CapturedDeliverySnapshot
64
92
  }
65
93
 
66
94
  /** What the gateway should do for the oldest open obligation at an idle boundary. */
@@ -316,6 +344,20 @@ export class ObligationLedger {
316
344
  return null
317
345
  }
318
346
 
347
+ /**
348
+ * #3282 — attach (or refresh) the durable captured-answer snapshot for a
349
+ * PARTIALLY delivered backstop answer. No-op if the obligation isn't open (a
350
+ * fully delivered turn closes its obligation, so there is nothing to resume).
351
+ * Persists, so the snapshot survives a restart and the represent resumes the
352
+ * missing tail instead of regenerating the whole answer.
353
+ */
354
+ noteCapturedDelivery(originTurnId: string, snapshot: CapturedDeliverySnapshot): void {
355
+ const o = this.open.get(originTurnId)
356
+ if (o === undefined || snapshot.chunks.length === 0) return
357
+ o.capturedDelivery = snapshot
358
+ this.persist()
359
+ }
360
+
319
361
  /** Record that an obligation was just re-presented (bumps representCount, stamps
320
362
  * lastRepresentedAt for the per-represent grace window). */
321
363
  markRepresented(originTurnId: string, now = Date.now()): number {
@@ -55,6 +55,42 @@ function isObligationRow(x: unknown): x is Obligation {
55
55
  )
56
56
  }
57
57
 
58
+ /**
59
+ * #3282 — true iff the optional captured-answer snapshot is structurally sound.
60
+ * A malformed blob (partial write, forward-incompatible shape) must NOT crash the
61
+ * resume: `sanitizeCapturedDelivery` strips it, so the obligation degrades to a
62
+ * fresh-generation represent (today's behaviour) rather than throwing.
63
+ */
64
+ function isValidCapturedDelivery(x: unknown): boolean {
65
+ if (x == null || typeof x !== 'object') return false
66
+ const c = x as Record<string, unknown>
67
+ // Empty chunks ⇒ nothing to resume: treat as malformed so it is STRIPPED and
68
+ // the obligation degrades to a (bounded) fresh-generation represent rather than
69
+ // sitting non-null-but-empty and no-op'ing the sweep forever.
70
+ if (!Array.isArray(c.chunks) || c.chunks.length === 0 || !c.chunks.every((t) => typeof t === 'string')) return false
71
+ if (!Array.isArray(c.chunkStates)) return false
72
+ return c.chunkStates.every((s) => {
73
+ if (s == null || typeof s !== 'object') return false
74
+ const st = s as Record<string, unknown>
75
+ return (
76
+ typeof st.index === 'number' &&
77
+ typeof st.confirmed === 'boolean' &&
78
+ Array.isArray(st.messageIds) &&
79
+ st.messageIds.every((m) => typeof m === 'number')
80
+ )
81
+ })
82
+ }
83
+
84
+ /** Drop a malformed `capturedDelivery` in place so a corrupt snapshot degrades to
85
+ * fresh-generation represent instead of crashing the resume (fail-open). */
86
+ function sanitizeCapturedDelivery(o: Obligation): Obligation {
87
+ if (o.capturedDelivery != null && !isValidCapturedDelivery(o.capturedDelivery)) {
88
+ const { capturedDelivery: _drop, ...rest } = o
89
+ return rest
90
+ }
91
+ return o
92
+ }
93
+
58
94
  /**
59
95
  * Load the persisted open set. Returns [] on a missing, unreadable, or
60
96
  * malformed file (fail-open to empty: a corrupt snapshot must never crash boot;
@@ -78,7 +114,7 @@ export function loadObligations(path: string, fs: ObligationStoreFsSeam): Obliga
78
114
  if (parsed == null || typeof parsed !== 'object') return []
79
115
  const env = parsed as Record<string, unknown>
80
116
  if (env.v !== 1 || !Array.isArray(env.obligations)) return []
81
- return env.obligations.filter(isObligationRow)
117
+ return env.obligations.filter(isObligationRow).map(sanitizeCapturedDelivery)
82
118
  }
83
119
 
84
120
  /**