switchroom 0.19.42 → 0.19.44

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 (75) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +284 -124
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +4094 -2711
  5. package/dist/host-control/main.js +472 -155
  6. package/dist/vault/approvals/kernel-server.js +231 -71
  7. package/dist/vault/broker/server.js +293 -133
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +93 -8
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1682 -936
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  73. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  75. package/vendor/hindsight-memory/settings.json +1 -1
@@ -105,6 +105,29 @@ import type { InboundMessage } from './ipc-protocol.js'
105
105
  * the seam's `handleReaped` hook (stop typing loop + drop map entry). */
106
106
  export const PRETURN_TURNKEY_PREFIX = 'preturn:'
107
107
 
108
+ /** `meta` key carrying the deterministic-recovery re-injection counter across a
109
+ * re-injected handback's round trip through the pending-inbound buffer. Stamped
110
+ * on the inbound just before it is re-injected on a genuine orphan; read back
111
+ * when that same inbound is released again, so the retry cap survives the
112
+ * drop-and-recreate of the per-topic pre-turn entry. `meta` is
113
+ * `Record<string,string>`, so the count is stored as a decimal string. */
114
+ export const HANDBACK_REINJECT_COUNT_META_KEY = 'handbackReinjectCount'
115
+
116
+ /** Emitted (NOT to chat) when a genuine orphan has exhausted its re-injection
117
+ * retries — the deterministic-recovery escalation surface. The gateway routes
118
+ * this to fleet-health telemetry (a structured log line the nightly sensor
119
+ * reads), never to an operator-facing card: the system must recover itself, not
120
+ * ask a human to nudge it. */
121
+ export interface HandbackOrphanEscalation {
122
+ statusKey: string
123
+ chatId: string
124
+ threadId: number | null
125
+ adoptTurnId: string
126
+ reinjectCount: number
127
+ /** Age (ms) of the handback at escalation — release → escalation. */
128
+ ageMs: number
129
+ }
130
+
108
131
  /** True IFF `source` is the load-bearing subagent-handback source string. Kept
109
132
  * here next to the seam so a source-string regression is caught by this
110
133
  * module's own test, not only the inbound-builder's. */
