switchroom 0.19.17 → 0.19.19

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 (91) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +13 -4
  4. package/dist/auth-broker/index.js +32 -5
  5. package/dist/cli/drive-write-pretool.mjs +48 -5
  6. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  7. package/dist/cli/notion-write-pretool.mjs +13 -4
  8. package/dist/cli/switchroom.js +10614 -8104
  9. package/dist/host-control/main.js +12849 -11446
  10. package/dist/vault/approvals/kernel-server.js +90 -12
  11. package/dist/vault/broker/server.js +277 -94
  12. package/package.json +5 -3
  13. package/profiles/_base/start.sh.hbs +69 -5
  14. package/profiles/coding/CLAUDE.md.hbs +1 -1
  15. package/profiles/default/CLAUDE.md.hbs +3 -3
  16. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  17. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  18. package/skills/mental-model-curator/SKILL.md +8 -6
  19. package/telegram-plugin/bridge/bridge.ts +25 -19
  20. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +28 -20
  22. package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
  23. package/telegram-plugin/dist/server.js +32 -20
  24. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  25. package/telegram-plugin/gateway/boot-card.ts +5 -1
  26. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  27. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  28. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  30. package/telegram-plugin/gateway/gateway.ts +43 -1
  31. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  32. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  33. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  34. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  35. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  36. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  37. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  38. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  39. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  40. package/telegram-plugin/gateway/store-file.ts +244 -0
  41. package/telegram-plugin/gateway/stream-render.ts +24 -5
  42. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  43. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  44. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  45. package/telegram-plugin/registry/turns-schema.ts +40 -12
  46. package/telegram-plugin/runtime-metrics.ts +14 -0
  47. package/telegram-plugin/silence-poke.ts +138 -0
  48. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  49. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  51. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  52. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  53. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  54. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  56. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  57. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  58. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  59. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  60. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  61. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  62. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  63. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  64. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  66. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  67. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  68. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  69. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  70. package/telegram-plugin/tts-normalize.ts +6 -4
  71. package/telegram-plugin/voice-normalize-text.ts +168 -11
  72. package/telegram-plugin/worker-activity-feed.ts +51 -1
  73. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  74. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  75. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  76. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  77. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  78. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  79. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  81. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  82. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  84. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  85. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  87. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  89. package/vendor/hindsight-memory/settings.json +1 -1
  90. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  91. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -10,8 +10,11 @@
10
10
  * `typing…` indicator and no activity card. The user — who dispatched the work
11
11
  * and is waiting — sees nothing until the turn is well underway. Worse, a
12
12
  * handback turn historically never even got a turn-long typing loop
13
- * (`startTurnTypingLoop` has a single caller on the real-inbound path), so the
14
- * dark stretch extended past turn start.
13
+ * (`startTurnTypingLoop` had a single caller on the real-inbound path), so the
14
+ * dark stretch extended past turn start. That second half is now closed
15
+ * independently of this seam: the `enqueue` seam arms the loop for EVERY minted
16
+ * turn (#3544), so an un-adopted handback turn is lit for its whole compose
17
+ * window. This seam still covers the pre-turn stretch (release → enqueue).
15
18
  *
16
19
  * THE FIX (one continuous card lifecycle, NOT an orphan). The moment the
17
20
  * buffered handback is RELEASED, emit a pre-turn signal for its topic:
@@ -63,6 +66,10 @@
63
66
  * gateway's mid-session + boot reapers are the crash backstop (the complete
64
67
  * record lets them finalize an orphan across a restart), and a reap hook
65
68
  * lets them stop the typing loop + drop the map entry too.
