switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -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
  /**
@@ -0,0 +1,333 @@
1
+ /**
2
+ * obligation-wiring.ts — obligation open/close/cancel + the idle sweep
3
+ * (#2996 P8 PR-C2).
4
+ *
5
+ * Extracted VERBATIM from gateway.ts: `closeObligationOnSubstantiveReply`,
6
+ * `openObligationFromInbound`, `cancelInterruptedObligation` and
7
+ * `obligationSweep` (idle-only re-present / escalate driver). STATE stays in
8
+ * gateway.ts — the `ObligationLedger` instance + durable store,
9
+ * `pendingCrossTurnGate`, `obligationEscalateInFlight`, the grace consts, and
10
+ * the escalation NUDGE SEND (the one bot-api touch, kept behind the gateway's
11
+ * retry-policy allowlist and injected as `sendEscalationNudge`). The sweep
12
+ * `setInterval` stays gateway-owned (P0c: modules export the tick body).
13
+ *
14
+ * Never-storm invariants preserved by construction: grace consts injected
15
+ * verbatim; `obligationEscalateInFlight` crosses as the SAME shared Set
16
+ * reference; the represent/escalate guards are the same imported pure modules.
17
+ */
18
+ import type { CurrentTurn, ObligationWiringDeps } from './gateway.js'
19
+ import type { InboundMessage } from './ipc-protocol.js'
20
+ import { shouldTrackDelivery } from './inbound-delivery-confirm.js'
21
+ import { deriveTurnId } from './derive-turn-id.js'
22
+ import { buildObligationRepresentInbound, type Obligation } from './obligation-ledger.js'
23
+ import { driveEscalation } from './escalation-drive.js'
24
+ import { shouldSuppressRepresent } from './represent-guard.js'
25
+ import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
26
+
27
+ export function createObligationWiring(deps: ObligationWiringDeps) {
28
+ const {
29
+ OBLIGATION_LEDGER_ENABLED,
30
+ HISTORY_ENABLED,
31
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS,
32
+ OBLIGATION_ESCALATE_GRACE_MS,
33
+ OBLIGATION_REPRESENT_GRACE_MS,
34
+ OBLIGATION_REPRESENT_MAX,
35
+ OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
36
+ OBLIGATION_ESCALATE_MAX,
37
+ OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
38
+ obligationLedger,
39
+ obligationEscalateInFlight,
40
+ pendingCrossTurnGate,
41
+ pendingInboundBuffer,
42
+ capturedResume,
43
+ getCurrentTurn,
44
+ turnInFlightForGate,
45
+ agentHasInFlightBackgroundWork,
46
+ hasOutboundDeliveredSince,
47
+ findTurnByOriginId,
48
+ bridgeAlive,
49
+ sendEscalationNudge,
50
+ } = deps
51
+
52
+ /**
53
+ * PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
54
+ * (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
55
+ * obligation discharged is the one for the SAME origin the answer routes to
56
+ * (origin_turn_id the model echoed, else the routed origin the gateway resolved,
57
+ * else the live turn). So 713's reply closes 713's obligation even after
58
+ * currentTurn flipped to 715, and 715 stays open until ITS own substantive
59
+ * answer. Answers to re-presented obligations (via=quoted, no model echo) close
60
+ * via the gateway-resolved routedOriginTurn. An ack does NOT close (so
61
+ * ack-then-ghost is re-presented, not re-dropped). The live-turn fallback fires
62
+ * only for the live turn's OWN obligation (it was the turn delivering this
63
+ * reply), preserving the 713/715 invariant. No-op unless the flag is on.
64
+ *
65
+ * @param routedOriginTurn — the origin the reply router already resolved
66
+ * (echoedTurn ?? quotedTurn); pass whenever the TURN_ORIGIN_ROUTING path ran.
67
+ * Skipped when null/undefined (pre-routing paths, or DM with no quote).
68
+ */
69
+ function closeObligationOnSubstantiveReply(
70
+ args: Record<string, unknown>,
71
+ liveTurn: CurrentTurn | null | undefined,
72
+ routedOriginTurn?: CurrentTurn | null,
73
+ ): void {
74
+ if (!OBLIGATION_LEDGER_ENABLED) return
75
+ const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
76
+ // routedOriginTurn is the gateway-resolved origin (echoedTurn ?? quotedTurn).
77
+ // Only pass it as routedOriginId when it DIFFERS from the echoed turn (if
78
+ // echoed is present, resolveCloseTarget's first branch already handles it),
79
+ // and only when it is NOT the live turn (live-turn is the fallback, not the
80
+ // routed origin — passing live turn here would bypass the live-turn fallback
81
+ // logic and still close correctly, but naming matters for the 713/715 case:
82
+ // the routed origin on a via=quoted reply IS the origin, not "live fallback").
83
+ const routedOriginId =
84
+ routedOriginTurn != null && echoed == null
85
+ ? routedOriginTurn.turnId
86
+ : null
87
+ const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
88
+ if (target != null) obligationLedger.close(target)
89
+ }
90
+
91
+ /**
92
+ * PR2 obligation-ledger OPEN. Track a fresh user inbound as an unanswered
93
+ * obligation the moment it is received — called BEFORE the buffer-until-idle /
94
+ * deliver split so a mid-turn cross-topic inbound (the 715 case: buffered while
95
+ * another turn runs) is tracked too. Drain paths re-deliver buffered inbounds
96
+ * via sendToAgent (NOT through handleInbound), so handleInbound is the ONLY
97
+ * point that sees every fresh inbound — opening here is mandatory for both
98
+ * branches. Same gate as delivery-tracking (real user turns only; synthetic /
99
+ * steering / `!` interrupt / empty excluded — they have no origin id / need no
100
+ * answer; deriveTurnId null-guards them). Idempotent → opening at buffer-time
101
+ * and any later delivery is safe. No-op unless the flag is on.
102
+ */
103
+ function openObligationFromInbound(
104
+ inboundMsg: InboundMessage,
105
+ gate: { isSteering: boolean; isInterrupt: boolean; effectiveText: string },
106
+ ): void {
107
+ if (!OBLIGATION_LEDGER_ENABLED) return
108
+ if (
109
+ !shouldTrackDelivery({
110
+ isSteering: gate.isSteering,
111
+ isInterrupt: gate.isInterrupt,
112
+ hasSource: inboundMsg.meta?.source != null,
113
+ effectiveText: gate.effectiveText,
114
+ })
115
+ ) {
116
+ return
117
+ }
118
+ const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId)
119
+ if (oid == null) return
120
+ obligationLedger.openIfAbsent({
121
+ originTurnId: oid,
122
+ chatId: inboundMsg.chatId,
123
+ threadId: inboundMsg.threadId,
124
+ messageId: inboundMsg.messageId,
125
+ text: inboundMsg.text ?? '',
126
+ openedAt: Date.now(),
127
+ })
128
+ }
129
+
130
+ /**
131
+ * An `!` interrupt SIGINT-kills the in-flight turn. That turn was handling a
132
+ * user message with an open obligation, and the killed turn does NOT reliably
133
+ * emit turn_end (so endCurrentTurnAtomic never closes it) — so without this the
134
+ * obligation survives and the idle sweep later re-presents/escalates "you have
135
+ * an earlier message you never answered" for a question the user EXPLICITLY
136
+ * cancelled. An interrupt is a deliberate redirect, so closing that obligation
137
+ * is the correct terminal (the user chose to interrupt; they can re-ask). Only
138
+ * the interrupted turn's OWN obligation is closed — queued siblings (other open
139
+ * obligations) are untouched. No-op when the flag is off, no turn is in flight,
140
+ * or the turn isn't a tracked obligation (synthetic / already closed).
141
+ */
142
+ function cancelInterruptedObligation(): void {
143
+ if (!OBLIGATION_LEDGER_ENABLED) return
144
+ const turn = getCurrentTurn()
145
+ if (turn == null) return
146
+ if (obligationLedger.close(turn.turnId)) {
147
+ process.stderr.write(
148
+ `telegram gateway: obligation cancelled by interrupt origin=${turn.turnId}\n`,
149
+ )
150
+ }
151
+ }
152
+
153
+
154
+ // Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
155
+ // log every tick across a multi-minute research window).
156
+ let lastBgWorkDeferLogMs = 0
157
+
158
+ function obligationSweep(): void {
159
+ if (!OBLIGATION_LEDGER_ENABLED) return
160
+ if (!obligationLedger.hasOpen()) return
161
+ if (turnInFlightForGate()) return // a turn is running — let it finish/answer
162
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
163
+ const now = Date.now()
164
+ // Background-work grace: while genuine autonomous sub-agent work is in flight
165
+ // (a running worker, or an orphaned foreground sub-agent — neither visible to
166
+ // the turn machine), an obligation younger than the ceiling is NOT re-presented
167
+ // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
168
+ // always eventually fires. =0 disables it.
169
+ const backgroundWorkActive =
170
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
171
+ // Grace window: skip an obligation whose handling turn ended < grace ago — its
172
+ // trailing slow/worker answer may still be landing (over-escalation fix).
173
+ // Per-represent grace: skip an obligation re-presented < grace ago — prevents
174
+ // the 5s sweep from immediately firing again before the re-present even lands.
175
+ const decision = obligationLedger.decideAtIdle(
176
+ OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0
177
+ ? {
178
+ now,
179
+ graceMs: OBLIGATION_ESCALATE_GRACE_MS,
180
+ backgroundWorkActive,
181
+ backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
182
+ representGraceMs: OBLIGATION_REPRESENT_GRACE_MS,
183
+ }
184
+ : undefined,
185
+ )
186
+ const o = decision.obligation
187
+ if (decision.action === 'none' || o == null) {
188
+ if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60_000) {
189
+ lastBgWorkDeferLogMs = now
190
+ process.stderr.write(
191
+ `telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
192
+ `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
193
+ )
194
+ }
195
+ return
196
+ }
197
+ if (decision.action === 'represent') {
198
+ // Fix #2472 — duplicate-represent guard. Before re-presenting AGAIN, check
199
+ // whether the agent has ALREADY delivered a substantive outbound reply to
200
+ // this chat SINCE the obligation was most recently re-presented. If so the
201
+ // obligation is satisfied-but-misdetected (the reply landed but its routing
202
+ // didn't resolve back to this origin, so the normal close path missed it) —
203
+ // close silently and do NOT re-fire, which is what produced the near-identical
204
+ // duplicate in #2472 (reply 10608 answered represent_count=1, yet
205
+ // represent_count=2 fired anyway → duplicate 10609).
206
+ //
207
+ // The cutoff is `lastRepresentedAt` (the time of the PREVIOUS represent), NOT
208
+ // `openedAt`. This is load-bearing: the genuine "agent wrote a plain-text
209
+ // answer and never called reply" case must still represent ONCE. On the first
210
+ // represent `lastRepresentedAt` is undefined, so this guard is a no-op and the
211
+ // single represent fires as before. Only the SECOND-and-later represent is
212
+ // gated — exactly where a reply that landed between fires must suppress the
213
+ // re-ask. Falls back to false (never suppresses) if history is unavailable.
214
+ if (
215
+ shouldSuppressRepresent(o, {
216
+ historyEnabled: HISTORY_ENABLED,
217
+ // Pass the represent-guard's OWN low threshold — a terse-but-real reply
218
+ // must suppress the duplicate (#2472/#2474), unlike the escalate branch
219
+ // below which keeps the 200-char default.
220
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
221
+ hasOutboundDeliveredSince(
222
+ chatId,
223
+ sinceMs,
224
+ threadId,
225
+ OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
226
+ ),
227
+ })
228
+ ) {
229
+ process.stderr.write(
230
+ `telegram gateway: obligation closed silently — reply delivered since last represent (no re-fire) origin=${o.originTurnId}\n`,
231
+ )
232
+ obligationLedger.close(o.originTurnId)
233
+ return
234
+ }
235
+ // #3282 — source-aware represent: a captured snapshot ⇒ resume the non-confirmed tail byte-identically; no snapshot ⇒ the fresh-generation represent below.
236
+ if (o.capturedDelivery != null) { capturedResume.dispatch(o); return }
237
+ // Re-present goes through the bridge → buffer. Only the represent path is
238
+ // gated on an empty buffer (let the existing drain run first, avoid
239
+ // double-presenting). Escalation below is NOT gated on the buffer — it is a
240
+ // direct Telegram send, independent of the bridge, so a represent stranded
241
+ // behind a dead bridge can never block the operator nudge.
242
+ if (pendingInboundBuffer.depth(agent) > 0) return
243
+ pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
244
+ // PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Arm the
245
+ // card-OPEN gate for the synthetic turn this represent inbound will spawn:
246
+ // carry the obligation's `openedAt` so that turn's first card-OPEN can ask
247
+ // "was a substantive answer already delivered since the obligation was
248
+ // raised?" and, if so, suppress the card (it would otherwise narrate beneath
249
+ // the answer the user already received). Keyed on the obligation's
250
+ // `originTurnId` — the SAME id the represent inbound carries
251
+ // (`buildObligationRepresentInbound` reuses `o.messageId`/`o.chatId`/
252
+ // `o.threadId`, so the enqueue-time `deriveTurnId` reconstructs exactly
253
+ // `o.originTurnId`). Keying on the turn id (not chat/thread) means ONLY the
254
+ // exact represent turn this gate was armed for can consume it; an unrelated
255
+ // later foreground turn on the same chat/thread has a different originTurnId
256
+ // → finds no entry → its card opens normally. This closes the residual
257
+ // cross-contamination window where a never-enqueued represent's stale gate
258
+ // could suppress an unrelated turn's card (the represent/duplicate-reply
259
+ // family). This does NOT gate the represent SEND — the represent guard above
260
+ // already owns suppressing an already-satisfied represent; this only governs
261
+ // the decorative card.
262
+ pendingCrossTurnGate.set(o.originTurnId, { sinceMs: o.openedAt })
263
+ const attempt = obligationLedger.markRepresented(o.originTurnId)
264
+ process.stderr.write(
265
+ `telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}\n`,
266
+ )
267
+ return
268
+ }
269
+ // escalate — re-present ladder exhausted. Before sending the user-visible
270
+ // apology, check whether the agent has ALREADY delivered an outbound reply
271
+ // to this chat since the obligation was opened. If yes, the obligation is
272
+ // stale (the agent did answer, just without closing the obligation via the
273
+ // normal close path) — close silently instead of alarming the user with a
274
+ // false "I may have missed this". This is Fix 4: escalate only on knowledge,
275
+ // not doubt. Fall back to false (safe: never suppresses) if history unavailable.
276
+ //
277
+ // #2788 Gap A — bridge-flap gate. The escalate branch DIRECT-SENDS (via
278
+ // bot.api.sendMessage below), bypassing the bridge. So while the bridge is
279
+ // down the represent branch is naturally stranded (bridge → buffer) but this
280
+ // branch would still fire a false "I may have missed this", even though the
281
+ // real reply is merely queued behind a transient outage. Defer: if the bridge
282
+ // is not alive, leave the obligation OPEN and re-drive on a later sweep once
283
+ // it recovers. Obligations survive bridge death (durable ledger, re-evaluated
284
+ // every sweep), so this deferral adds NO unbounded liveness dependency — the
285
+ // whole gateway already rests on the bridge eventually reconnecting.
286
+ if (shouldDeferEscalationForBridge({ bridgeAlive: bridgeAlive(agent) })) {
287
+ process.stderr.write(
288
+ `telegram gateway: obligation escalation deferred — bridge down (nudge waits for reconnect) origin=${o.originTurnId}\n`,
289
+ )
290
+ return
291
+ }
292
+ if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
293
+ process.stderr.write(
294
+ `telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
295
+ )
296
+ obligationLedger.close(o.originTurnId)
297
+ return
298
+ }
299
+ // Proceed with escalation: send ONE operator-visible nudge and close the
300
+ // obligation ONLY AFTER it actually lands. This inverts the old
301
+ // close-before-send (which silently dropped the terminal whenever the send
302
+ // failed): the close is now itself an observable terminal. A transient send
303
+ // failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
304
+ // (dead topic even after thread-fallback, blocked bot) is bounded by
305
+ // OBLIGATION_ESCALATE_MAX → close best-effort (the user is unreachable, so a
306
+ // bounded give-up beats an infinite loop / a boot-surviving poison record).
307
+ // Drive one escalation attempt. The send is a direct Telegram nudge
308
+ // (retryWithThreadFallback: a stale/renumbered topic → THREAD_NOT_FOUND retries
309
+ // thread-less, the #2096 pattern). driveEscalation guards against concurrent
310
+ // sends, bounds the send with withDeadline (so a hung send can't leak the
311
+ // in-flight flag and wedge the obligation OPEN), closes only after a successful
312
+ // send, and bounds permanent failures to a best-effort close. Extracted so the
313
+ // hang → bounded → terminal path is executable in escalation-drive.test.ts —
314
+ // the path neither mtcute (can't hang Telegram) nor a synchronous test reaches.
315
+ void driveEscalation({
316
+ escId: o.originTurnId,
317
+ inFlight: obligationEscalateInFlight,
318
+ ledger: obligationLedger,
319
+ // The nudge send stays in gateway.ts (it owns the bot handle + the
320
+ // bot-api retry-policy allowlist); this module only decides WHEN it fires.
321
+ send: () => sendEscalationNudge(o),
322
+ maxAttempts: OBLIGATION_ESCALATE_MAX,
323
+ deadlineMs: OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
324
+ })
325
+ }
326
+
327
+ return {
328
+ closeObligationOnSubstantiveReply,
329
+ openObligationFromInbound,
330
+ cancelInterruptedObligation,
331
+ obligationSweep,
332
+ }
333
+ }