@@ -162,9 +185,21 @@ export interface HandbackPreturnSignalDeps {
162
185
  * when the send failed / was suppressed (best-effort — a null just means no
163
186
  * card was painted, so nothing to adopt or reap). */
164
187
  openCard: (chatId: string, threadId: number | null) => Promise<number | null>
165
- /** Finalize (single honest edit) a frozen never-adopted pre-turn card on
166
- * orphan self-reap. Best-effort; failures swallowed by the caller. */
167
- finalizeCard: (record: PreTurnCardRecord) => void | Promise<void>
188
+ /** DELETE (not edit) a never-adopted pre-turn card. Used on the genuine-orphan
189
+ * reap so the frozen card is removed rather than rewritten to a stale
190
+ * user-facing "needs a nudge" message, and on the rare emit-race teardown of a
191
+ * card the adopting turn already owns. Best-effort; failures swallowed by the
192
+ * caller. */
193
+ deleteCard: (record: PreTurnCardRecord) => void | Promise<void>
194
+ /** Re-inject a genuinely-orphaned handback back through the pending-inbound
195
+ * buffer so the machine recovers itself — the deterministic alternative to
196
+ * asking the operator to nudge. The gateway's `pendingInboundBuffer.push`. The
197
+ * seam stamps `HANDBACK_REINJECT_COUNT_META_KEY` on `inbound.meta` first so the
198
+ * retry cap survives the round trip. */
199
+ reinjectHandback: (inbound: InboundMessage) => void
200
+ /** Escalate a genuine orphan that has exhausted its re-injection retries to
201
+ * fleet-health telemetry (NOT a chat card). */
202
+ escalateOrphan: (escalation: HandbackOrphanEscalation) => void
168
203
  /** Persist the durable card record (the gateway's `writeActivityCardRecord`,
169
204
  * scoped by `turnKey`). */
170
205
  writeCardRecord: (record: PreTurnCardRecord) => void
@@ -190,16 +225,18 @@ export interface HandbackPreturnSignalDeps {
190
225
  * handback is delivered into that single queue and only mints its adopting
191
226
  * turn when claude next goes idle, so while claude is busy the handback is
192
227
  * NOT orphaned — it is correctly enqueued BEHIND the in-flight turn (a long
193
- * parent turn can run many minutes). Finalizing it as an orphan in that
194
- * window posts a false "never started — needs a nudge" card. The reap
195
- * therefore DEFERS (re-arms) while this is true and finalizes only once
196
- * claude is idle and the handback still has not been adopted the genuine
197
- * bridge-death / dropped-inbound degenerate case the reap exists for.
198
- * Distinct from `hasLiveTurn`, which is PER-KEY and only governs the
199
- * typing-loop stop; this is the GLOBAL busy signal because the handback
200
- * queues behind whatever turn is running, regardless of topic. Optional;
201
- * defaults to "not busy" (immediate reap the pre-fix behaviour), so the
202
- * genuine-orphan path is unchanged. */
228
+ * parent turn can run many minutes). Reaping it as an orphan in that window
229
+ * used to post a false "never started — needs a nudge" card (the 3-cards-in-
230
+ * 4-min incident). The reap therefore DEFERS (re-arms) while this is true
231
+ * and, complementarily, while the handback was delivered but its turn has not
232
+ * started streaming yet (`deliveredAwaitingTurnStart`, the #4027 coverage
233
+ * gap) recovering only once claude is idle AND the delivered window has
234
+ * elapsed AND the handback still has not been adopted (the genuine bridge-
235
+ * death / dropped-inbound case). Recovery is deterministic (delete the frozen
236
+ * card + re-inject, then escalate), never an operator nudge. Distinct from
237
+ * `hasLiveTurn`, which is PER-KEY and only governs the typing-loop stop; this
238
+ * is the GLOBAL busy signal because the handback queues behind whatever turn
239
+ * is running, regardless of topic. Optional; defaults to "not busy". */
203
240
  isClaudeBusy?: () => boolean
204
241
  now?: () => number
205
242
  /** Debounce before painting the pre-turn card (kills sub-second flicker). */
@@ -207,6 +244,17 @@ export interface HandbackPreturnSignalDeps {
207
244
  /** Age after emit at which a never-adopted card self-reaps. Independent of
208
245
  * topic liveness. */
209
246
  adoptTimeoutMs?: number
247
+ /** TTL (ms) for the delivered-but-not-yet-streaming window. A released
248
+ * handback has been DELIVERED into claude's queue but its adopting turn may
249
+ * not have started streaming yet — the false-orphan window #4027 left open
250
+ * (idle machine + un-adopted = looked like an orphan). While within this TTL
251
+ * the reap DEFERS (`deliveredAwaitingTurnStart`); past it a truly-stuck
252
+ * delivery finally reaps for deterministic recovery rather than hanging
253
+ * forever. Defaults to 5 min. */
254
+ deliveredTtlMs?: number
255
+ /** Max deterministic re-injections of a genuine orphan before escalating to
256
+ * fleet-health telemetry. Defaults to 2. */
257
+ maxReinjects?: number
210
258
  /** Injected scheduler (mirrors bridge-dead-watchdog.ts): defaults to
211
259
  * setTimeout/clearTimeout. Injected so a runner-agnostic test can drive the
212
260
  * debounce + self-reap deterministically WITHOUT any vitest-only fake-timer
@@ -224,6 +272,18 @@ interface PreTurnEntry {
224
272
  adoptTurnId: string
225
273
  syntheticTurnKey: string
226
274
  startedAt: number
275
+ /** When the handback was RELEASED/DELIVERED for delivery (stamped at
276
+ * `noteHandbackRelease`). Drives `deliveredAwaitingTurnStart`: the reap must
277
+ * not treat a delivered-but-not-yet-streaming handback as an orphan within
278
+ * the delivered TTL. Equal to `startedAt` (release is the delivery point). */
279
+ deliveredAt: number
280
+ /** How many times this handback has already been deterministically re-injected
281
+ * (carried across the buffer round trip via `HANDBACK_REINJECT_COUNT_META_KEY`
282
+ * on the inbound). Governs the retry cap before fleet-health escalation. */
283
+ reinjectCount: number
284
+ /** The released handback inbound itself — retained so a genuine orphan can be
285
+ * re-injected through the pending-inbound buffer (deterministic recovery). */
286
+ inbound: InboundMessage
227
287
  pinned: boolean
228
288
  debounceTimer: unknown | null
229
289
  reapTimer: unknown | null
@@ -273,6 +333,8 @@ export function createHandbackPreturnSignal(
273
333
  const now = deps.now ?? (() => Date.now())
274
334
  const debounceMs = deps.debounceMs ?? 700
275
335
  const adoptTimeoutMs = deps.adoptTimeoutMs ?? 30_000
336
+ const deliveredTtlMs = deps.deliveredTtlMs ?? 300_000
337
+ const maxReinjects = deps.maxReinjects ?? 2
276
338
  const setTimer =
277
339
  deps.setTimer ??
278
340
  ((fn: () => void, ms: number) => {
@@ -333,9 +395,9 @@ export function createHandbackPreturnSignal(
333
395
  if (messageId == null) return // no card painted; the reap timer cleans up
334
396
  if (entry.consumed) {
335
397
  // Adopted/reaped while the send was in flight: the card is now the
336
- // turn's (adoption seeds a null id it can't use) — finalize+clear so
337
- // it never dangles. Rare; the debounce makes it unlikely.
338
- void deps.finalizeCard({
398
+ // turn's (adoption seeds a null id it can't use) — DELETE it so it
399
+ // never dangles. Rare; the debounce makes it unlikely.
400
+ void deps.deleteCard({
339
401
  turnKey: entry.syntheticTurnKey,
340
402
  chatId: entry.chatId,
341
403
  threadId: entry.threadId,
@@ -385,30 +447,52 @@ export function createHandbackPreturnSignal(
385
447
  deps.stopTypingLoop(entry.chatId, entry.threadId)
386
448
  }
387
449
 
450
+ /**
451
+ * The delivered-but-not-yet-streaming window (#4027 coverage gap). A released
452
+ * handback has been DELIVERED into claude's single input queue, but its
453
+ * adopting turn may not have started streaming yet — and in that stretch the
454
+ * machine reads as idle (`isClaudeBusy()` false) with the entry un-adopted,
455
+ * which looked exactly like a genuine orphan and produced the false "needs a
456
+ * nudge" card even after #4027 gated on `isMachineInTurn`. While the handback
457
+ * is within the delivered TTL and its turn has not started (we are in `reap`,
458
+ * so the entry is un-consumed = no adopting turn minted), it is NOT orphaned —
459
+ * defer. Past the TTL a truly-stuck delivery finally reaps rather than hanging
460
+ * forever, handing off to deterministic recovery.
461
+ */
462
+ function deliveredAwaitingTurnStart(entry: PreTurnEntry): boolean {
463
+ return now() - entry.deliveredAt < deliveredTtlMs
464
+ }
465
+
388
466
  function reap(entry: PreTurnEntry): void {
389
467
  entry.reapTimer = null
390
468
  if (entry.consumed) return
391
469
  // PRIMARY GATE (queue state, not a flat timeout): a released handback is
392
470
  // delivered into claude's SINGLE input queue and mints its adopting turn
393
- // only when claude next goes idle. If claude is STILL in a turn, this
394
- // handback is not orphaned it is correctly enqueued behind the in-flight
395
- // turn (a long parent turn can run many minutes). Finalizing now would post
396
- // a false "never started — needs a nudge" card (the 3-cards-in-4-min
397
- // incident). Defer: re-arm and re-check. We finalize only once claude is
398
- // idle AND the handback still has not been adopted (the genuine bridge-
399
- // death / dropped-inbound case). This cannot loop forever on a healthy
400
- // agent: claude processes its queue FIFO, so the handback is either adopted
401
- // (cancelling the reap) or claude eventually goes idle (letting it fire).
402
- if (deps.isClaudeBusy?.() === true) {
471
+ // only when claude next goes idle. If claude is STILL in a turn, OR the
472
+ // handback was delivered and its turn has simply not started streaming yet
473
+ // (within the delivered TTL the #4027 coverage gap), the handback is NOT
474
+ // orphaned. Reaping now would have posted a false "never started — needs a
475
+ // nudge" card (the 3-cards-in-4-min incident). Defer: re-arm and re-check.
476
+ // We proceed to recovery only once claude is idle AND the delivered window
477
+ // has elapsed AND the handback still has not been adopted (the genuine
478
+ // bridge-death / dropped-inbound case). This cannot loop forever: claude
479
+ // processes its queue FIFO (adoption cancels the reap), and the delivered
480
+ // TTL is bounded, so a stuck delivery eventually recovers deterministically.
481
+ if (deps.isClaudeBusy?.() === true || deliveredAwaitingTurnStart(entry)) {
403
482
  log(
404
483
  `handback-preturn-signal: reap deferred key=${entry.statusKey} ` +
405
- `(claude busy handback enqueued behind an in-flight turn, not orphaned)\n`,
484
+ `(${deps.isClaudeBusy?.() === true ? 'claude busy' : 'delivered, turn not yet streaming'}` +
485
+ ` — not orphaned)\n`,
406
486
  )
407
487
  entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
408
488
  return
409
489
  }
410
490
  entry.consumed = true
411
- // Stop the forever-running typing loop and finalize the frozen card.
491
+ // GENUINE ORPHAN. Recover DETERMINISTICALLY never ask the operator to
492
+ // nudge. Stop the forever-running typing loop, DELETE the frozen card (do
493
+ // not leave a stale "needs a nudge" message), then re-inject the handback so
494
+ // the machine re-processes it itself. Cap the re-injections; past the cap
495
+ // escalate to fleet-health telemetry (NOT a chat card).
412
496
  stopTypingUnlessTurnLive(entry, 'orphan reap')
413
497
  if (entry.activityMessageId != null) {
414
498
  const record: PreTurnCardRecord = {
@@ -419,17 +503,61 @@ export function createHandbackPreturnSignal(
419
503
  startedAt: entry.startedAt,
420
504
  pinned: entry.pinned,
421
505
  }
422
- // Clear the durable record BEFORE the finalizing edit (at-most-once
423
- // idempotency guard, mirroring the activity-card-store reapers).
506
+ // Clear the durable record BEFORE the delete (at-most-once idempotency
507
+ // guard, mirroring the activity-card-store reapers).
424
508
  deps.clearCardRecord(entry.syntheticTurnKey, entry.activityMessageId)
425
- void Promise.resolve(deps.finalizeCard(record)).catch((err) => {
509
+ void Promise.resolve(deps.deleteCard(record)).catch((err) => {
426
510
  log(
427
- `handback-preturn-signal: orphan finalize failed key=${entry.statusKey}: ` +
511
+ `handback-preturn-signal: orphan card delete failed key=${entry.statusKey}: ` +
428
512
  `${err instanceof Error ? err.message : String(err)}\n`,
429
513
  )
430
514
  })
431
515
  }
516
+ // Drop the entry from the topic map BEFORE re-injecting so a redelivery that
517
+ // re-enters `noteHandbackRelease` (production: async buffer drain; possible
518
+ // synchronously) is not swallowed by the per-topic dedupe guard. The entry
519
+ // object stays valid for the captured re-inject below.
432
520
  dropEntry(entry)
521
+ if (entry.reinjectCount < maxReinjects) {
522
+ const nextCount = entry.reinjectCount + 1
523
+ // Stamp the counter on the inbound so it survives the buffer round trip
524
+ // (the re-injected inbound re-enters `noteHandbackRelease`, which reads it
525
+ // back — the retry cap outlives the drop-and-recreate of this entry).
526
+ entry.inbound.meta[HANDBACK_REINJECT_COUNT_META_KEY] = String(nextCount)
527
+ log(
528
+ `handback-preturn-signal: orphan reap key=${entry.statusKey} ` +
529
+ `turnId=${entry.adoptTurnId} — re-injecting handback (attempt ${nextCount}/${maxReinjects})\n`,
530
+ )
531
+ try {
532
+ deps.reinjectHandback(entry.inbound)
533
+ } catch (err) {
534
+ log(
535
+ `handback-preturn-signal: orphan re-inject failed key=${entry.statusKey}: ` +
536
+ `${err instanceof Error ? err.message : String(err)}\n`,
537
+ )
538
+ }
539
+ } else {
540
+ log(
541
+ `handback-preturn-signal: orphan reap key=${entry.statusKey} ` +
542
+ `turnId=${entry.adoptTurnId} — re-injection cap (${maxReinjects}) exhausted, ` +
543
+ `escalating to fleet-health telemetry\n`,
544
+ )
545
+ try {
546
+ deps.escalateOrphan({
547
+ statusKey: entry.statusKey,
548
+ chatId: entry.chatId,
549
+ threadId: entry.threadId,
550
+ adoptTurnId: entry.adoptTurnId,
551
+ reinjectCount: entry.reinjectCount,
552
+ ageMs: now() - entry.deliveredAt,
553
+ })
554
+ } catch (err) {
555
+ log(
556
+ `handback-preturn-signal: orphan escalation failed key=${entry.statusKey}: ` +
557
+ `${err instanceof Error ? err.message : String(err)}\n`,
558
+ )
559
+ }
560
+ }
433
561
  }
434
562
 
435
563
  return {
@@ -466,6 +594,12 @@ export function createHandbackPreturnSignal(
466
594
  )
467
595
  const startedAt = now()
468
596
  const syntheticTurnKey = `${PRETURN_TURNKEY_PREFIX}${statusKey}:${startedAt}`
597
+ // Carry the re-injection counter across the buffer round trip: a genuine
598
+ // orphan re-injects this inbound, which re-enters here with the counter
599
+ // stamped on `meta`, so the retry cap survives the entry's recreate.
600
+ const rawReinject = inbound.meta?.[HANDBACK_REINJECT_COUNT_META_KEY]
601
+ const parsedReinject = rawReinject != null ? Number.parseInt(rawReinject, 10) : 0
602
+ const reinjectCount = Number.isFinite(parsedReinject) && parsedReinject > 0 ? parsedReinject : 0
469
603
  const entry: PreTurnEntry = {
470
604
  statusKey,
471
605
  chatId,
@@ -473,6 +607,10 @@ export function createHandbackPreturnSignal(
473
607
  adoptTurnId,
474
608
  syntheticTurnKey,
475
609
  startedAt,
610
+ // Release IS the delivery point (this is the buffer-drain chokepoint).
611
+ deliveredAt: startedAt,
612
+ reinjectCount,
613
+ inbound,
476
614
  pinned: false,
477
615
  debounceTimer: null,
478
616
  reapTimer: null,
@@ -179,6 +179,7 @@ export type GatewayToClient =
179
179
  | Ms365ApprovalPostedEvent
180
180
  | ConfigApprovalResolvedEvent
181
181
  | RolloutStatusPostedEvent
182
+ | RolloutStatusEditedEvent
182
183
  | PendingPermissionStatusEvent
183
184
  | PreApprovedResultEvent;
184
185
 
@@ -567,9 +568,13 @@ export interface RolloutStatusPostMessage {
567
568
  /**
568
569
  * #2726 Part 2 — hostd asks the gateway to EDIT the rollout status message it
569
570
  * previously posted (identified by `messageId`, returned in the
570
- * `rollout_status_posted` reply). Best-effort, fire-and-forget: an edit failure
571
- * (incl. Telegram 429) is handled gateway-side and never surfaced back toward
572
- * the roll.
571
+ * `rollout_status_posted` reply). Best-effort toward the ROLL: hostd never
572
+ * blocks on it and Telegram 429s are retried gateway-side.
573
+ *
574
+ * #4065 — the gateway now replies with `rollout_status_edited` so hostd can
575
+ * tell "edited" from "edited into the void". A seeded-resume narrator holds a
576
+ * message_id it never posted itself; without the reply a stale id means the
577
+ * operator sees a frozen card for the rest of the roll.
573
578
  */
574
579
  export interface RolloutStatusEditMessage {
575
580
  type: "rollout_status_edit";
@@ -597,6 +602,29 @@ export interface RolloutStatusPostedEvent {
597
602
  reason?: string;
598
603
  }
599
604
 
605
+ /**
606
+ * #4065 — gateway → hostd reply after a `rollout_status_edit`, so a narrator
607
+ * editing a card it did NOT post (the seeded post-self-bump resume) can tell
608
+ * that the card is gone instead of editing into the void for the rest of the
609
+ * roll. Advisory only: hostd never blocks a roll on it, and a gateway that
610
+ * predates this event simply never sends one (hostd times out and behaves as
611
+ * before).
612
+ */
613
+ export interface RolloutStatusEditedEvent {
614
+ type: "rollout_status_edited";
615
+ requestId: string;
616
+ /** True when the live card now carries the requested body. */
617
+ ok: boolean;
618
+ /**
619
+ * True IFF the failure means the target message no longer exists / can no
620
+ * longer be edited. ONLY this justifies a re-post — a transient failure
621
+ * (429 past retries, network) leaves a perfectly good card in the chat.
622
+ */
623
+ gone?: boolean;
624
+ /** Diagnostic detail on failure. */
625
+ reason?: string;
626
+ }
627
+
600
628
  /**
601
629
  * Issue #2971 — sent by the wedge-watchdog (autoaccept-poll sidecar) over
602
630
  * the gateway UDS, BEFORE it would otherwise Esc a shape-persistent
@@ -152,12 +152,14 @@ export interface IpcServerOptions {
152
152
  ) => Promise<void>;
153
153
  /**
154
154
  * #2726 Part 2 — hostd asks the gateway to EDIT a previously-posted rollout
155
- * status message as later phases arrive. Best-effort, no reply. Optional.
155
+ * status message as later phases arrive. Optional. #4065: the handler replies
156
+ * with a `rollout_status_edited` outcome so hostd can tell an applied edit
157
+ * from an edit into a deleted card; the roll never blocks on that reply.
156
158
  */
157
159
  onRolloutStatusEdit?: (
158
160
  client: IpcClient,
159
161
  msg: RolloutStatusEditMessage,
160
- ) => void;
162
+ ) => void | Promise<void>;
161
163
  log?: (msg: string) => void;
162
164
  /**
163
165
  * How long (in ms) to wait without a heartbeat before force-closing the
@@ -813,14 +815,21 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
813
815
  case "rollout_status_edit":
814
816
  if (onRolloutStatusEdit) {
815
817
  try {
816
- onRolloutStatusEdit(client, msg as RolloutStatusEditMessage);
818
+ void Promise.resolve(
819
+ onRolloutStatusEdit(client, msg as RolloutStatusEditMessage),
820
+ ).catch((err) => {
821
+ log(
822
+ `rollout_status_edit handler rejected (client=${client.id}): ${(err as Error).message}`,
823
+ );
824
+ });
817
825
  } catch (err) {
818
826
  log(
819
827
  `rollout_status_edit handler threw (client=${client.id}): ${(err as Error).message}`,
820
828
  );
821
829
  }
822
830
  }
823
- // Fire-and-forget; no reply.
831
+ // The handler replies `rollout_status_edited` (#4065) when it can; an
832
+ // unwired gateway sends nothing and hostd's bounded wait expires.
824
833
  break;
825
834
  case "update_placeholder":
826
835
  // Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
@@ -43,6 +43,11 @@ export interface ObligationStoreFsSeam {
43
43
  * entry lives in the parent's own cached metadata and is otherwise lost
44
44
  * in a power cut. */
45
45
  fsyncDirSync: (path: string) => void
46
+ /** #4146: remove a snapshot this process will not maintain. Optional so any
47
+ * existing seam (tests, other callers) keeps compiling and keeps working —
48
+ * an absent `unlinkSync` degrades to "no cleanup", which is what those
49
+ * callers do today. */
50
+ unlinkSync?: (path: string) => void
46
51
  }
47
52
 
48
53
  interface SnapshotEnvelope {
@@ -126,6 +131,56 @@ export function loadObligations(path: string, fs: ObligationStoreFsSeam): Obliga
126
131
  return env.obligations.filter(isObligationRow).map(sanitizeCapturedDelivery)
127
132
  }
128
133
 
134
+ /**
135
+ * #4146 — drop a snapshot this process is NOT going to maintain.
136
+ *
137
+ * When the gateway is STATIC or `SWITCHROOM_OBLIGATION_LEDGER=0`, the ledger's
138
+ * `onChange` persister is never wired, so obligations open and close purely in
139
+ * memory while any previously-written `obligations.json` sits on disk
140
+ * unchanged. That leftover is not harmless: the W1-d audience classifier
141
+ * (`hooks/audience-classify.mjs`) reads this exact file to decide whether
142
+ * anyone is waiting on a chat, and an empty-but-stale open set reads there as
143
+ * POSITIVE evidence that nobody is — which is the one input that can make the
144
+ * classifier suppress a message owed to a human.
145
+ *
146
+ * Removing the file restores that classifier's stated invariant ("absent ⇒
147
+ * unknown ⇒ deliver") deterministically, with no freshness heuristic and no
148
+ * clock. Note this is safe precisely because the snapshot has exactly one
149
+ * writer: with the ledger ON, a merely-stopped gateway leaves a snapshot that
150
+ * is accurate rather than stale, so nothing is lost by only cleaning up in the
151
+ * modes where persistence is disabled.
152
+ *
153
+ * Best-effort and never throws — a failure degrades to the reader-side
154
+ * ledger-off guard in the hook.
155
+ *
156
+ * @returns `true` iff a file was actually removed.
157
+ */
158
+ export function removeUnmaintainedSnapshot(
159
+ path: string,
160
+ fs: ObligationStoreFsSeam,
161
+ reason = 'persistence disabled',
162
+ log: (line: string) => void = (l) => process.stderr.write(l),
163
+ ): boolean {
164
+ const unlink = fs.unlinkSync
165
+ if (unlink == null) return false
166
+ try {
167
+ if (!fs.existsSync(path)) return false
168
+ unlink(path)
169
+ log(
170
+ `obligation-store: persistence is off (${reason}) — removed unmaintained ` +
171
+ `snapshot path=${path} so it cannot be read as "nobody is waiting" (#4146)\n`,
172
+ )
173
+ return true
174
+ } catch (err) {
175
+ log(
176
+ `obligation-store: could not remove unmaintained snapshot path=${path}: ` +
177
+ `${(err as Error).message} — audience classification falls back to the ` +
178
+ `reader-side ledger-off guard (#4146)\n`,
179
+ )
180
+ return false
181
+ }
182
+ }
183
+
129
184
  /**
130
185
  * Persist the open set atomically (write sibling tmp → rename over the real
131
186
  * path). Best-effort relative to fs availability: a write failure is logged but