69
+ * The typing stop on EITHER reap path is SKIPPED when a real turn is live
70
+ * on that key (`hasLiveTurn`, #3544): the loop is keyed on (chat, thread),
71
+ * so reaping a stale pre-turn entry must never darken a turn that is still
72
+ * composing — that turn's canonical turn-end owns the stop.
66
73
  *
67
74
  * • DEBOUNCE (~700ms) kills sub-second-worker flicker: a worker that finishes
68
75
  * and whose turn mints within the debounce window never paints a pre-turn
@@ -155,6 +162,15 @@ export interface HandbackPreturnSignalDeps {
155
162
  * entirely (design lever 5: never paint beneath a settled turn). Optional;
156
163
  * defaults to "not settled". */
157
164
  isTurnSettled?: (statusKey: string) => boolean
165
+ /** True IFF a turn is CURRENTLY live (minted, not yet ended) on this topic
166
+ * key. Consulted before the orphan reap stops the typing loop: that loop is
167
+ * keyed on (chat, thread), NOT on this seam's entry, so stopping it while a
168
+ * real turn is composing darkens that turn's indicator for the rest of its
169
+ * life (#3544 — the 30 s reap killing a healthy loop). The stop is only
170
+ * ours to make when nothing else owns the key; when a turn IS live, its
171
+ * canonical turn-end owns the stop. Optional; defaults to "no live turn"
172
+ * (the pre-#3544 behaviour). */
173
+ hasLiveTurn?: (statusKey: string) => boolean
158
174
  now?: () => number
159
175
  /** Debounce before painting the pre-turn card (kills sub-second flicker). */
160
176
  debounceMs?: number
@@ -312,12 +328,30 @@ export function createHandbackPreturnSignal(
312
328
  })
313
329
  }
314
330
 
