switchroom 0.19.19 → 0.19.23

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +121 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +2995 -1583
  5. package/dist/host-control/main.js +122 -2
  6. package/dist/vault/approvals/kernel-server.js +124 -4
  7. package/dist/vault/broker/server.js +124 -4
  8. package/package.json +7 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +13 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +90 -107
  17. package/skills/switchroom-release/SKILL.md +103 -20
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/card-format.ts +92 -3
  20. package/telegram-plugin/dist/gateway/gateway.js +873 -184
  21. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  22. package/telegram-plugin/format.ts +19 -7
  23. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  24. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  26. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  27. package/telegram-plugin/gateway/gateway.ts +75 -63
  28. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  29. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  30. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  31. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  32. package/telegram-plugin/gateway/stream-render.ts +6 -0
  33. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  34. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  35. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  36. package/telegram-plugin/retry-api-call.ts +15 -2
  37. package/telegram-plugin/send-gate.ts +1 -1
  38. package/telegram-plugin/status-no-truncate.ts +64 -1
  39. package/telegram-plugin/status-pin-driver.ts +50 -27
  40. package/telegram-plugin/status-pin.ts +43 -5
  41. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  42. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  43. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  44. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  45. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  46. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  47. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  48. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  49. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  50. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  51. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  52. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  53. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  54. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  55. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  56. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  57. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  58. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  59. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  60. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  61. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  62. package/telegram-plugin/tool-activity-summary.ts +85 -13
  63. package/telegram-plugin/worker-activity-feed.ts +5 -1
  64. package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
  65. package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
  66. package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
  67. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
  76. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -10,6 +10,7 @@ import {
10
10
  } from '../gateway/backstop-delivery.js'
11
11
  import {
12
12
  backstopSendOutcomeGated,
13
+ buildTurnRecord,
13
14
  finalizeBackstopSendGated,
14
15
  computeTurnStatus,
15
16
  } from '../gateway/turn-record-status.js'
@@ -348,25 +349,29 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
348
349
  expect(ledger.hasConfirmedChunk('#drop', 0)).toBe(true)
349
350
  })
350
351
 
351
- it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, delivered=false', async () => {
352
+ it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, still DELIVERED', async () => {
352
353
  const ledger = new BackstopDeliveryLedger()
353
354
  const sendChunk = vi.fn(async () => [950])
354
355
  const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
355
356
  const res = await runBackstopDelivery(ledger, '#amb', ['answer'], null, { sendChunk, readBack }, 3)
356
357
  expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent on ambiguous
357
- expect(res.delivered).toBe(false)
358
- expect(res.exhausted).toBe(true)
358
+ // An inconclusive probe establishes nothing, so the landed-id evidence
359
+ // stands: the answer IS in the chat and the turn is not a failure.
360
+ expect(res.delivered).toBe(true)
361
+ expect(res.confirmed).toBe(false) // landed-unconfirmed, honestly reported
362
+ expect(res.exhausted).toBe(false)
359
363
  expect(ledger.hasConfirmedChunk('#amb', 0)).toBe(false)
360
364
  expect(ledger.landedUnconfirmedIndices('#amb', 1)).toEqual([0]) // still landed-unconfirmed
361
365
  })
362
366
 
