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
@@ -397,20 +397,166 @@ describe('stripExcessBold', () => {
397
397
  expect(stripExcessBold(input)).toBe(input)
398
398
  })
399
399
 
400
- test('48/49-char pseudo-heading boundary honoured under global strip', () => {
401
- // Heading length is measured WITH the `**` markers. 44 inner chars → 48
402
- // total (exempt); 45 inner chars → 49 total (stripped).
403
- const heading48 = '**' + 'H'.repeat(44) + '**' // length 48
404
- const heading49 = '**' + 'H'.repeat(45) + '**' // length 49
405
- expect(heading48.length).toBe(48)
406
- expect(heading49.length).toBe(49)
407
-
408
- const out48 = stripExcessBold(`${heading48}\n\n${boldDenseBody}`)
409
- expect(out48).toContain(heading48)
410
-
411
- const out49 = stripExcessBold(`${heading49}\n\n${boldDenseBody}`)
412
- expect(out49).not.toContain(heading49)
413
- expect(out49).toContain('H'.repeat(45))
400
+ test('64/65-char pseudo-heading boundary honoured under global strip', () => {
401
+ // Heading length is measured WITH the `**` markers. 60 inner chars → 64
402
+ // total (exempt); 61 inner chars → 65 total (stripped).
403
+ const heading64 = '**' + 'H'.repeat(60) + '**' // length 64
404
+ const heading65 = '**' + 'H'.repeat(61) + '**' // length 65
405
+ expect(heading64.length).toBe(64)
406
+ expect(heading65.length).toBe(65)
407
+
408
+ const out64 = stripExcessBold(`${heading64}\n\n${boldDenseBody}`)
409
+ expect(out64).toContain(heading64)
410
+
411
+ const out65 = stripExcessBold(`${heading65}\n\n${boldDenseBody}`)
412
+ expect(out65).not.toContain(heading65)
413
+ expect(out65).toContain('H'.repeat(61))
414
+ })
415
+
416
+ // ── #4021: long-but-legitimate bold section labels survive ───────────────
417
+ // At PSEUDO_HEADING_MAX_CHARS=48 a real section label of 50-63 chars was
418
+ // read as an over-bolded paragraph and flattened. The cap is 64.
419
+
420
+ test.each([
421
+ // [label, survives?]
422
+ ['**Deployment status across all three regions:**', true], // 46
423
+ ['**Deployment status across all three AWS regions:**', true], // 50
424
+ ['**What changed in the delivery path since last Tuesday:**', true], // 56
425
+ ['**What actually changed in the delivery path since Tue:**', true], // 56
426
+ ['**Everything that changed across the delivery path this week:**', true], // 62
427
+ ['**Everything that changed across the whole delivery path today:**', false], // 65
428
+ ])('bold section label %s survives=%s under global strip', (label, survives) => {
429
+ // Sanity: the fixtures must straddle the 64-char boundary.
430
+ expect(label.length <= 64).toBe(survives)
431
+
432
+ const out = stripExcessBold(`${label}\n\n${boldDenseBody}`)
433
+ const inner = label.slice(2, -2)
434
+ if (survives) {
435
+ expect(out).toContain(label)
436
+ } else {
437
+ expect(out).not.toContain(label)
438
+ expect(out).toContain(inner)
439
+ }
440
+ // Either way the body is still flattened — the ratio guard is intact.
441
+ expect(out).not.toContain('**alpha**')
442
+ })
443
+
444
+ // ── #4017: a message that is ALL pseudo-headings still strips ────────────
445
+ // Every block exempted meant `out === masked`: a 100%-bold digest stayed
446
+ // fully bold and onStrip never fired.
447
+
448
+ test('all-pseudo-heading message strips (no body to contrast against)', () => {
449
+ const input = [
450
+ '**Deploy status**',
451
+ '**Open incidents**',
452
+ '**Merged today**',
453
+ '**Blocked on review**',
454
+ '**Rollout window tomorrow**',
455
+ '**Next steps:**',
456
+ ].join('\n\n')
457
+ expect(input.length).toBeGreaterThan(100)
458
+
459
+ const calls: Array<{ rule: string; ratio: number }> = []
460
+ const out = stripExcessBold(input, (d) => calls.push(d))
461
+
462
+ expect(out).not.toContain('**')
463
+ expect(out).toContain('Deploy status')
464
+ expect(out).toContain('Next steps:')
465
+ // Blank-line gaps between the headings are preserved.
466
+ expect(out).toBe(input.replace(/\*\*/g, ''))
467
+ // …and the strip is logged.
468
+ expect(calls).toHaveLength(1)
469
+ expect(calls[0].rule).toBe('global')
470
+ expect(calls[0].ratio).toBeGreaterThan(0.3)
471
+ })
472
+
473
+ test.each([
474
+ [
475
+ 'all headings, no body → strip',
476
+ `**Deploy status**\n\n**Open incidents this morning**\n\n**Merged today since the last release**\n\n**Next steps:**`,
477
+ false,
478
+ ],
479
+ [
480
+ 'headings + bold-dense body → headings survive',
481
+ `**Deploy status**\n\n${boldDenseBody}`,
482
+ true,
483
+ ],
484
+ [
485
+ 'headings around a bold-dense body → headings survive',
486
+ `**Deploy status**\n\n**Open incidents**\n\n${boldDenseBody}\n\n**Next steps:**`,
487
+ true,
488
+ ],
489
+ ])('all-heading guard: %s', (_name, input, headingSurvives) => {
490
+ expect(input.length).toBeGreaterThan(100)
491
+ const out = stripExcessBold(input)
492
+ expect(out.includes('**Deploy status**')).toBe(headingSurvives)
493
+ expect(out).toContain('Deploy status')
494
+ })
495
+
496
+ // ── #4114: a code-only block is NOT body for the all-heading guard ───────
497
+ // #4108's short-circuit tested `blocks.every(isPseudoHeadingBlock)` on every
498
+ // non-blank block. A masked code block satisfies neither side, so ONE code
499
+ // fence in an all-headings digest flipped `every(...)` to false, re-exempted
500
+ // every heading, and reproduced #4017 exactly: 100% bold, onStrip silent.
501
+ // Decision pinned here: masked code is not body — it is invisible to every
502
+ // other measurement in stripExcessBold (it is stripped before the ratio is
503
+ // taken), so it does not buy the headings an exemption either.
504
+
505
+ const HEADINGS = ['**Deploy status**', '**Open incidents**', '**Merged today**']
506
+ const TAIL = ['**Blocked on review**', '**Rollout window tomorrow**', '**Next steps:**']
507
+
508
+ test.each([
509
+ ['fenced code block', '```\nswitchroom agent restart klanker\n```'],
510
+ ['inline code span', '`switchroom agent restart klanker`'],
511
+ ['fenced block with a language tag', '```bash\nswitchroom agent restart klanker\n```'],
512
+ ['several inline spans on one line', '`alpha` `beta` `gamma` `delta` `epsilon`'],
513
+ ])('all-heading guard: headings + a code-only block (%s) still strips', (_name, code) => {
514
+ const input = [...HEADINGS, code, ...TAIL].join('\n\n')
515
+ expect(input.length).toBeGreaterThan(100)
516
+
517
+ const calls: Array<{ rule: string; ratio: number }> = []
518
+ const out = stripExcessBold(input, (d) => calls.push(d))
519
+
520
+ // Outcome 1: no bold survives — this is the assertion that fails on the
521
+ // real bug, where `out` was the input verbatim.
522
+ expect(out).not.toContain('**')
523
+ expect(out).toContain('Deploy status')
524
+ expect(out).toContain('Next steps:')
525
+ // Outcome 2: the code block itself is preserved byte-for-byte.
526
+ expect(out).toContain(code)
527
+ expect(out).toBe(input.replace(/\*\*/g, ''))
528
+ // Outcome 3: the strip is logged (the #4017 silence is what made this
529
+ // class of bug invisible in the first place).
530
+ expect(calls).toHaveLength(1)
531
+ expect(calls[0].rule).toBe('global')
532
+ expect(calls[0].ratio).toBeGreaterThan(0.3)
533
+ })
534
+
535
+ test('all-heading guard: real prose body CONTAINING inline code keeps headings', () => {
536
+ // The counterpart that stops the #4114 fix from over-correcting: a block
537
+ // with visible text around its code span IS body, so the headings keep
538
+ // their bold. Only blocks that are *nothing but* code are skipped.
539
+ const input = [
540
+ ...HEADINGS,
541
+ 'Run `switchroom agent restart klanker` now, then check the gateway log for errors.',
542
+ ...TAIL,
543
+ ].join('\n\n')
544
+
545
+ const calls: Array<{ rule: string; ratio: number }> = []
546
+ const out = stripExcessBold(input, (d) => calls.push(d))
547
+
548
+ expect(out).toContain('**Deploy status**')
549
+ expect(out).toContain('**Next steps:**')
550
+ expect(calls).toHaveLength(0)
551
+ })
552
+
553
+ test('all-heading guard: a code-only block does not rescue a headings-only digest', () => {
554
+ // Degenerate shape: the code block is the ONLY non-heading block and sits
555
+ // last. Same verdict, so the rule does not depend on block position.
556
+ const input = [...HEADINGS, ...TAIL, '```\nok\n```'].join('\n\n')
557
+ const out = stripExcessBold(input)
558
+ expect(out).not.toContain('**')
559
+ expect(out).toContain('```\nok\n```')
414
560
  })
415
561
 
416
562
  test('multi-line fully-bolded block is NOT mislabelled a heading (global)', () => {
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import {
6
+ createHandbackOrphanRecovery,
7
+ formatOrphanEscalation,
8
+ } from '../gateway/handback-orphan-recovery.js'
9
+ import type { HandbackOrphanEscalation, PreTurnCardRecord } from '../gateway/handback-preturn-signal.js'
10
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
11
+
12
+ /**
13
+ * Unit test for the gateway-side EFFECTS of deterministic handback-orphan
14
+ * recovery, extracted out of gateway.ts (switchroom#2996 ratchet). The seam's
15
+ * DECISION logic is covered by `handback-preturn-signal.test.ts`; this file
16
+ * asserts the three effects behave as the seam's contract requires:
17
+ *
18
+ * - a genuine orphan's card is DELETED (never edited to a user-facing string)
19
+ * - a delete failure is swallowed, so a reap is never wedged by a Telegram error
20
+ * - the handback is re-injected through the pending-inbound buffer verbatim
21
+ * - retry exhaustion emits greppable telemetry, NOT a chat message
22
+ *
23
+ * Uses injected spies + an injected `writeLog`, so it runs identically under
24
+ * vitest and bun (no fake timers, no module mocks).
25
+ */
26
+
27
+ const record: PreTurnCardRecord = {
28
+ turnKey: 'preturn:chatA:_:1000',
29
+ chatId: 'chatA',
30
+ threadId: 7,
31
+ activityMessageId: 4242,
32
+ startedAt: 1000,
33
+ pinned: false,
34
+ }
35
+
36
+ const escalation: HandbackOrphanEscalation = {
37
+ statusKey: 'chatA:7',
38
+ chatId: 'chatA',
39
+ threadId: 7,
40
+ adoptTurnId: 'turn-abc',
41
+ reinjectCount: 2,
42
+ ageMs: 90_000,
43
+ }
44
+
45
+ function inbound(): InboundMessage {
46
+ return {
47
+ type: 'inbound',
48
+ chatId: 'chatA',
49
+ threadId: 7,
50
+ messageId: 900,
51
+ text: 'worker finished',
52
+ meta: { source: 'subagent_handback', handbackReinjectCount: '1' },
53
+ } as unknown as InboundMessage
54
+ }
55
+
56
+ describe('handback orphan recovery effects', () => {
57
+ it('deletes the frozen pre-turn card with its chat/message/thread identity', async () => {
58
+ const deleteMessage = vi.fn(async () => ({ ok: true }))
59
+ const r = createHandbackOrphanRecovery({
60
+ deleteMessage,
61
+ pushInbound: vi.fn(),
62
+ writeLog: vi.fn(),
63
+ })
64
+
65
+ await r.deleteCard(record)
66
+
67
+ expect(deleteMessage).toHaveBeenCalledTimes(1)
68
+ expect(deleteMessage).toHaveBeenCalledWith('chatA', 4242, 7)
69
+ })
70
+
71
+ it('swallows a delete failure so the reap is never wedged by a Telegram error', async () => {
72
+ const deleteMessage = vi.fn(async () => {
73
+ throw new Error('message to delete not found')
74
+ })
75
+ const r = createHandbackOrphanRecovery({
76
+ deleteMessage,
77
+ pushInbound: vi.fn(),
78
+ writeLog: vi.fn(),
79
+ })
80
+
81
+ await expect(r.deleteCard(record)).resolves.toBeUndefined()
82
+ })
83
+
84
+ it('re-injects the orphaned handback verbatim through the pending-inbound buffer', () => {
85
+ const pushInbound = vi.fn()
86
+ const r = createHandbackOrphanRecovery({
87
+ deleteMessage: vi.fn(async () => ({})),
88
+ pushInbound,
89
+ writeLog: vi.fn(),
90
+ })
91
+
92
+ const msg = inbound()
93
+ r.reinjectHandback(msg)
94
+
95
+ expect(pushInbound).toHaveBeenCalledTimes(1)
96
+ // Same object identity: the seam already stamped the retry counter on
97
+ // `meta`, and this effect must not rebuild or strip the inbound.
98
+ expect(pushInbound.mock.calls[0]?.[0]).toBe(msg)
99
+ })
100
+
101
+ it('escalates exhausted retries to telemetry with the fleet-health join keys, not to chat', () => {
102
+ const writeLog = vi.fn()
103
+ const pushInbound = vi.fn()
104
+ const deleteMessage = vi.fn(async () => ({}))
105
+ const r = createHandbackOrphanRecovery({ deleteMessage, pushInbound, writeLog })
106
+
107
+ r.escalateOrphan(escalation)
108
+
109
+ expect(writeLog).toHaveBeenCalledTimes(1)
110
+ const line = writeLog.mock.calls[0]?.[0] as string
111
+ expect(line).toContain('handback orphan escalation')
112
+ expect(line).toContain('key=chatA:7')
113
+ expect(line).toContain('turnId=turn-abc')
114
+ expect(line).toContain('reinjects=2')
115
+ expect(line).toContain('ageMs=90000')
116
+ expect(line.endsWith('\n')).toBe(true)
117
+ // Escalation is telemetry-only: it must not touch any chat transport.
118
+ expect(deleteMessage).not.toHaveBeenCalled()
119
+ expect(pushInbound).not.toHaveBeenCalled()
120
+ })
121
+
122
+ it('formatOrphanEscalation is a single line (one log record, not a multi-line blob)', () => {
123
+ const line = formatOrphanEscalation(escalation)
124
+ expect(line.trimEnd().split('\n')).toHaveLength(1)
125
+ })
126
+
127
+ it('the operator-facing "needs a nudge" string is gone from the recovery module', () => {
128
+ const src = readFileSync(new URL('../gateway/handback-orphan-recovery.ts', import.meta.url), 'utf8')
129
+ expect(src).not.toContain('it may need a nudge')
130
+ })
131
+ })
@@ -1,9 +1,13 @@
1
+ import { readFileSync } from 'node:fs'
2
+
1
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
4
 
3
5
  import {
4
6
  createHandbackPreturnSignal,
5
7
  isHandbackInbound,
8
+ HANDBACK_REINJECT_COUNT_META_KEY,
6
9
  PRETURN_TURNKEY_PREFIX,
10
+ type HandbackOrphanEscalation,
7
11
  type HandbackPreturnSignalDeps,
8
12
  type PreTurnCardRecord,
9
13
  } from '../gateway/handback-preturn-signal.js'
@@ -136,9 +140,13 @@ interface Harness {
136
140
  sched: ReturnType<typeof makeScheduler>
137
141
  sendChatAction: ReturnType<typeof vi.fn>
138
142
  openCard: ReturnType<typeof vi.fn>
139
- finalizeCard: ReturnType<typeof vi.fn>
143
+ deleteCard: ReturnType<typeof vi.fn>
144
+ reinjectHandback: ReturnType<typeof vi.fn>
145
+ escalateOrphan: ReturnType<typeof vi.fn>
140
146
  records: Map<string, PreTurnCardRecord>
141
- finalizedIds: number[]
147
+ deletedIds: number[]
148
+ reinjected: InboundMessage[]
149
+ escalations: HandbackOrphanEscalation[]
142
150
  }
143
151
 
144
152
  const harnesses: Harness[] = []
@@ -168,9 +176,19 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
168
176
  if (cur != null && cur.activityMessageId === activityMessageId) records.delete(turnKey)
169
177
  }
170
178
 
171
- const finalizedIds: number[] = []
172
- const finalizeCard = vi.fn<(r: PreTurnCardRecord) => void>((r) => {
173
- finalizedIds.push(r.activityMessageId)
179
+ const deletedIds: number[] = []
180
+ const deleteCard = vi.fn<(r: PreTurnCardRecord) => void>((r) => {
181
+ deletedIds.push(r.activityMessageId)
182
+ })
183
+
184
+ const reinjected: InboundMessage[] = []
185
+ const reinjectHandback = vi.fn<(m: InboundMessage) => void>((m) => {
186
+ reinjected.push(m)
187
+ })
188
+
189
+ const escalations: HandbackOrphanEscalation[] = []
190
+ const escalateOrphan = vi.fn<(e: HandbackOrphanEscalation) => void>((e) => {
191
+ escalations.push(e)
174
192
  })
175
193
 
176
194
  const signal = createHandbackPreturnSignal({
@@ -179,7 +197,9 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
179
197
  startTypingLoop: (c, t) => typing.start(c, t),
180
198
  stopTypingLoop: (c, t) => typing.stop(c, t),
181
199
  openCard,
182
- finalizeCard,
200
+ deleteCard,
201
+ reinjectHandback,
202
+ escalateOrphan,
183
203
  writeCardRecord,
184
204
  clearCardRecord,
185
205
  now: sched.now,
@@ -187,10 +207,28 @@ function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harnes
187
207
  clearTimer: sched.clearTimer,
188
208
  debounceMs: 700,
189
209
  adoptTimeoutMs: 30_000,
210
+ // Default the delivered-window TTL to 0 so tests that exercise the reap
211
+ // MECHANICS (idle-orphan, #3544 typing, isClaudeBusy gate) see the reap fire
212
+ // at the adopt timeout exactly as before this fix. Tests that specifically
213
+ // assert the delivered-but-not-yet-streaming deferral opt into a real TTL.
214
+ deliveredTtlMs: 0,
190
215
  ...overrides,
191
216
  })
192
217
 
193
- const h: Harness = { signal, typing, sched, sendChatAction, openCard, finalizeCard, records, finalizedIds }
218
+ const h: Harness = {
219
+ signal,
220
+ typing,
221
+ sched,
222
+ sendChatAction,
223
+ openCard,
224
+ deleteCard,
225
+ reinjectHandback,
226
+ escalateOrphan,
227
+ records,
228
+ deletedIds,
229
+ reinjected,
230
+ escalations,
231
+ }
194
232
  harnesses.push(h)
195
233
  return h
196
234
  }
@@ -283,7 +321,7 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
283
321
  expect(h.signal.pendingCount()).toBe(0)
284
322
  })
285
323
 
286
- it('never-adopted handback self-reaps its frozen card past the TTL', async () => {
324
+ it('never-adopted handback self-reaps: DELETES the frozen card and re-injects (deterministic recovery, no nudge)', async () => {
287
325
  const h = makeHarness()
288
326
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatD', messageId: 77 }))
289
327
  await h.sched.advance(700)
@@ -291,10 +329,15 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
291
329
  expect(h.typing.activeCount()).toBe(1)
292
330
  expect(h.records.size).toBe(1)
293
331
 
294
- // No enqueue ever arrives. Past the adopt TTL the orphan self-reaps.
332
+ // No enqueue ever arrives. Past the adopt TTL the orphan self-reaps into
333
+ // deterministic recovery: the frozen card is DELETED (never rewritten to a
334
+ // user-facing "needs a nudge" string) and the handback is re-injected.
295
335
  await h.sched.advance(30_000)
296
336
 
297
- expect(h.finalizedIds).toEqual([resolvedId]) // finalized exactly THAT card
337
+ expect(h.deletedIds).toEqual([resolvedId]) // deleted exactly THAT card
338
+ expect(h.reinjected).toHaveLength(1) // re-injected for the machine to recover itself
339
+ expect(h.reinjected[0]!.meta[HANDBACK_REINJECT_COUNT_META_KEY]).toBe('1')
340
+ expect(h.escalations).toEqual([]) // first orphan → recover, do NOT escalate
298
341
  expect(h.typing.activeCount()).toBe(0) // typing stopped
299
342
  expect(h.records.size).toBe(0) // durable record cleared (at-most-once)
300
343
  expect(h.signal.pendingCount()).toBe(0) // map empty
@@ -326,18 +369,20 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
326
369
  // DEFER each time: no false orphan card, entry still live, typing still lit.
327
370
  await h.sched.advance(30_000)
328
371
  await h.sched.advance(30_000)
329
- expect(h.finalizedIds).toEqual([]) // NEVER posted the "never started" nudge
372
+ expect(h.deletedIds).toEqual([]) // NEVER reaped: card intact
373
+ expect(h.reinjected).toEqual([]) // NEVER re-injected while enqueued
330
374
  expect(h.signal.pendingCount()).toBe(1) // still queued, not reaped
331
375
  expect(h.typing.activeCount()).toBe(1) // indicator kept while queued
332
376
 
333
377
  // The parent turn finally ends and the handback mints its own turn: it
334
- // adopts the SAME card (an edit, not a second send / not an orphan finalize).
378
+ // adopts the SAME card (an edit, not a second send / not an orphan reap).
335
379
  busy = false
336
380
  const turnId = deriveTurnId('chatBusy', null, 700)!
337
381
  const adoption = h.signal.tryAdopt(turnId)
338
382
  expect(adoption).not.toBeNull()
339
383
  expect(adoption!.activityMessageId).toBe(resolvedId)
340
- expect(h.finalizedIds).toEqual([]) // adopted, never finalized as orphan
384
+ expect(h.deletedIds).toEqual([]) // adopted, never reaped as orphan
385
+ expect(h.reinjected).toEqual([])
341
386
  expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
342
387
  expect(h.signal.pendingCount()).toBe(0)
343
388
  })
@@ -345,7 +390,7 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
345
390
  // The deferred reap is not a leak: once claude goes idle and the handback has
346
391
  // STILL not been adopted (genuine bridge-death / dropped inbound), the re-armed
347
392
  // reap finalizes honestly — the degenerate case the reap exists for.
348
- it('finalizes a genuine orphan once claude goes idle, even after deferring while busy', async () => {
393
+ it('recovers a genuine orphan once claude goes idle, even after deferring while busy', async () => {
349
394
  let busy = true
350
395
  const h = makeHarness({ isClaudeBusy: () => busy })
351
396
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatIdleOrphan', messageId: 710 }))
@@ -354,19 +399,169 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
354
399
 
355
400
  // Busy: the first reap defers.
356
401
  await h.sched.advance(30_000)
357
- expect(h.finalizedIds).toEqual([])
402
+ expect(h.deletedIds).toEqual([])
403
+ expect(h.reinjected).toEqual([])
358
404
  expect(h.signal.pendingCount()).toBe(1)
359
405
 
360
406
  // Claude goes idle but NO adopting turn ever mints (the inbound was dropped).
361
- // The re-armed reap now finalizes the genuine orphan.
407
+ // The re-armed reap now recovers the genuine orphan: delete + re-inject.
362
408
  busy = false
363
409
  await h.sched.advance(30_000)
364
- expect(h.finalizedIds).toEqual([resolvedId])
410
+ expect(h.deletedIds).toEqual([resolvedId])
411
+ expect(h.reinjected).toHaveLength(1)
365
412
  expect(h.typing.activeCount()).toBe(0)
366
413
  expect(h.records.size).toBe(0)
367
414
  expect(h.signal.pendingCount()).toBe(0)
368
415
  })
369
416
 
417
+ // ── #4027 coverage gap: delivered-but-not-yet-streaming is NOT an orphan ──
418
+ // A handback that WAS delivered into claude's queue but whose adopting turn
419
+ // has not started streaming yet reads as idle (`isClaudeBusy()` false) + un-
420
+ // adopted — which #4027 still finalized as a false "needs a nudge" card. The
421
+ // `deliveredAwaitingTurnStart` gate DEFERS the reap for the whole delivered
422
+ // TTL. This test FAILS on revert of the gate: without it the reap fires at the
423
+ // 30 s adopt timeout (idle, un-adopted) and reaps a healthy delivery.
424
+ it('does NOT reap a delivered-but-not-yet-streaming handback within the delivered TTL', async () => {
425
+ // Real delivered TTL, no busy signal — the ONLY thing that can defer the
426
+ // reap here is the delivered-window gate.
427
+ const h = makeHarness({ deliveredTtlMs: 300_000 })
428
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatDeliv', messageId: 800 }))
429
+ await h.sched.advance(700)
430
+ const resolvedId = await h.openCard.mock.results[0]!.value
431
+ expect(h.records.size).toBe(1)
432
+
433
+ // Well past the 30 s adopt timeout but comfortably within the 5 min TTL: the
434
+ // reap must DEFER every tick — no delete, no re-inject, entry still live,
435
+ // typing still lit. (Pre-fix this fired a false orphan at 30 s.)
436
+ await h.sched.advance(120_000)
437
+ expect(h.deletedIds).toEqual([])
438
+ expect(h.reinjected).toEqual([])
439
+ expect(h.escalations).toEqual([])
440
+ expect(h.signal.pendingCount()).toBe(1)
441
+ expect(h.typing.activeCount()).toBe(1)
442
+
443
+ // The turn finally starts streaming and adopts the SAME card — an edit, not
444
+ // an orphan reap, and no double-send.
445
+ const turnId = deriveTurnId('chatDeliv', null, 800)!
446
+ const adoption = h.signal.tryAdopt(turnId)
447
+ expect(adoption).not.toBeNull()
448
+ expect(adoption!.activityMessageId).toBe(resolvedId)
449
+ expect(h.deletedIds).toEqual([])
450
+ expect(h.reinjected).toEqual([])
451
+ expect(h.openCard).toHaveBeenCalledTimes(1)
452
+ expect(h.signal.pendingCount()).toBe(0)
453
+ })
454
+
455
+ it('reaps a delivered handback ONCE the delivered TTL elapses (deterministic recovery, still no nudge)', async () => {
456
+ const h = makeHarness({ deliveredTtlMs: 300_000 })
457
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatStuck', messageId: 801 }))
458
+ await h.sched.advance(700)
459
+ const resolvedId = await h.openCard.mock.results[0]!.value
460
+
461
+ // Within the TTL: deferred.
462
+ await h.sched.advance(120_000)
463
+ expect(h.deletedIds).toEqual([])
464
+
465
+ // Past the TTL a truly-stuck delivery finally reaps into recovery rather than
466
+ // hanging forever — delete + re-inject, never a nudge card.
467
+ await h.sched.advance(210_000)
468
+ expect(h.deletedIds).toEqual([resolvedId])
469
+ expect(h.reinjected).toHaveLength(1)
470
+ expect(h.escalations).toEqual([])
471
+ expect(h.signal.pendingCount()).toBe(0)
472
+ })
473
+
474
+ // ── deterministic-recovery retry cap: re-inject up to 2×, THEN escalate ──
475
+ // A genuine orphan whose re-injections keep coming back orphaned must not loop
476
+ // forever, and must NEVER fall back to a chat nudge. After `maxReinjects` it
477
+ // escalates to fleet-health telemetry (the `escalateOrphan` sink). The retry
478
+ // counter rides `HANDBACK_REINJECT_COUNT_META_KEY` on the inbound across the
479
+ // buffer round trip, so the cap survives the drop-and-recreate of the entry.
480
+ it('re-injects a persistently-orphaned handback up to the cap, then escalates (never a nudge)', async () => {
481
+ const sched = makeScheduler()
482
+ const sendChatAction = vi.fn<(c: string, t: number | null) => void>()
483
+ const typing = createTurnTypingLoop({
484
+ sendChatAction,
485
+ chatKey: (c, t) => chatKey(c, t),
486
+ refreshMs: 4000,
487
+ })
488
+ let msgSeq = 5000
489
+ const deletedIds: number[] = []
490
+ const reinjectedCounts: (string | undefined)[] = []
491
+ const escalations: HandbackOrphanEscalation[] = []
492
+ const records = new Map<string, PreTurnCardRecord>()
493
+
494
+ // eslint-disable-next-line prefer-const
495
+ let signal: ReturnType<typeof createHandbackPreturnSignal>
496
+ signal = createHandbackPreturnSignal({
497
+ chatKey: (c, t) => chatKey(c, t),
498
+ deriveTurnId,
499
+ startTypingLoop: (c, t) => typing.start(c, t),
500
+ stopTypingLoop: (c, t) => typing.stop(c, t),
501
+ openCard: async () => ++msgSeq,
502
+ deleteCard: (r) => {
503
+ deletedIds.push(r.activityMessageId)
504
+ },
505
+ // Simulate the buffer round trip: a re-injected orphan comes straight back
506
+ // through the release chokepoint carrying its stamped retry counter.
507
+ reinjectHandback: (m) => {
508
+ reinjectedCounts.push(m.meta[HANDBACK_REINJECT_COUNT_META_KEY])
509
+ signal.noteHandbackRelease(m)
510
+ },
511
+ escalateOrphan: (e) => {
512
+ escalations.push(e)
513
+ },
514
+ writeCardRecord: (r) => {
515
+ records.set(r.turnKey, r)
516
+ },
517
+ clearCardRecord: (turnKey, id) => {
518
+ const cur = records.get(turnKey)
519
+ if (cur != null && cur.activityMessageId === id) records.delete(turnKey)
520
+ },
521
+ now: sched.now,
522
+ setTimer: sched.setTimer,
523
+ clearTimer: sched.clearTimer,
524
+ debounceMs: 700,
525
+ adoptTimeoutMs: 30_000,
526
+ deliveredTtlMs: 0, // no delivered-window deferral: exercise the retry cap directly
527
+ maxReinjects: 2,
528
+ })
529
+
530
+ try {
531
+ signal.noteHandbackRelease(handbackInbound({ chatId: 'chatCap', messageId: 802 }))
532
+ // Drive several reap cycles: each orphan deletes its card, re-injects (up
533
+ // to the cap), which re-arms the seam via the simulated round trip.
534
+ await sched.advance(200_000)
535
+
536
+ // Exactly 2 re-injections, stamped 1 then 2, then a single escalation.
537
+ expect(reinjectedCounts).toEqual(['1', '2'])
538
+ expect(escalations).toHaveLength(1)
539
+ expect(escalations[0]!.reinjectCount).toBe(2)
540
+ // One card deleted per orphan cycle (initial + 2 re-injected), never edited
541
+ // to a "needs a nudge" string.
542
+ expect(deletedIds).toHaveLength(3)
543
+ expect(signal.pendingCount()).toBe(0)
544
+ } finally {
545
+ typing.stopAll()
546
+ }
547
+ })
548
+
549
+ // ── the user-facing "needs a nudge" string is GONE from the gateway source ──
550
+ // Part A of the fix removed the operator-facing card text entirely; the orphan
551
+ // path now deletes + re-injects. This is a durable regression guard: it FAILS
552
+ // if the string is reintroduced anywhere in the gateway source.
553
+ it('the "may need a nudge" user-facing string is absent from the gateway source', () => {
554
+ const gatewaySrc = readFileSync(
555
+ new URL('../gateway/gateway.ts', import.meta.url),
556
+ 'utf8',
557
+ )
558
+ // The literal user-facing phrase must not appear as a rendered string. (The
559
+ // only surviving mentions are historical CODE COMMENTS explaining the removal
560
+ // — assert the load-bearing card fragment is gone from any send/edit call.)
561
+ expect(gatewaySrc).not.toContain('it may need a nudge')
562
+ expect(gatewaySrc).not.toContain('HANDBACK_PRETURN_ORPHAN_HTML')
563
+ })
564
+
370
565
  it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
371
566
  const h = makeHarness()
372
567
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))