331
+ /**
332
+ * Stop the typing loop for an entry ONLY when no real turn owns that key
333
+ * (#3544). The loop is keyed on (chat, thread); a live turn's indicator would
334
+ * otherwise be killed mid-compose by this seam's age-based reap, and nothing
335
+ * re-arms it. When a turn IS live the canonical turn-end owns the stop, so
336
+ * skipping here cannot leak an interval.
337
+ */
338
+ function stopTypingUnlessTurnLive(entry: PreTurnEntry, reason: string): void {
339
+ if (deps.hasLiveTurn?.(entry.statusKey) === true) {
340
+ log(
341
+ `handback-preturn-signal: ${reason} key=${entry.statusKey} ` +
342
+ `typing=kept (live turn owns the stop)\n`,
343
+ )
344
+ return
345
+ }
346
+ deps.stopTypingLoop(entry.chatId, entry.threadId)
347
+ }
348
+
315
349
  function reap(entry: PreTurnEntry): void {
316
350
  entry.reapTimer = null
317
351
  if (entry.consumed) return
318
352
  entry.consumed = true
319
353
  // Stop the forever-running typing loop and finalize the frozen card.
320
- deps.stopTypingLoop(entry.chatId, entry.threadId)
354
+ stopTypingUnlessTurnLive(entry, 'orphan reap')
321
355
  if (entry.activityMessageId != null) {
322
356
  const record: PreTurnCardRecord = {
323
357
  turnKey: entry.syntheticTurnKey,
@@ -347,11 +381,31 @@ export function createHandbackPreturnSignal(
347
381
  if (chatId == null || chatId === '') return
348
382
  const threadId = inbound.threadId ?? null
349
383
  const adoptTurnId = deps.deriveTurnId(chatId, threadId, inbound.messageId)
350
- if (adoptTurnId == null) return // no stable identity → can't be adopted
351
- const statusKey = deps.chatKey(chatId, threadId)
384
+ // Observability (#3544): one line per handback RELEASE — the event that
385
+ // was previously invisible in the gateway log (zero `subagent_handback`
386
+ // lines were ever captured), so the next occurrence of a dark handback
387
+ // turn is diagnosable. Bounded by worker completions, not by turn volume.
388
+ const releaseKey = deps.chatKey(chatId, threadId)
389
+ if (adoptTurnId == null) {
390
+ log(`handback-preturn-signal: release key=${releaseKey} armed=no (no derivable turnId)\n`)
391
+ return // no stable identity → can't be adopted
392
+ }
393
+ const statusKey = releaseKey
352
394
  // Dedupe: a live entry already covers this topic (e.g. two handbacks for
353
395
  // the same topic released together — the first owns the pre-turn signal).
354
- if (byKey.has(statusKey)) return
396
+ if (byKey.has(statusKey)) {
397
+ // NOT a dark turn any more: the enqueue seam arms the typing loop
398
+ // unconditionally for every minted turn (#3544), so a deduped handback
399
+ // still lights up — it just doesn't own the pre-turn card.
400
+ log(
401
+ `handback-preturn-signal: release key=${statusKey} turnId=${adoptTurnId} ` +
402
+ `armed=no (deduped: topic already has a live pre-turn entry)\n`,
403
+ )
404
+ return
405
+ }
406
+ log(
407
+ `handback-preturn-signal: release key=${statusKey} turnId=${adoptTurnId} armed=yes\n`,
408
+ )
355
409
  const startedAt = now()
356
410
  const syntheticTurnKey = `${PRETURN_TURNKEY_PREFIX}${statusKey}:${startedAt}`
357
411
  const entry: PreTurnEntry = {
@@ -423,7 +477,7 @@ export function createHandbackPreturnSignal(
423
477
  const entry = byKey.get(statusKey)
424
478
  if (entry == null) return
425
479
  entry.consumed = true
426
- deps.stopTypingLoop(entry.chatId, entry.threadId)
480
+ stopTypingUnlessTurnLive(entry, 'reaped record')
427
481
  dropEntry(entry)
428
482
  },
429
483
 
@@ -427,6 +427,11 @@ export interface RequestConfigApprovalMessage {
427
427
  unifiedDiff: string;
428
428
  /** Card timeout in milliseconds (gateway-enforced). */
429
429
  timeoutMs: number;
430
+ /**
431
+ * Optional card header override (KEN-129 — update-check drift card).
432
+ * Absent → the default "🛠 Config edit proposed" header. ≤200 chars.
433
+ */
434
+ title?: string;
430
435
  }
431
436
 
432
437
  /**
@@ -391,6 +391,19 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
391
391
  if (typeof m.timeoutMs !== "number"
392
392
  || !Number.isFinite(m.timeoutMs)
393
393
  || (m.timeoutMs as number) <= 0) return false;
394
+ // Optional header override (KEN-129) — absent falls back to the
395
+ // default config-edit header. SINGLE LINE ONLY: the header renders
396
+ // VERBATIM as the card's first line (it carries intentional markdown,
397
+ // so it can't be escaped), which means a newline would let a caller
398
+ // forge the `Agent:` / `Reason:` lines beneath it — or unbalance the
399
+ // diff's ``` fence — on a card the operator is about to approve.
400
+ // Control characters are rejected for the same reason.
401
+ if (m.title !== undefined
402
+ && (typeof m.title !== "string"
403
+ || (m.title as string).length === 0
404
+ || (m.title as string).length > 200
405
+ // eslint-disable-next-line no-control-regex
406
+ || /[\u0000-\u001f\u007f]/.test(m.title as string))) return false;
394
407
  return true;
395
408
  }
396
409
  case "request_config_finalize": {
@@ -38,10 +38,12 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
38
38
  SILENCE_FALLBACK_HARD_MS,
39
39
  SILENCE_FLOOR_MS,
40
40
  SILENCE_DEFER_INFLIGHT_TOOLS,
41
+ isObligationOpenForTurn,
41
42
  TURN_PREVIEW_MAX,
42
43
  STATE_DIR,
43
44
  isLegitimatelyWorking,
44
45
  getCurrentTurn,
46
+ getCurrentTurnForKey,
45
47
  getInFlightUpdate,
46
48
  getTurnsDb,
47
49
  getInboundSpool,
@@ -68,6 +70,23 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
68
70
  thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
69
71
  deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
70
72
  isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
73
+ // #3552 — orphan-state reaper predicate. Deliberately the SAME condition the
74
+ // `onFrameworkFallback` late-fire guard below uses: no `activeTurnStartedAt`
75
+ // entry AND no current turn ⇒ the turn this state belongs to is over. Note
76
+ // `activeTurnStartedAt` alone is NOT sufficient — `releaseTurnBufferGate`
77
+ // clears it mid-turn on every final-answer reply while the turn keeps running
78
+ // (post-answer housekeeping), and silence-poke must keep watching that turn.
79
+ //
80
+ // #3580 — the second clause is read KEYED (`getCurrentTurnForKey(key)`, i.e.
81
+ // `currentTurnMap.get(key)`), never the bare `getCurrentTurn()` singleton.
82
+ // Under `SWITCHROOM_EMISSION_AUTHORITY=1` that singleton is a MOST-RECENT-SET
83
+ // MIRROR, so an unkeyed read answers about whichever topic started a turn last
84
+ // — not about `key`. Combined with the buffer-gate release above, topic A
85
+ // (live, gate cleared mid-turn) read DEAD as soon as topic B started and ended
86
+ // a turn, and the tick false-reaped A: permanent and silent, nothing re-arms
87
+ // until the next `startTurn`, so A loses both its mid-turn beat and its 300 s
88
+ // #1122 unwedge. Flag-OFF the keyed read IS the singleton, byte-for-byte.
89
+ isTurnLive: (key) => !(activeTurnStartedAt.get(key) == null && getCurrentTurnForKey(key) == null),
71
90
  emitMetric: (event) => {
72
91
  // Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
73
92
  emitRuntimeMetric(event)
@@ -96,8 +115,10 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
96
115
  // retired in #2667.
97
116
  onMidTurnFloor: async (ctx) => {
98
117
  // Late-fire guard, mirroring the fallback: a clean turn-end can race the
99
- // tick. If the turn is gone, stay silent.
100
- if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) return
118
+ // tick. If the turn is gone, stay silent. #3580 — keyed read; an unkeyed
119
+ // `getCurrentTurn()` here answers about another topic's turn (see the
120
+ // `isTurnLive` note above) and silences a live turn's approval re-ping.
121
+ if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) return
101
122
  const blockedOnApproval = activeStatusReactions
102
123
  .get(statusKey(ctx.chatId, ctx.threadId))
103
124
  ?.isAwaiting() ?? false
@@ -142,7 +163,18 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
142
163
  // events (124 of 138 `currentTurn_nulled=false` cases). Distinct
143
164
  // log line so observability still tracks the fact that the silence
144
165
  // crossed threshold; the wedge counter is no longer polluted.
145
- if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) {
166
+ //
167
+ // #3552: with the `isTurnLive` reaper wired above, this branch is now only
168
+ // the sub-poll-interval race (the turn ends between the tick's reap check
169
+ // and this handler running). The steady-state case it used to absorb —
170
+ // state armed for the full 300s against a turn that ended minutes earlier,
171
+ // 504 events in 14 days vs 110 real fires — is reaped at the tick instead,
172
+ // so this counter finally reads as the genuine race it names.
173
+ //
174
+ // #3580 — keyed read, same reason as `isTurnLive`: unkeyed, a sibling
175
+ // topic's turn flip made this guard skip the 300 s unwedge for a genuinely
176
+ // wedged turn (the #1122 permanent-wedge class) and log it as a clean race.
177
+ if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) {
146
178
  process.stderr.write(
147
179
  `telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
148
180
  `turn ended cleanly during silence window ` +
@@ -259,6 +291,21 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
259
291
  // stays null and we skip the send. The turn-teardown below (the unwedge —
260
292
  // the one job the live draft can't do, per the conversational-pacing RFC)
261
293
  // still runs unconditionally.
294
+ // #3551 L1/L2 — the teardown-notice suppression signal. It must be narrower
295
+ // than "some text went out", and it must mean DELIVERED.
296
+ //
297
+ // L1: `text` has two sources with opposite meanings. The approval re-ping is
298
+ // a LOUD, user-addressed message that explains why nothing is happening and
299
+ // asks the user to act — stacking a teardown notice on it is noise. The
300
+ // deterministic `formatUpdateStatusLine` is a SILENT status surface about an
301
+ // unrelated in-flight `update_apply`; it says nothing about this turn ending.
302
+ // A user who asked a question during an update and got killed anyway is
303
+ // EXACTLY the #3551 case, so that line must not suppress the notice.
304
+ //
305
+ // L2: computed from send SUCCESS, not from `text != null`. The send sits in a
306
+ // try/catch that only logs, so a throwing approval re-ping would otherwise
307
+ // suppress the notice while the user received nothing at all.
308
+ let userAddressedTextDelivered = false
262
309
  if (text != null) {
263
310
  try {
264
311
  // Conditional: when the turn is parked on an approval card, this
@@ -268,6 +315,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
268
315
  // line stays SILENT (a status surface). Gate on `blockedOnApproval`.
269
316
  // The send stays in gateway.ts behind the bot-api retry policy.
270
317
  await sendSilenceText(ctx.chatId, ctx.threadId ?? null, text, blockedOnApproval ? false : true)
318
+ userAddressedTextDelivered = blockedOnApproval
271
319
  } catch (err) {
272
320
  process.stderr.write(
273
321
  `silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
@@ -299,6 +347,18 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
299
347
  wedgedTurn != null &&
300
348
  wedgedTurn.sessionChatId === fbChatId &&
301
349
  wedgedTurn.sessionThreadId === fbThreadId
350
+ // #3580 L3 — the ONE liveness decision this fire makes about the wedged
351
+ // turn. It gates the teardown below AND is what `decideFallbackTeardownNotice`
352
+ // is told as `tearsDownLiveTurn`, so the notice can never claim a teardown
353
+ // the gate skipped (previously the notice got the looser bare
354
+ // `turnMatchesFallback`, and a user could be told "the framework ended that
355
+ // stalled turn" about a turn that never ended). Evaluated HERE — before the
356
+ // `purgeChatStale` self-heal and before `endCurrentTurnForKey`, both of
357
+ // which drop the very entry `turnLiveForItsTopic` reads — so it answers
358
+ // "was this turn live when the fallback fired", the question both the
359
+ // teardown and the notice actually mean.
360
+ const tearsDownLiveTurn =
361
+ turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
302
362
  const turnStartedAt = activeTurnStartedAt.get(fbKey)
303
363
  if (turnStartedAt != null) {
304
364
  const turnDurationMs = Date.now() - turnStartedAt
@@ -424,7 +484,14 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
424
484
  // Flag-ON: `byKey.get(fbKey) === wedgedTurn`, so the keyed delete still
425
485
  // fires when the LIVE mirror has already flipped to another topic B (a bare
426
486
  // `currentTurn === wedgedTurn` would falsely skip and leak A's byKey entry).
427
- if (turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)) {
487
+ //
488
+ // #3580 L3 — the gate is the `tearsDownLiveTurn` const computed above, the
489
+ // SAME value handed to `decideFallbackTeardownNotice`. Do not re-inline the
490
+ // condition here: it must be evaluated before the `purgeChatStale` sweep
491
+ // above (which can drop this key's entry and so flip
492
+ // `turnLiveForItsTopic`), and the notice must speak for exactly what this
493
+ // gate did.
494
+ if (tearsDownLiveTurn && wedgedTurn != null) {
428
495
  // Status-surface observability: emit the lifecycle CLEAR for the
429
496
  // silence-poke teardown so a fallback-nulled turn has a turn-lifecycle
430
497
  // line like every other clear path (the framework-fallback line below is
@@ -439,6 +506,56 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
439
506
  // the mirror iff it still points here — a live sibling topic is untouched.
440
507
  endCurrentTurnForKey(wedgedTurn, fbKey)
441
508
  }
509
+ // #3551 — the teardown NOTICE. Everything above just ENDED a live turn. On
510
+ // the non-approval branch `text` is null (`formatFrameworkFallbackText`
511
+ // returns a string only when parked on an approval card), so before this
512
+ // the user saw literally nothing: N minutes of silence, then the agent
513
+ // apparently starting the same request over. Killing a user's turn with no
514
+ // user-visible signal is a correctness bug, not a tuning knob — 110 fires /
515
+ // 14 days, ~9h of dead air (#3551). The teardown itself is NOT removed (it
516
+ // is the #1122 unwedge, the fallback's one load-bearing job); it is made
517
+ // observable. Sent AFTER the teardown so the notice cannot itself be
518
+ // clobbered by the state purge, and gated by `decideFallbackTeardownNotice`
519
+ // so it can only ever speak for a human-waiting, undelivered turn that this
520
+ // fire actually killed — at most once, since `endTurn(fbKey)` above dropped
521
+ // the key.
522
+ const teardownNotice = silencePoke.decideFallbackTeardownNotice({
523
+ tearsDownLiveTurn,
524
+ role: wedgedTurn?.role ?? null,
525
+ finalAnswerDelivered: wedgedTurn?.finalAnswerDelivered ?? false,
526
+ userAddressedTextDelivered,
527
+ // #3575 review B1 — this must be a question about THIS turn, not a static
528
+ // env flag. `OBLIGATION_LEDGER_ENABLED` is true for the whole process, so
529
+ // it promised "being re-asked now" in cases where no re-ask can ever
530
+ // happen: the obligation already hit OBLIGATION_REPRESENT_MAX (2) and
531
+ // escalated+closed, or it was closed silently by an outbound-since-open
532
+ // (obligation-wiring.ts:230/:294), or the inbound never opened one at all
533
+ // (synthetic / steering / interrupt). The user was then told to wait for a
534
+ // re-ask that never comes. Ask the ledger about this turn's own origin id
535
+ // instead, so the honest "please re-send it" branch fires when there is no
536
+ // open obligation. The lookup is deliberately made HERE, after the
537
+ // teardown above (which does not touch the ledger), so it reflects the
538
+ // ledger state at the moment we speak.
539
+ representWillFollow: isObligationOpenForTurn(wedgedTurn?.turnId ?? null),
540
+ silenceMs: ctx.silenceMs,
541
+ })
542
+ if (teardownNotice.send) {
543
+ try {
544
+ // LOUD (disable_notification: false). The user asked a question and is
545
+ // being told their attempt was killed — that is exactly the case where
546
+ // silence would train them to distrust the channel.
547
+ await sendSilenceText(fbChatId, ctx.threadId ?? null, teardownNotice.text, false)
548
+ } catch (err) {
549
+ process.stderr.write(
550
+ `silence-poke teardown notice sendMessage failed chat=${fbChatId} thread=${ctx.threadId}: ${err}\n`,
551
+ )
552
+ }
553
+ } else {
554
+ process.stderr.write(
555
+ `telegram gateway: silence-poke teardown notice skipped reason=${teardownNotice.reason} ` +
556
+ `chat=${fbChatId} thread=${ctx.threadId ?? '-'}\n`,
557
+ )
558
+ }
442
559
  // Best-effort: clear any pending silent-end marker so the Stop hook
443
560
  // doesn't double-block when claude eventually exits the wedged turn.
444
561
  try {
@@ -464,7 +581,10 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
464
581
  process.stderr.write(
465
582
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
466
583
  `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
467
- `currentTurn_nulled=${turnMatchesFallback} ` +
584
+ // #3580 L3 — report the gate that actually ran, not the looser
585
+ // chat/thread match: `turnMatchesFallback` was true in cases where the
586
+ // keyed liveness check skipped the teardown, so the field lied.
587
+ `currentTurn_nulled=${tearsDownLiveTurn} ` +
468
588
  `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
469
589
  `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
470
590
  `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
@@ -19,13 +19,24 @@
19
19
  * record and edits the card closed.
20
20
  *
21
21
  * File format: a single JSON object `{ pending, delivered }`, written
22
- * synchronously to avoid interleaving on concurrent auto-denies, mode 0o600.
23
- * Mirrors the `permission-card-store.ts` pattern. Both lists are hard-capped
24
- * so the file stays tiny under a runaway loop.
22
+ * synchronously to avoid interleaving on concurrent auto-denies, mode 0o600,
23
+ * ATOMICALLY (tmp + fsync + rename) so a crash mid-persist can't leave a torn
24
+ * file. Mirrors the `permission-card-store.ts` pattern. Both lists are
25
+ * hard-capped so the file stays tiny under a runaway loop. A corrupt file is
26
+ * quarantined and logged loudly rather than silently read as "nothing was
27
+ * missed" — see store-file.ts. NOTE: atomic REPLACEMENT only —
28
+ * whole-old-or-whole-new, not power-loss durability; the missing
29
+ * parent-directory fsync is tracked in #3603.
25
30
  */
26
31
 
27
- import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
32
+ import { unlinkSync } from 'node:fs'
28
33
  import { join } from 'node:path'
34
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
35
+ import {
36
+ preserveUnreadableStoreFile,
37
+ quarantineCorruptStoreFile,
38
+ readStoreJsonSync,
39
+ } from './store-file.js'
29
40
 
30
41
  export interface MissedApproval {
31
42
  /** The permission request_id that timed out (dedup key). */
@@ -80,29 +91,67 @@ export interface MissedApprovalsStore {
80
91
  export const MAX_PENDING = 50
81
92
  export const MAX_DELIVERED = 20
82
93
 
83
- export function createMissedApprovalsStore(stateDir: string): MissedApprovalsStore {
94
+ export function createMissedApprovalsStore(
95
+ stateDir: string,
96
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
97
+ log: (line: string) => void = l => process.stderr.write(l),
98
+ ): MissedApprovalsStore {
84
99
  const filePath = join(stateDir, 'missed-approvals.json')
85
100
 
101
+ const EMPTY = (): FileShape => ({ pending: [], delivered: [] })
102
+
103
+ /** Set when the last read failed for a non-ENOENT reason — the next write
104
+ * must preserve the file it could not read instead of clobbering it. */
105
+ let unreadable = false
106
+
86
107
  function read(): FileShape {
87
- try {
88
- const raw = readFileSync(filePath, 'utf-8')
89
- const parsed = JSON.parse(raw) as Partial<FileShape>
90
- return {
91
- pending: Array.isArray(parsed?.pending) ? parsed.pending : [],
92
- delivered: Array.isArray(parsed?.delivered) ? parsed.delivered : [],
108
+ const result = readStoreJsonSync(filePath, 'missed-approvals-store', log)
109
+ unreadable = result.status === 'unreadable'
110
+ if (result.status !== 'ok') return EMPTY()
111
+ const parsed = result.value as Partial<FileShape>
112
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
113
+ quarantineCorruptStoreFile(
114
+ filePath,
115
+ 'missed-approvals-store',
116
+ 'parsed to a non-object — not a missed-approvals file',
117
+ log,
118
+ )
119
+ return EMPTY()
120
+ }
121
+ // A PRESENT-but-non-array list is corruption, not "empty". Coercing it to
122
+ // [] would resurrect the exact bug this store was hardened against: a
123
+ // half-written `{"pending": null}` parses fine, so quarantine would never
124
+ // fire and the digest would silently come up empty. An ABSENT list is the
125
+ // legitimate cold-start/partial-shape case and stays silent.
126
+ for (const field of ['pending', 'delivered'] as const) {
127
+ if (parsed[field] !== undefined && !Array.isArray(parsed[field])) {
128
+ quarantineCorruptStoreFile(
129
+ filePath,
130
+ 'missed-approvals-store',
131
+ `\`${field}\` is present but not an array — truncated or malformed write`,
132
+ log,
133
+ )
134
+ return EMPTY()
93
135
  }
94
- } catch {
95
- return { pending: [], delivered: [] }
136
+ }
137
+ return {
138
+ pending: parsed.pending ?? [],
139
+ delivered: parsed.delivered ?? [],
96
140
  }
97
141
  }
98
142
 
99
143
  function write(f: FileShape): void {
100
144
  try {
101
- writeFileSync(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
145
+ // Fail closed: never let an overwrite be what destroys state we merely
146
+ // failed to READ (flaky mount, transient EACCES).
147
+ if (unreadable) {
148
+ preserveUnreadableStoreFile(filePath, 'missed-approvals-store', log)
149
+ unreadable = false
150
+ }
151
+ // tmp + fsync + rename — never truncate the destination in place.
152
+ atomicWriteFileSync(filePath, JSON.stringify(f), 0o600)
102
153
  } catch (err) {
103
- process.stderr.write(
104
- `telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`,
105
- )
154
+ log(`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`)
106
155
  }
107
156
  }
108
157
 
@@ -242,6 +242,10 @@ export class ObligationLedger {
242
242
  * since `lastRepresentedAt`. Without this, the 5s sweep can fire again before
243
243
  * the re-presented turn even reaches the agent, burning the represent budget
244
244
  * immediately and producing back-to-back escalations on the same message.
245
+ * #3550: this window is retired early once a turn that started after the
246
+ * re-present has ENDED — at that point the trailing-answer grace above is the
247
+ * one covering the real risk, and holding the rest of the represent window is
248
+ * pure latency. See `representGraceStillProtecting`.
245
249
  */
246
250
  decideAtIdle(opts?: {
247
251
  now: number
@@ -267,6 +271,80 @@ export class ObligationLedger {
267
271
  return { action: 'represent', obligation: o }
268
272
  }
269
273
 
274
+ /**
275
+ * #3550 — is the per-represent grace still doing real work for this
276
+ * obligation? Pure so the rule is testable in isolation.
277
+ *
278
+ * The per-represent grace exists for ONE reason, stated at its definition:
279
+ * "the 5s sweep can fire again before the re-presented turn even reaches the
280
+ * agent". It is a proxy for "the re-present has not landed yet". Once a turn
281
+ * that started AFTER the re-present has ENDED, the proxy is spent — the
282
+ * re-present demonstrably reached the agent, the agent ran a whole turn on
283
+ * it, and that turn is over. Holding the remaining ~110s of a 120s window
284
+ * open at that point delays the next rung of a ladder that is already
285
+ * bounded by `maxRepresents`, for no protective benefit.
286
+ *
287
+ * The obligation is NOT released into the void when this returns false: the
288
+ * trailing-answer grace (`trailingGraceMs` from `lastTurnEndedAt`, 45s by
289
+ * default) is a separate, independently-evaluated window that covers exactly
290
+ * the risk that matters here — a slow answer still landing after its turn
291
+ * ended. So the early-out only applies when that grace is actually armed
292
+ * (`trailingGraceMs > 0`); with it disabled, the represent grace stays the
293
+ * full window rather than leaving the obligation ungated.
294
+ *
295
+ * A re-present with NO ended turn after it — the genuinely in-flight case
296
+ * the grace was written for — is untouched and keeps the full window.
297
+ *
298
+ * FLOOR (`MIN_REPRESENT_INTERVAL_MS`). Retiring the represent window makes the
299
+ * rung interval DERIVED — `trailingGraceMs + the represent-turn's duration`
300
+ * — where it used to be floored by the flat 120s window regardless of how the
301
+ * trailing grace was tuned. Without a floor,
302
+ * `SWITCHROOM_OBLIGATION_ESCALATE_GRACE_MS=1000` (a plausible "make it
303
+ * snappier" tune) collapses the whole ladder to ~1s+d per rung and escalates
304
+ * to the operator within ~15-20s of the original message. That is a config
305
+ * footgun the old code could not have, so it is closed by a MECHANISM, not a
306
+ * doc warning: the early-out is withheld until at least
307
+ * `min(representGraceMs, max(trailingGraceMs, MIN_REPRESENT_INTERVAL_MS))`
308
+ * has elapsed since the re-present. At the defaults (45s trailing / 120s
309
+ * represent) the floor is 45s and never binds — the trailing grace, measured
310
+ * from the strictly-later turn end, always expires after it — so this changes
311
+ * nothing in the shipped configuration.
312
+ */
313
+ static representGraceStillProtecting(
314
+ o: Pick<Obligation, 'lastRepresentedAt' | 'lastTurnEndedAt'>,
315
+ now: number,
316
+ representGraceMs: number,
317
+ trailingGraceMs: number,
318
+ ): boolean {
319
+ if (representGraceMs <= 0) return false
320
+ if (o.lastRepresentedAt == null) return false
321
+ const sinceRepresent = now - o.lastRepresentedAt
322
+ if (sinceRepresent >= representGraceMs) return false
323
+ // Inside the window. Is it still protecting anything?
324
+ const representTurnEnded =
325
+ o.lastTurnEndedAt != null && o.lastTurnEndedAt > o.lastRepresentedAt
326
+ if (representTurnEnded && trailingGraceMs > 0) {
327
+ // Spent — the trailing grace takes over, but never sooner than the floor
328
+ // (and never longer than the represent window the operator configured).
329
+ const floorMs = Math.min(
330
+ representGraceMs,
331
+ Math.max(trailingGraceMs, ObligationLedger.MIN_REPRESENT_INTERVAL_MS),
332
+ )
333
+ return sinceRepresent < floorMs
334
+ }
335
+ return true
336
+ }
337
+
338
+ /**
339
+ * The hard floor on the interval between two rungs of the represent ladder,
340
+ * used by `representGraceStillProtecting`. Independent of how the trailing
341
+ * grace is tuned, an obligation is never acted on again within this long of
342
+ * its own re-present. 30s comfortably exceeds the 5s sweep tick and the
343
+ * round-trip for a re-presented turn to reach the agent and answer, which is
344
+ * the only thing the represent window was ever debouncing.
345
+ */
346
+ static readonly MIN_REPRESENT_INTERVAL_MS = 30_000
347
+
270
348
  /** The oldest open obligation that is currently ELIGIBLE to act on — i.e. NOT
271
349
  * within any grace window:
272
350
  * - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
@@ -275,9 +353,11 @@ export class ObligationLedger {
275
353
  * - background-work grace: when `backgroundWorkActive`, it was opened <
276
354
  * `backgroundGraceMs` ago (genuine in-flight autonomous work — bounded by
277
355
  * the ceiling so a stale/leaked worker can't suppress escalation forever);
278
- * - per-represent grace: it was re-presented < `representGraceMs` ago (prevents
279
- * a 5s sweep tick from immediately firing again on the same obligation before
280
- * the re-presented turn even reaches the agent). */
356
+ * - per-represent grace: it was re-presented < `representGraceMs` ago AND that
357
+ * re-present has not yet produced a completed turn (prevents a 5s sweep tick
358
+ * from immediately firing again before the re-presented turn even reaches the
359
+ * agent — see `representGraceStillProtecting` for why a turn that has already
360
+ * ended retires this window early, #3550). */
281
361
  private oldestEligible(
282
362
  now: number,
283
363
  graceMs: number,
@@ -290,7 +370,7 @@ export class ObligationLedger {
290
370
  if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
291
371
  if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
292
372
  continue // in-flight autonomous work, bounded by the ceiling
293
- if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
373
+ if (ObligationLedger.representGraceStillProtecting(o, now, representGraceMs, graceMs))
294
374
  continue // per-represent grace: sweep fired before re-presented turn landed
295
375
  if (best === undefined || o.openedAt < best.openedAt) best = o
296
376
  }