363
- it('a read-back adapter THAT THROWS is treated as ambiguous — never re-sent', async () => {
367
+ it('a read-back adapter THAT THROWS is ambiguous — never re-sent, never a failure', async () => {
364
368
  const ledger = new BackstopDeliveryLedger()
365
369
  const sendChunk = vi.fn(async () => [951])
366
370
  const readBack = vi.fn(async () => { throw new Error('boom') })
367
371
  const res = await runBackstopDelivery(ledger, '#throw', ['answer'], null, { sendChunk, readBack }, 3)
368
372
  expect(sendChunk).toHaveBeenCalledTimes(1)
369
- expect(res.delivered).toBe(false)
373
+ expect(res.delivered).toBe(true)
374
+ expect(res.confirmed).toBe(false)
370
375
  })
371
376
 
372
377
  it('#3278 CORE: API-ack fresh id but read-back ABSENT ⇒ send_failed, NOT complete', async () => {
@@ -403,15 +408,207 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
403
408
  expect(ledger.hasConfirmedChunk('#noprobe', 0)).toBe(true)
404
409
  })
405
410
 
406
- it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ delivered=false, chunk 0 not re-sent', async () => {
411
+ it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ both landed ⇒ delivered, chunk 0 not re-sent', async () => {
407
412
  const ledger = new BackstopDeliveryLedger()
408
413
  const calls: number[] = []
409
414
  const sendChunk = vi.fn(async (i: number) => { calls.push(i); return [700 + i] })
410
415
  const readBack = vi.fn(async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'))
411
416
  const res = await runBackstopDelivery(ledger, '#part', ['c0', 'c1'], null, { sendChunk, readBack }, 3)
412
- expect(res.delivered).toBe(false)
417
+ expect(res.delivered).toBe(true) // every chunk landed a fresh id
418
+ expect(res.confirmed).toBe(false) // ...but chunk 1 was never corroborated
413
419
  expect(calls.filter(i => i === 0)).toHaveLength(1) // confirmed chunk never re-sent
414
420
  expect(ledger.hasConfirmedChunk('#part', 0)).toBe(true)
415
421
  expect(ledger.hasConfirmedChunk('#part', 1)).toBe(false)
416
422
  })
423
+
424
+ // ── The measurement bug this PR fixes ────────────────────────────────────
425
+ //
426
+ // The probe is issued at cosmetic priority in the same millisecond as the
427
+ // send it probes, so the per-chat token bucket (1/sec, just consumed by that
428
+ // very send) sheds it: in production it resolved `ambiguous` 146 times in two
429
+ // weeks and `absent` ZERO times. Under the old confirmation-gated verdict each
430
+ // of those became `delivered:false` → `send_failed` on a turn whose answer the
431
+ // user had received, plus an error reaction and a still-OPEN obligation.
432
+ it('AMBIGUOUS probe ⇒ turn record `complete`, NOT send_failed (the fixed bug)', async () => {
433
+ const ledger = new BackstopDeliveryLedger()
434
+ const cardId = 500
435
+ const sendChunk = vi.fn(async () => [971]) // a fresh, non-card chat id
436
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
437
+ const res = await runBackstopDelivery(ledger, '#shed', ['answer'], cardId, { sendChunk, readBack }, 3)
438
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
439
+ finalAnswerDelivered: true,
440
+ }
441
+ finalizeBackstopSendGated(turn, {
442
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
443
+ })
444
+ expect(computeTurnStatus(turn)).toBe('complete')
445
+ })
446
+
447
+ it('an ambiguous probe on a CARD-ONLY delivery is still send_failed (guard 7 holds)', async () => {
448
+ const ledger = new BackstopDeliveryLedger()
449
+ const cardId = 501
450
+ // The only id that landed IS the progress card — swept ~60-90s later, so
451
+ // the user receives nothing. Ambiguity must not rescue this.
452
+ const sendChunk = vi.fn(async () => [cardId])
453
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
454
+ const res = await runBackstopDelivery(ledger, '#cardonly', ['answer'], cardId, { sendChunk, readBack }, 3)
455
+ expect(res.delivered).toBe(false)
456
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
457
+ finalAnswerDelivered: true,
458
+ }
459
+ finalizeBackstopSendGated(turn, {
460
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
461
+ })
462
+ expect(computeTurnStatus(turn)).toBe('send_failed')
463
+ })
464
+
465
+ it('an ambiguous probe on a chunk that never landed is still NOT delivered', async () => {
466
+ const ledger = new BackstopDeliveryLedger()
467
+ // chunk 0 lands; chunk 1 throws on every attempt → never landed.
468
+ const sendChunk = vi.fn(async (i: number) => {
469
+ if (i === 1) throw new Error('flood')
470
+ return [800 + i]
471
+ })
472
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
473
+ const res = await runBackstopDelivery(ledger, '#gap', ['c0', 'c1'], null, { sendChunk, readBack }, 2)
474
+ expect(res.delivered).toBe(false)
475
+ expect(res.exhausted).toBe(true)
476
+ })
477
+
478
+ // Mixed sequence: the two probe states that MOVE the verdict, composed in one
479
+ // run. Chunk 0's first send is silently discarded (`absent` ⇒ demote ⇒
480
+ // re-send) and the probe on the RE-SEND is shed (`ambiguous`). Under the old
481
+ // confirmation-gated verdict the re-sent chunk was never `landed-confirmed`,
482
+ // so the whole turn came out `delivered:false` — a `send_failed` for an answer
483
+ // that had just been successfully re-sent. Demote-and-re-send and
484
+ // ambiguity-is-not-failure must compose.
485
+ it('absent ⇒ demote ⇒ re-send, then AMBIGUOUS on the re-send ⇒ delivered, sent exactly twice', async () => {
486
+ const ledger = new BackstopDeliveryLedger()
487
+ let sends = 0
488
+ const sendChunk = vi.fn(async () => { sends++; return [990 + sends] })
489
+ let probes = 0
490
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => {
491
+ probes++
492
+ return probes === 1 ? 'absent' : 'ambiguous'
493
+ })
494
+ const res = await runBackstopDelivery(ledger, '#mixed', ['answer'], null, { sendChunk, readBack }, 3)
495
+ expect(sendChunk).toHaveBeenCalledTimes(2) // initial + exactly one re-send
496
+ expect(readBack).toHaveBeenCalledTimes(2) // probed once per landing
497
+ expect(res.delivered).toBe(true) // the re-send landed a fresh id; ambiguity proves nothing
498
+ expect(res.confirmed).toBe(false) // ...and is honestly reported as uncorroborated
499
+ expect(res.sentIds).toEqual([992]) // the demoted first id is gone; the re-send's id stands
500
+ expect(res.landedUnconfirmedIds).toEqual([992])
501
+ // The outcome the user actually experiences: a `complete` turn record.
502
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
503
+ finalAnswerDelivered: true,
504
+ }
505
+ finalizeBackstopSendGated(turn, {
506
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: null,
507
+ })
508
+ expect(computeTurnStatus(turn)).toBe('complete')
509
+ })
510
+ })
511
+
512
+ // ── The observability of the new optimism (#3702 L2/L5/L6a) ─────────────────
513
+ //
514
+ // Counting an inconclusive probe as delivered is a BET. These pin the two
515
+ // artifacts that let the fleet find out if it is ever wrong: the per-delivery
516
+ // stderr line, and the `landed_unconfirmed` field on the turns.jsonl row.
517
+ describe('#3702 landed-unconfirmed is MEASURED, not silently assumed', () => {
518
+ it('a delivered-but-unconfirmed turn writes the diagnostic stderr line', async () => {
519
+ const ledger = new BackstopDeliveryLedger()
520
+ const lines: string[] = []
521
+ const res = await runBackstopDelivery(
522
+ ledger, '#obs', ['answer'], null,
523
+ {
524
+ sendChunk: async () => [1001],
525
+ readBack: async (): Promise<ReadBackResult> => 'ambiguous',
526
+ stderr: (s) => lines.push(s),
527
+ },
528
+ 3,
529
+ )
530
+ expect(res.delivered).toBe(true)
531
+ expect(res.confirmed).toBe(false)
532
+ const diag = lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))
533
+ expect(diag).toHaveLength(1)
534
+ expect(diag[0]).toContain('#obs')
535
+ expect(diag[0]).toContain('1 of 1 landed id(s)') // the measured quantity, not just a warning
536
+ })
537
+
538
+ it('a fully CONFIRMED delivery writes NO landed-unconfirmed line (no false alarm)', async () => {
539
+ const ledger = new BackstopDeliveryLedger()
540
+ const lines: string[] = []
541
+ const res = await runBackstopDelivery(
542
+ ledger, '#conf', ['answer'], null,
543
+ {
544
+ sendChunk: async () => [1002],
545
+ readBack: async (): Promise<ReadBackResult> => 'exists',
546
+ stderr: (s) => lines.push(s),
547
+ },
548
+ 3,
549
+ )
550
+ expect(res.confirmed).toBe(true)
551
+ expect(res.landedUnconfirmedIds).toEqual([])
552
+ expect(lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))).toHaveLength(0)
553
+ })
554
+
555
+ it('landedUnconfirmedIds is exactly the uncorroborated subset of sentIds', async () => {
556
+ const ledger = new BackstopDeliveryLedger()
557
+ // chunk 0 confirmed, chunk 1 landed-unconfirmed (shed probe).
558
+ const res = await runBackstopDelivery(
559
+ ledger, '#subset', ['c0', 'c1'], null,
560
+ {
561
+ sendChunk: async (i: number) => [1100 + i],
562
+ readBack: async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'),
563
+ },
564
+ 3,
565
+ )
566
+ expect(res.sentIds).toEqual([1100, 1101])
567
+ expect(res.landedUnconfirmedIds).toEqual([1101]) // c0 is corroborated; c1 is the bet
568
+ expect(res.delivered).toBe(true)
569
+ expect(res.confirmed).toBe(false)
570
+ })
571
+
572
+ it('the count reaches turns.jsonl: a complete turn carries `landed_unconfirmed`', async () => {
573
+ const ledger = new BackstopDeliveryLedger()
574
+ const res = await runBackstopDelivery(
575
+ ledger, '#row', ['c0', 'c1'], null,
576
+ {
577
+ sendChunk: async (i: number) => [1200 + i],
578
+ readBack: async (): Promise<ReadBackResult> => 'ambiguous',
579
+ },
580
+ 3,
581
+ )
582
+ const turn = {
583
+ agent: 'test-agent',
584
+ startedAt: 1_000_000,
585
+ toolCallCount: 0,
586
+ turnId: '#row',
587
+ finalAnswerDelivered: true,
588
+ deliveryOutcome: 'delivered' as const,
589
+ landedUnconfirmed: res.landedUnconfirmedIds.length,
590
+ }
591
+ const row = buildTurnRecord(turn, 1_002_000)
592
+ // The status is honest AND the bet is visible on the same row — that pairing
593
+ // is the point: a `complete` we could not corroborate is countable.
594
+ expect(row.status).toBe('complete')
595
+ expect(row.landed_unconfirmed).toBe(2)
596
+ })
597
+
598
+ it('an ordinary confirmed turn omits the field entirely (row shape unchanged)', () => {
599
+ const row = buildTurnRecord(
600
+ {
601
+ agent: 'test-agent',
602
+ startedAt: 1_000_000,
603
+ toolCallCount: 0,
604
+ turnId: '#plain',
605
+ finalAnswerDelivered: true,
606
+ deliveryOutcome: 'delivered',
607
+ landedUnconfirmed: 0,
608
+ },
609
+ 1_002_000,
610
+ )
611
+ expect(row.status).toBe('complete')
612
+ expect('landed_unconfirmed' in row).toBe(false)
613
+ })
417
614
  })
@@ -141,4 +141,16 @@ describe('#3278 createBackstopReadBack — full gate+edit+classify wiring', () =
141
141
  const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
142
142
  expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
143
143
  })
144
+
145
+ test('gate NO-OP drop (resolves undefined) ⇒ ambiguous, NOT a false exists', async () => {
146
+ // The gate's edit path drops a repeat of the last payload it actually sent
147
+ // (`counters.dropped`) and drops an expired queue entry, resolving
148
+ // `undefined` in both cases. Nothing reached Telegram, so nothing was
149
+ // proven — classifying it `exists` would fabricate a confirmation from a
150
+ // call that never happened.
151
+ const gate = vi.fn(async () => undefined)
152
+ const editMessageText = vi.fn(async () => true)
153
+ const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
154
+ expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
155
+ })
144
156
  })
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Boot pin sweep WIRING fence (#3664 Defect A).
3
+ *
4
+ * The behaviour lives in `boot-sweep-gate.test.ts`; this file pins the gateway
5
+ * WIRING, because the regression was a wiring regression: `caa4d7568` (#3310)
6
+ * moved the `lockedBot` assignment into `initGatewayBot()` and left the sweep's
7
+ * kick-off at module-eval time. `tsc` stayed green (the `!` definite-assignment
8
+ * assertion on `lockedBot` suppresses the error) and no test noticed, so every
9
+ * boot-cleanup unpin threw `undefined is not an object` for a month.
10
+ *
11
+ * These are STRUCTURAL assertions (the gateway module can't be instantiated
12
+ * in-process — same pattern as silence-liveness-wiring.test.ts) that fail if a
13
+ * future refactor re-introduces a module-eval-time dispatch of the sweep.
14
+ */
15
+ import { describe, it, expect } from 'vitest'
16
+ import { readFileSync } from 'node:fs'
17
+ import { resolve } from 'node:path'
18
+
19
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
20
+
21
+ describe('boot pin sweep wiring (#3664)', () => {
22
+ it('never dispatches runBootPinCleanupAndDmSweep() directly — only through the gate', () => {
23
+ // The original bug shape, verbatim: a fire-and-forget call at module-eval
24
+ // time. Any direct invocation (voided or awaited) bypasses the bot-ready
25
+ // half of the precondition.
26
+ const directCalls =
27
+ gatewaySrc.match(/(?<!function\s)(?<![.\w])runBootPinCleanupAndDmSweep\s*\(/g) ?? []
28
+ expect(directCalls).toEqual([])
29
+ })
30
+
31
+ it('passes the sweep to the gate as a reference, and arms it at both mutex sites', () => {
32
+ expect(gatewaySrc).toContain('createBootSweepGate({ run: runBootPinCleanupAndDmSweep')
33
+ // Both startup-lock outcomes (mutex acquired, and the non-atomic
34
+ // writePidFile fallback) arm — neither may dispatch on its own.
35
+ const armSites = gatewaySrc.match(/bootPinSweepGate\.arm\(\)/g) ?? []
36
+ expect(armSites.length).toBe(2)
37
+ })
38
+
39
+ it('signals botReady() only from initGatewayBot, AFTER lockedBot is assigned', () => {
40
+ const readySites = gatewaySrc.match(/bootPinSweepGate\.botReady\(\)/g) ?? []
41
+ expect(readySites.length).toBe(1)
42
+
43
+ const initStart = gatewaySrc.indexOf('async function initGatewayBot()')
44
+ const assignIdx = gatewaySrc.indexOf('lockedBot = chatLock.wrapBot(')
45
+ const readyIdx = gatewaySrc.indexOf('bootPinSweepGate.botReady()')
46
+ expect(initStart).toBeGreaterThan(-1)
47
+ expect(assignIdx).toBeGreaterThan(initStart)
48
+ expect(readyIdx).toBeGreaterThan(assignIdx)
49
+ })
50
+
51
+ it('runs the sweep steps through runBootPinSweepSteps, not a bare await chain', () => {
52
+ // Salvage S2. The BEHAVIOUR (a throwing step must not strand the steps
53
+ // behind it, and must never strand the DM-eligibility flip) is proven in
54
+ // boot-sweep-gate.test.ts. This fence is what makes that proof apply to the
55
+ // gateway: the reapers must reach the runner as injected steps, and nothing
56
+ // may reintroduce the bare sequential chain that skipped the isolation.
57
+ expect(gatewaySrc).toContain('return runBootPinSweepSteps({')
58
+ const stepFns = ['statusPinBootCleanup', 'activityCardBootReaper', 'queuedCardBootReaper']
59
+ for (const stepFn of stepFns) {
60
+ // Passed BY REFERENCE as a step, never invoked inline in the sweep.
61
+ expect(gatewaySrc).toContain(`: ${stepFn},`)
62
+ const inlineAwaits = gatewaySrc.match(new RegExp(`await\\s+${stepFn}\\s*\\(`, 'g')) ?? []
63
+ expect(inlineAwaits).toEqual([])
64
+ }
65
+ // The DM-eligibility flip is what a throw used to strand, so it must live
66
+ // INSIDE the enableDmSweep step callback — not as a statement sequenced
67
+ // after the reapers, where one rejection skips it for the whole session.
68
+ const lines = gatewaySrc.split('\n')
69
+ const flipIdxs = lines
70
+ .map((l, i) => ({ l, i }))
71
+ .filter(({ l }) => /^\s*dmPinSweepEligible = true\s*$/.test(l))
72
+ .map(({ i }) => i)
73
+ expect(flipIdxs.length).toBe(1)
74
+ expect(lines[flipIdxs[0] - 1]).toContain('enableDmSweep: () => {')
75
+ })
76
+
77
+ it('routes the pin API through the asserting seam, never a raw lockedBot.api pin', () => {
78
+ // Salvage S1/S3: `statusPinApi()` must build on `createStatusPinApi`, which
79
+ // asserts the bot exists and converts a send-gate SHED into a throw. A
80
+ // hand-rolled `robustApiCall(() => lockedBot.api.pinChatMessage(...))`
81
+ // anywhere in the gateway would bypass both invariants.
82
+ expect(gatewaySrc).toContain('return createStatusPinApi(')
83
+ // Every `status-pin.*` verb is now issued from status-pin-api.ts. A raw
84
+ // `robustApiCall(() => lockedBot.api.unpinChatMessage(…), { verb:
85
+ // 'status-pin.unpin' })` reappearing here would be a driver call that
86
+ // skips assertBotReady + assertLanded.
87
+ const statusPinVerbs = gatewaySrc.match(/verb:\s*['"`]status-pin\.(?:un)?pin['"`]/g) ?? []
88
+ expect(statusPinVerbs).toEqual([])
89
+ })
90
+
91
+ it('arms only from inside the startup-mutex block, never at bare module scope', () => {
92
+ // Every arm() site must be indented (i.e. nested inside the
93
+ // `if (isGatewayMain) { … }` startup-lock block), never a top-level
94
+ // statement that would run unconditionally on import.
95
+ for (const line of gatewaySrc.split('\n')) {
96
+ if (line.includes('bootPinSweepGate.arm()')) {
97
+ expect(line.startsWith(' ')).toBe(true)
98
+ }
99
+ }
100
+ })
101
+ })
@@ -0,0 +1,293 @@
1
+ /**
2
+ * boot-sweep-gate — the deterministic ordering fence for #3664 Defect A.
3
+ *
4
+ * The regression these assertions exist to catch: the boot orphan sweep was
5
+ * dispatched the moment the gateway won the startup mutex, which happens at
6
+ * module-eval time — thousands of lines before `initGatewayBot()` assigns
7
+ * `lockedBot`. Every boot-cleanup unpin therefore threw
8
+ * `undefined is not an object (evaluating 'lockedBot.api')` before reaching
9
+ * Telegram (14/14 fleet-wide failures since #3310, zero clean successes).
10
+ *
11
+ * The behavioural assertion is "arming ALONE must not dispatch the sweep" —
12
+ * a test that would have failed on the original bug shape.
13
+ */
14
+ import { describe, it, expect } from 'vitest'
15
+ import {
16
+ createBootSweepGate,
17
+ runBootPinSweepSteps,
18
+ type BootPinSweepSteps,
19
+ } from '../gateway/boot-sweep-gate.js'
20
+
21
+ /** A fake sweep that records whether it ran and what the bot handle looked
22
+ * like at that moment — the thing the original bug got wrong. */
23
+ function fakeSweep() {
24
+ const observed: (string | undefined)[] = []
25
+ let bot: string | undefined
26
+ return {
27
+ run: async () => {
28
+ observed.push(bot)
29
+ },
30
+ setBot: (b: string) => {
31
+ bot = b
32
+ },
33
+ get observed() {
34
+ return observed
35
+ },
36
+ }
37
+ }
38
+
39
+ describe('createBootSweepGate (#3664 Defect A)', () => {
40
+ it('does NOT dispatch on arm() alone — the bot handle does not exist yet', async () => {
41
+ const sweep = fakeSweep()
42
+ const gate = createBootSweepGate({ run: sweep.run })
43
+
44
+ gate.arm()
45
+ await Promise.resolve()
46
+
47
+ expect(gate.hasRun()).toBe(false)
48
+ expect(sweep.observed).toEqual([])
49
+ })
50
+
51
+ it('does NOT dispatch on botReady() alone — this boot may not own the pin state', async () => {
52
+ const sweep = fakeSweep()
53
+ const gate = createBootSweepGate({ run: sweep.run })
54
+
55
+ gate.botReady()
56
+ await Promise.resolve()
57
+
58
+ expect(gate.hasRun()).toBe(false)
59
+ expect(sweep.observed).toEqual([])
60
+ })
61
+
62
+ it('dispatches once BOTH signals arrive, and the bot handle is live when it does', async () => {
63
+ const sweep = fakeSweep()
64
+ const gate = createBootSweepGate({ run: sweep.run })
65
+
66
+ gate.arm() // startup mutex won (module-eval time)
67
+ await Promise.resolve()
68
+ expect(sweep.observed).toEqual([])
69
+
70
+ sweep.setBot('lockedBot') // initGatewayBot() assigns lockedBot …
71
+ gate.botReady() // … and signals the gate
72
+ await Promise.resolve()
73
+
74
+ // The sweep ran, and it saw a live bot handle — never `undefined`.
75
+ expect(sweep.observed).toEqual(['lockedBot'])
76
+ expect(gate.hasRun()).toBe(true)
77
+ })
78
+
79
+ it('is order-independent: botReady() before arm() still dispatches exactly once', async () => {
80
+ const sweep = fakeSweep()
81
+ const gate = createBootSweepGate({ run: sweep.run })
82
+
83
+ sweep.setBot('lockedBot')
84
+ gate.botReady()
85
+ await Promise.resolve()
86
+ expect(sweep.observed).toEqual([])
87
+
88
+ gate.arm()
89
+ await Promise.resolve()
90
+ expect(sweep.observed).toEqual(['lockedBot'])
91
+ })
92
+
93
+ it('dispatches at most once however many times the signals repeat', async () => {
94
+ const sweep = fakeSweep()
95
+ const gate = createBootSweepGate({ run: sweep.run })
96
+
97
+ sweep.setBot('lockedBot')
98
+ gate.arm()
99
+ gate.botReady()
100
+ gate.arm() // e.g. the mutex path AND the mutex-fallback path
101
+ gate.botReady()
102
+ await Promise.resolve()
103
+
104
+ expect(sweep.observed).toEqual(['lockedBot'])
105
+ })
106
+
107
+ it('routes a rejecting sweep to onError instead of leaking an unhandled rejection', async () => {
108
+ const rejections: unknown[] = []
109
+ const onUnhandled = (err: unknown) => rejections.push(err)
110
+ process.on('unhandledRejection', onUnhandled)
111
+ try {
112
+ const errs: unknown[] = []
113
+ const gate = createBootSweepGate({
114
+ run: async () => {
115
+ throw new Error('boom')
116
+ },
117
+ onError: (err) => errs.push(err),
118
+ })
119
+
120
+ gate.arm()
121
+ gate.botReady()
122
+ await new Promise((r) => setTimeout(r, 10))
123
+
124
+ expect((errs[0] as Error).message).toBe('boom')
125
+ expect(rejections).toEqual([])
126
+ } finally {
127
+ process.off('unhandledRejection', onUnhandled)
128
+ }
129
+ })
130
+ })
131
+
132
+ /**
133
+ * Per-step isolation (#3664 salvage S2).
134
+ *
135
+ * The steps used to be a bare sequential `await` chain in gateway.ts. A throw
136
+ * from either card reaper skipped `dmPinSweepEligible = true`, which authorises
137
+ * the DM unpin-all path for the WHOLE SESSION — so one throwing reaper silently
138
+ * disabled DM stale-pin cleanup, including every later lazy first-inbound
139
+ * sweep. That became newly reachable the moment #3664 made the sweep actually
140
+ * run for the first time.
141
+ *
142
+ * The outcome asserted is "was the DM path enabled and were the DM chats
143
+ * swept", not "was a wrapper called".
144
+ */
145
+ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
146
+ function deps(over: Partial<BootPinSweepSteps> = {}) {
147
+ const order: string[] = []
148
+ const logs: string[] = []
149
+ const base: BootPinSweepSteps = {
150
+ scanDmChatIds: () => {
151
+ order.push('scan')
152
+ return ['12345']
153
+ },
154
+ statusPinCleanup: async () => {
155
+ order.push('status-pins')
156
+ },
157
+ activityCardReaper: async () => {
158
+ order.push('activity-cards')
159
+ },
160
+ queuedCardReaper: async () => {
161
+ order.push('queued-cards')
162
+ },
163
+ enableDmSweep: () => order.push('enable-dm'),
164
+ sweepDm: async (id) => {
165
+ order.push(`dm:${id}`)
166
+ },
167
+ log: (l) => logs.push(l),
168
+ }
169
+ return { order, logs, deps: { ...base, ...over } }
170
+ }
171
+
172
+ it('runs the store scan first, then the reapers, then enables + runs the DM sweep', async () => {
173
+ const d = deps()
174
+ await runBootPinSweepSteps(d.deps)
175
+ expect(d.order).toEqual([
176
+ 'scan',
177
+ 'status-pins',
178
+ 'activity-cards',
179
+ 'queued-cards',
180
+ 'enable-dm',
181
+ 'dm:12345',
182
+ ])
183
+ })
184
+
185
+ it('a throwing reaper does not strand the steps behind it', async () => {
186
+ const d = deps({
187
+ activityCardReaper: async () => {
188
+ throw new Error('boom')
189
+ },
190
+ })
191
+ await runBootPinSweepSteps(d.deps)
192
+ // The DM path is still enabled and the recorded DM chat is still swept.
193
+ expect(d.order).toEqual(['scan', 'status-pins', 'queued-cards', 'enable-dm', 'dm:12345'])
194
+ expect(d.logs.join('')).toContain("step 'activity-card-reaper' failed: boom")
195
+ })
196
+
197
+ it('enables the DM sweep even when EVERY earlier step throws', async () => {
198
+ const boom = async () => {
199
+ throw new Error('boom')
200
+ }
201
+ const d = deps({
202
+ statusPinCleanup: boom,
203
+ activityCardReaper: boom,
204
+ queuedCardReaper: boom,
205
+ })
206
+ await runBootPinSweepSteps(d.deps)
207
+ expect(d.order).toEqual(['scan', 'enable-dm', 'dm:12345'])
208
+ })
209
+
210
+ it('a failing store scan still lets the reapers and the DM enable run', async () => {
211
+ const d = deps({
212
+ scanDmChatIds: () => {
213
+ throw new Error('ENOENT')
214
+ },
215
+ })
216
+ await runBootPinSweepSteps(d.deps)
217
+ // No DM ids to sweep, but nothing behind the scan is stranded.
218
+ expect(d.order).toEqual(['status-pins', 'activity-cards', 'queued-cards', 'enable-dm'])
219
+ expect(d.logs.join('')).toContain("step 'dm-chat-scan' failed: ENOENT")
220
+ })
221
+
222
+ it('one failing DM chat does not block the next one', async () => {
223
+ const swept: string[] = []
224
+ const d = deps({
225
+ scanDmChatIds: () => ['1', '2'],
226
+ sweepDm: async (id) => {
227
+ if (id === '1') throw new Error('kicked')
228
+ swept.push(id)
229
+ },
230
+ })
231
+ await runBootPinSweepSteps(d.deps)
232
+ expect(swept).toEqual(['2'])
233
+ })
234
+
235
+ it('logs a non-Error throw honestly instead of "undefined"', async () => {
236
+ const d = deps({
237
+ queuedCardReaper: async () => {
238
+ // A non-Error throw: `(err as Error).message` would log "undefined".
239
+ throw 'not an Error'
240
+ },
241
+ })
242
+ await runBootPinSweepSteps(d.deps)
243
+ expect(d.logs.join('')).toContain("step 'queued-card-reaper' failed: not an Error")
244
+ expect(d.logs.join('')).not.toContain('failed: undefined')
245
+ })
246
+
247
+ it('survives a log sink that itself throws', async () => {
248
+ // stderr can be gone on a dying process; the one function whose job is
249
+ // "a step must not take the sweep down" must not take the sweep down.
250
+ const order: string[] = []
251
+ await expect(
252
+ runBootPinSweepSteps({
253
+ scanDmChatIds: () => [],
254
+ statusPinCleanup: async () => {
255
+ throw new Error('boom')
256
+ },
257
+ activityCardReaper: async () => {
258
+ order.push('activity-cards')
259
+ },
260
+ queuedCardReaper: async () => {
261
+ order.push('queued-cards')
262
+ },
263
+ enableDmSweep: () => order.push('enable-dm'),
264
+ sweepDm: async () => {},
265
+ log: () => {
266
+ throw new Error('EPIPE')
267
+ },
268
+ }),
269
+ ).resolves.toBeUndefined()
270
+ expect(order).toEqual(['activity-cards', 'queued-cards', 'enable-dm'])
271
+ })
272
+
273
+ it('never rejects — the caller fire-and-forgets it', async () => {
274
+ const boom = async () => {
275
+ throw new Error('boom')
276
+ }
277
+ await expect(
278
+ runBootPinSweepSteps({
279
+ scanDmChatIds: () => {
280
+ throw new Error('boom')
281
+ },
282
+ statusPinCleanup: boom,
283
+ activityCardReaper: boom,
284
+ queuedCardReaper: boom,
285
+ enableDmSweep: () => {
286
+ throw new Error('boom')
287
+ },
288
+ sweepDm: boom,
289
+ log: () => {},
290
+ }),
291
+ ).resolves.toBeUndefined()
292
+ })
293
+ })