switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -754,6 +754,48 @@ describe('renderCombinedWorkerFeed (pure)', () => {
754
754
  expect(combinedHistoryDepth(6)).toBe(1)
755
755
  expect(combinedHistoryDepth(8)).toBe(1)
756
756
  })
757
+
758
+ // ── Worker numbering (#3298): stable ordinal prefix at 2+ workers ──────────
759
+ const rowN = (i: number, ordinal: number) => ({ ...row(i, `step ${i}`), ordinal })
760
+
761
+ it('numbers the workers with a literal "N. " prefix inside the bold header when 2+ run', () => {
762
+ const body = renderCombinedWorkerFeed([rowN(1, 1), rowN(2, 2)], { maxRows: 8 })!
763
+ // The literal `1. ` must survive markdown rendering, inside the bold span
764
+ // before the escaped description.
765
+ expect(body).toContain('**1. task number 1**')
766
+ expect(body).toContain('**2. task number 2**')
767
+ })
768
+
769
+ it('renders non-contiguous ordinals as-is (survivors keep their numbers after a finish)', () => {
770
+ // Worker 1 finished upstream — the surviving rows arrive with their
771
+ // ORIGINAL ordinals. Positional numbering would render 1./2. here; the
772
+ // stable scheme must render 2./3. and never a `1. `.
773
+ const body = renderCombinedWorkerFeed([rowN(2, 2), rowN(3, 3)], { maxRows: 8 })!
774
+ expect(body).toContain('**2. task number 2**')
775
+ expect(body).toContain('**3. task number 3**')
776
+ expect(body).not.toContain('**1. ')
777
+ })
778
+
779
+ it('a single row is never numbered, even when it carries an ordinal', () => {
780
+ const body = renderCombinedWorkerFeed([rowN(1, 1)], { maxRows: 8 })!
781
+ expect(body).toContain('**task number 1**')
782
+ expect(body).not.toContain('**1. ')
783
+ })
784
+
785
+ it('rows without ordinals render unnumbered (back-compat for direct callers)', () => {
786
+ const body = renderCombinedWorkerFeed([row(1, 'alpha'), row(2, 'beta')], { maxRows: 8 })!
787
+ expect(body).toContain('**task number 1**')
788
+ expect(body).toContain('**task number 2**')
789
+ expect(body).not.toContain('1. task')
790
+ })
791
+
792
+ it('spilled rows take their ordinals with them; visible ordinals do not shift', () => {
793
+ const rows = Array.from({ length: 10 }, (_, i) => rowN(i + 1, i + 1))
794
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 4 })!
795
+ expect(body).toContain('+6 more working')
796
+ for (let n = 1; n <= 4; n++) expect(body).toContain(`**${n}. task number ${n}**`)
797
+ expect(body).not.toContain('**5. ')
798
+ })
757
799
  })
758
800
 
759
801
  /**
@@ -1019,3 +1061,87 @@ describe('worker-feed ghost-leak — deterministic terminal removal + backstop',
1019
1061
  expect(edits.length).toBe(afterPaint)
1020
1062
  })
1021
1063
  })
1064
+
1065
+ describe('worker numbering — stable per-card ordinals end to end (#3298)', () => {
1066
+ const doneView = (desc: string, elapsedMs: number): WorkerActivityView => ({
1067
+ description: desc,
1068
+ lastTool: null,
1069
+ toolCount: 3,
1070
+ latestSummary: `${desc} result`,
1071
+ elapsedMs,
1072
+ state: 'done',
1073
+ })
1074
+ const lastBody = (h: { sends: { text: string }[]; edits: { messageId: number; text: string }[] }): string => {
1075
+ if (h.edits.length > 0) return h.edits[h.edits.length - 1].text
1076
+ return h.sends[h.sends.length - 1].text
1077
+ }
1078
+
1079
+ it('assigns 1./2./3. at dispatch and survivors KEEP their numbers when an earlier worker finishes', async () => {
1080
+ let clock = 1000
1081
+ const h = ghostHarness({ now: () => clock })
1082
+ await h.feed.update('a', 'chat', view('task alpha', 'a-doing', 0))
1083
+ await h.feed.update('b', 'chat', view('task beta', 'b-doing', 0))
1084
+ await h.feed.update('c', 'chat', view('task gamma', 'c-doing', 0))
1085
+ await drain()
1086
+
1087
+ let body = lastBody(h)
1088
+ expect(body).toContain('**1. task alpha**')
1089
+ expect(body).toContain('**2. task beta**')
1090
+ expect(body).toContain('**3. task gamma**')
1091
+
1092
+ // Worker 1 finishes: the survivors must STILL be 2. and 3. — positional
1093
+ // numbering would renumber them 1./2. and this assertion would fail.
1094
+ clock = 2000
1095
+ await h.feed.finish('a', doneView('task alpha', 1000))
1096
+ await drain()
1097
+ body = lastBody(h)
1098
+ expect(body).toContain('2 running')
1099
+ expect(body).toContain('**2. task beta**')
1100
+ expect(body).toContain('**3. task gamma**')
1101
+ expect(body).not.toContain('**1. ')
1102
+ })
1103
+
1104
+ it('a single running worker is never numbered (single-worker layout)', async () => {
1105
+ const h = ghostHarness({ now: () => 1000 })
1106
+ await h.feed.update('solo', 'chat', view('task solo', 'working', 0))
1107
+ await drain()
1108
+ const body = lastBody(h)
1109
+ expect(body).toContain('task solo')
1110
+ expect(body).not.toContain('1. task solo')
1111
+ })
1112
+
1113
+ it('a fresh card after ALL workers finish numbers from 1 again', async () => {
1114
+ let clock = 1000
1115
+ const h = ghostHarness({ now: () => clock })
1116
+ await h.feed.update('a', 'chat', view('task alpha', 'a-doing', 0))
1117
+ await h.feed.update('b', 'chat', view('task beta', 'b-doing', 0))
1118
+ await drain()
1119
+ clock = 2000
1120
+ await h.feed.finish('a', doneView('task alpha', 1000))
1121
+ await h.feed.finish('b', doneView('task beta', 1000))
1122
+ await drain()
1123
+ expect(h.feed.size).toBe(0)
1124
+
1125
+ // New fan-out in the same chat → a NEW card that starts at 1., not 3.
1126
+ clock = 3000
1127
+ await h.feed.update('c', 'chat', view('task gamma', 'c-doing', 0))
1128
+ await h.feed.update('d', 'chat', view('task delta', 'd-doing', 0))
1129
+ await drain()
1130
+ const body = lastBody(h)
1131
+ expect(body).toContain('**1. task gamma**')
1132
+ expect(body).toContain('**2. task delta**')
1133
+ })
1134
+
1135
+ it('introducing ordinals causes no dedup churn (same substance → edit still skipped)', async () => {
1136
+ const h = ghostHarness({ now: () => 1000 })
1137
+ await h.feed.update('a', 'chat', view('task alpha', 'same step', 0))
1138
+ await h.feed.update('b', 'chat', view('task beta', 'same step', 0))
1139
+ await drain()
1140
+ const landed = h.edits.length
1141
+ // Byte-identical substance re-update: the dedup/no-op skip must still fire
1142
+ // (a stable ordinal never varies the substance between identical renders).
1143
+ await h.feed.update('a', 'chat', view('task alpha', 'same step', 0))
1144
+ await drain()
1145
+ expect(h.edits.length).toBe(landed)
1146
+ })
1147
+ })
@@ -646,6 +646,15 @@ export interface CombinedWorkerRow {
646
646
  /** Running total tokens for this worker — rendered as `· {N} tok` on the row
647
647
  * header. Omitted (0/undefined) → no token segment. */
648
648
  totalTokens?: number
649
+ /**
650
+ * Stable per-card ordinal (1-based), assigned when the worker joins its feed
651
+ * group and KEPT for the card's lifetime — survivors keep their numbers when
652
+ * an earlier worker finishes (the card may show `2.`/`3.` with no `1.`; the
653
+ * `N running` chrome carries the count). Rendered as a `{ordinal}. ` prefix
654
+ * inside the bold header when the card has 2+ rows. Omitted → unnumbered
655
+ * (back-compat for direct callers).
656
+ */
657
+ ordinal?: number
649
658
  }
650
659
 
651
660
  export interface CombinedWorkerFeedOpts {
@@ -687,13 +696,19 @@ export function combinedHistoryDepth(w: number): number {
687
696
  * markdown; callers send verbatim — do NOT re-escape). Layout:
688
697
  *
689
698
  * 🛠 **Workers** · _N running_
690
- * **{desc1}** _· {elapsed} · {n} tools_
699
+ * **1. {desc1}** _· {elapsed} · {n} tools_
691
700
  * ~~_✓ {earlier step}_~~
692
701
  * **→ {newest step}**
693
- * **{desc2}** _· {elapsed} · {n} tools_
702
+ * **2. {desc2}** _· {elapsed} · {n} tools_
694
703
  * **→ {newest step}**
695
704
  * _+M more working…_
696
705
  *
706
+ * NUMBERING (#3298): when the card tracks 2+ rows AND a row carries `ordinal`,
707
+ * its header gets a stable `{ordinal}. ` prefix. Ordinals are assigned by the
708
+ * caller at dispatch and kept for the card's life — after an earlier worker
709
+ * finishes the survivors keep their numbers (`2.`, `3.` with no `1.`). A lone
710
+ * row, or rows without ordinals, render unnumbered.
711
+ *
697
712
  * ADAPTIVE DENSITY: each visible worker renders its last-K narrative lines as a
698
713
  * `✓`/`→` trail (prior steps struck, newest bold in-progress) — the single-
699
714
  * worker card's idiom — where K = `combinedHistoryDepth(visibleCount)` splits a
@@ -716,6 +731,11 @@ export function renderCombinedWorkerFeed(
716
731
  if (rows.length === 0) return null
717
732
  const maxRows = Math.max(1, Math.floor(opts.maxRows))
718
733
 
734
+ // Number the workers only when the CARD tracks 2+ (a lone worker stays
735
+ // unnumbered). Uses the total row count, not the visible count, so ordinals
736
+ // don't appear/vanish as the overflow backstop shrinks the visible set.
737
+ const numbered = rows.length >= 2
738
+
719
739
  const rowHeader = (r: CombinedWorkerRow): string => {
720
740
  const desc = escapeMarkdown(
721
741
  truncate(stripMarkdown(r.description).replace(/\s+/g, ' ').trim() || 'background task', COMBINED_ROW_DESC_MAX),
@@ -724,7 +744,11 @@ export function renderCombinedWorkerFeed(
724
744
  const tokPart = tokenSegment(r.totalTokens)
725
745
  const modelLabel = formatModelLabel(r.model)
726
746
  const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
727
- return `**${desc}** ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${tokPart}${modelPart}_`
747
+ // Stable ordinal prefix INSIDE the bold span, before the already-escaped
748
+ // description — no new escaping surface, and the gateway md→HTML conversion
749
+ // has no ordered-list auto-formatting on bolded text.
750
+ const num = numbered && r.ordinal != null ? `${r.ordinal}. ` : ''
751
+ return `**${num}${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${tokPart}${modelPart}_`
728
752
  }
729
753
 
730
754
  // Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
@@ -471,6 +471,14 @@ interface WorkerRow {
471
471
  * once past STEP_TIMER_MIN_MS.
472
472
  */
473
473
  stepStartedAtMs: number | null
474
+ /**
475
+ * Stable 1-based ordinal within the group's CURRENT card, assigned from the
476
+ * group's monotonic counter when the row is first registered and IMMUTABLE
477
+ * thereafter. The combined feed renders it as a `{n}. ` header prefix when
478
+ * 2+ workers are running; survivors keep their numbers when an earlier
479
+ * worker finishes (never renumbered positionally — #3298).
480
+ */
481
+ ordinal: number
474
482
  }
475
483
 
476
484
  /**
@@ -511,6 +519,14 @@ interface FeedGroup {
511
519
  chain: Promise<void>
512
520
  /** Live workers in this group, keyed by agentId (insertion ≈ dispatch order). */
513
521
  workers: Map<string, WorkerRow>
522
+ /**
523
+ * Monotonic per-card worker counter — `++counter` hands each newly
524
+ * registered row its stable {@link WorkerRow.ordinal}. Reset to 0 whenever a
525
+ * fresh card starts (group creation, and registration into an emptied group
526
+ * — which covers the group-reuse-after-terminal repaint, since finalize
527
+ * drops rows from `workers` immediately), so every new card numbers from 1.
528
+ */
529
+ workerOrdinalCounter: number
514
530
  /**
515
531
  * Terminal renders (per finishing worker's recap) staged because a 429
516
532
  * cooldown / flood window blocked the edit. Keyed by agentId so a SECOND
@@ -844,6 +860,9 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
844
860
  const currentStep = r.narrative.length > 0 ? r.narrative[r.narrative.length - 1] : v.latestSummary
845
861
  return {
846
862
  description: v.description,
863
+ // Stable per-card ordinal — assigned at registration, kept for the
864
+ // card's life (survivors don't renumber when an earlier worker ends).
865
+ ordinal: r.ordinal,
847
866
  elapsedMs: elapsedFor(r),
848
867
  toolCount: v.toolCount,
849
868
  totalTokens: v.totalTokens,
@@ -1488,6 +1507,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1488
1507
  cooldownUntil: 0,
1489
1508
  chain: Promise.resolve(),
1490
1509
  workers: new Map(),
1510
+ workerOrdinalCounter: 0,
1491
1511
  pendingFinalize: new Map(),
1492
1512
  terminalPainted: false,
1493
1513
  }
@@ -1512,8 +1532,12 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1512
1532
  }
1513
1533
  let row = g.workers.get(agentId)
1514
1534
  if (row == null) {
1535
+ // A registration into an EMPTIED group starts a fresh card — number
1536
+ // from 1 again rather than continuing the dead card's sequence.
1537
+ if (g.workers.size === 0) g.workerOrdinalCounter = 0
1515
1538
  row = {
1516
1539
  agentId,
1540
+ ordinal: ++g.workerOrdinalCounter,
1517
1541
  narrative: [],
1518
1542
  lastView: null,
1519
1543
  state: 'running',
@@ -1,113 +0,0 @@
1
- /**
2
- * #2787 Mechanism B — the `claudeBusyKeys` orphan-reaper lifecycle, extracted
3
- * so the gateway glue that mutates the busy-key set stays in lockstep with its
4
- * shadow insertion-timestamp map and is unit-testable WITHOUT importing
5
- * `gateway.ts` (which boots the bot + IPC listener on module load and so cannot
6
- * run inside a unit test). The gateway keeps ownership of the two containers
7
- * (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`) and calls these helpers at
8
- * every mark / reap site; `disconnect-flush.ts` clears them at bridge death.
9
- * The test harness (`tests/busy-key-reaper.test.ts`) wires the SAME containers,
10
- * the SAME delivery queue, and the real `flushOnAgentDisconnect` to reproduce
11
- * the disconnect → reconnect → re-mark and slow-vs-orphan delivery sequences.
12
- *
13
- * Two invariants this module enforces:
14
- *
15
- * 1. LOCKSTEP. The timestamp map shadows actual SET MEMBERSHIP, not its own
16
- * prior presence. `markBusyKeyLockstep` stamps a fresh timestamp whenever a
17
- * key transitions idle→busy (`claudeBusyKeys.has(key) === false`). Gating on
18
- * the set — not on `since.has` — is load-bearing: a disconnect flush clears
19
- * `claudeBusyKeys` directly, so if the shadow map ever lagged, a `!since.has`
20
- * guard would decline to re-stamp on the reconnect→re-mark and leave a
21
- * stale, >TTL-old timestamp on a freshly-marked key — which the reaper would
22
- * then reap out from under a live delivery.
23
- *
24
- * 2. SLOW-DELIVERY SAFETY (the #1922 hazard). `busy` is marked EAGERLY at
25
- * delivery, and the gateway→claude enqueue-ack lag can be up to ~5 MINUTES
26
- * under load (#1922). During that eager-mark→enqueue window the gateway's
27
- * `currentTurn` is null yet the turn is real-and-merely-slow. A bare
28
- * time-grace reaper cannot tell "slow" from "orphaned" and would reap a
29
- * genuine delivery, re-opening the idle-drain gate while claude is about to
30
- * process the very inbound whose key it just reaped (duplicate / concurrent
31
- * delivery on the CORE inbound path). So the reap is PROOF-GATED: a key is
32
- * reaped ONLY when it has NO entry in the delivery-confirm queue. A
33
- * slow-but-real inbound is tracked there from delivery until claude's
34
- * `enqueue` ack (via the never-drop re-deliver loop), so any key still
35
- * awaiting its turn is present and skipped — no matter how slow. The time
36
- * grace is retained as defense-in-depth, defaulting well above the observed
37
- * ack-lag tail. Only a busy-marked key with NO pending delivery AND age past
38
- * the grace is a true orphan (its turn acked and should have cleared busy at
39
- * turn_end, or it was a steer/interrupt inbound excluded from tracking that
40
- * amends a now-absent turn) — reaping those can never clobber a slow one.
41
- */
42
-
43
- /** Lockstep mark: add to the busy set and, on the idle→busy transition only,
44
- * stamp the insertion time. Keyed on set membership so a re-mark after a
45
- * disconnect flush (which cleared the set directly) always re-stamps fresh. */
46
- export function markBusyKeyLockstep(
47
- keys: Set<string>,
48
- since: Map<string, number>,
49
- key: string,
50
- now: number,
51
- ): void {
52
- const wasBusy = keys.has(key)
53
- keys.add(key)
54
- if (!wasBusy) since.set(key, now)
55
- }
56
-
57
- /** Lockstep clear: delete from both containers. */
58
- export function clearBusyKeyLockstep(
59
- keys: Set<string>,
60
- since: Map<string, number>,
61
- key: string,
62
- ): void {
63
- keys.delete(key)
64
- since.delete(key)
65
- }
66
-
67
- export interface ReapOptions {
68
- /** Grace before a busy-marked key with no pending delivery is a true orphan. */
69
- ttlMs: number
70
- /** True iff a delivery-confirm entry is still tracked for this key — i.e. a
71
- * slow-but-real inbound awaiting its enqueue ack. Such keys are NEVER reaped,
72
- * regardless of age (the #1922 slow-delivery guarantee). */
73
- hasPendingDelivery: (key: string) => boolean
74
- /** Optional line logger for each reaped key (observability). */
75
- log?: (msg: string) => void
76
- }
77
-
78
- /**
79
- * Reap orphaned busy markers. Call ONLY once the gateway has asserted no turn is
80
- * in flight (`currentTurn == null`). Returns the keys reaped (for the caller's
81
- * metrics / assertions). Also prunes shadow-map entries whose key already left
82
- * the set, so the map can't grow unbounded.
83
- */
84
- export function reapOrphanBusyKeys(
85
- keys: Set<string>,
86
- since: Map<string, number>,
87
- now: number,
88
- opts: ReapOptions,
89
- ): string[] {
90
- const reaped: string[] = []
91
- for (const key of [...since.keys()]) {
92
- if (!keys.has(key)) {
93
- since.delete(key)
94
- continue
95
- }
96
- // Proof gate: a key with a pending delivery is a slow-but-real inbound still
97
- // awaiting its enqueue ack (the never-drop re-deliver loop owns it) — NEVER
98
- // an orphan, regardless of age. Skip so a multi-minute (#1922) delivery
99
- // can't be reaped out from under claude.
100
- if (opts.hasPendingDelivery(key)) continue
101
- const stampedAt = since.get(key) ?? now
102
- if (now - stampedAt >= opts.ttlMs) {
103
- keys.delete(key)
104
- since.delete(key)
105
- reaped.push(key)
106
- opts.log?.(
107
- `telegram gateway: reaped orphan busy key=${key} (no turn in flight, ` +
108
- `no pending delivery, >${opts.ttlMs}ms) — unwedging idle-drain`,
109
- )
110
- }
111
- }
112
- return reaped
113
- }
@@ -1,102 +0,0 @@
1
- /**
2
- * Gate-parity drift probe — scaffolding for the inbound-delivery
3
- * state-machine cutover (#2794, RFC PR3b→PR4).
4
- *
5
- * ## Why this exists
6
- *
7
- * The inbound-delivery pipeline is mid-migration and TRIPLE-maintained:
8
- *
9
- * 1. the pure state machine (`inbound-delivery-machine.ts`) — the model,
10
- * 2. the shadow (`inbound-delivery-machine-shadow.ts`) — module-scope
11
- * machine state advanced by `shadowEmit`, now AUTHORITATIVE for the
12
- * turn-in-flight gate via `isMachineInTurn()`,
13
- * 3. the legacy imperative `claudeBusyKeys` set — still fully maintained
14
- * in parallel and read as the kill-switch fallback.
15
- *
16
- * Per #2794 the standing risk is DRIFT: because the same turn-lifecycle
17
- * fact ("is a turn in flight?") is tracked in two live places, a fix in
18
- * one can silently diverge from the other. The machine is authoritative
19
- * today, but `claudeBusyKeys` is still wired and still read on the
20
- * kill-switch path — so the two MUST NOT drift in the dangerous direction.
21
- *
22
- * ## What "agree" means (and the ONE intended divergence)
23
- *
24
- * The machine and `claudeBusyKeys` are designed to agree on every
25
- * WELL-FORMED schedule (every turnStart has a matching turnEnd). They are
26
- * deliberately allowed to diverge in exactly ONE direction on a MALFORMED
27
- * schedule — an orphaned turnStart (turn B opens before turn A's turnEnd
28
- * ever lands, the gymbro/clerk 5-min dangle of 2026-05-28):
29
- *
30
- * - `busykeys_dangle` — machine reads idle (self-healed via TTL tick or
31
- * single-activeTurn reopen) while `claudeBusyKeys`
32
- * still holds an orphan key. This is EXPECTED and
33
- * GOOD: it is precisely the wedge the machine was
34
- * made authoritative to kill. Not a drift alarm.
35
- *
36
- * - `machine_over_holds` — machine reads in-flight while `claudeBusyKeys`
37
- * is empty. This is the DANGEROUS direction: the
38
- * now-authoritative gate would hold closed while
39
- * the imperative view says idle — a NEW wedge
40
- * class the cutover must never introduce. This
41
- * is the drift #2794 warns about; surface it.
42
- *
43
- * This module is a pure classifier plus a log-only runtime probe. It
44
- * performs NO I/O of its own beyond an optional injected log sink and
45
- * changes NO delivery behaviour — it only observes. Deleting it once the
46
- * `claudeBusyKeys` shadow is removed in PR4 is a no-op for behaviour.
47
- */
48
-
49
- export type GateParityDivergence = 'none' | 'busykeys_dangle' | 'machine_over_holds'
50
-
51
- /**
52
- * Classify the relationship between the machine's authoritative
53
- * turn-in-flight read and the legacy imperative `claudeBusyKeys` size.
54
- * Pure — no side effects.
55
- */
56
- export function gateParityDivergence(
57
- machineInTurn: boolean,
58
- busyKeysSize: number,
59
- ): GateParityDivergence {
60
- const busy = busyKeysSize > 0
61
- if (machineInTurn === busy) return 'none'
62
- // machine idle, busyKeys non-empty → the orphan dangle the machine heals.
63
- if (!machineInTurn && busy) return 'busykeys_dangle'
64
- // machine in-flight, busyKeys empty → the dangerous over-hold.
65
- return 'machine_over_holds'
66
- }
67
-
68
- /**
69
- * True only for divergences that indicate a real cutover regression
70
- * (the machine holding a gate the imperative shadow believes is open).
71
- * The benign `busykeys_dangle` — the very wedge the machine fixes — is
72
- * NOT flagged, so this probe has zero false positives on the known-good
73
- * self-heal path.
74
- */
75
- export function isDangerousGateDivergence(d: GateParityDivergence): boolean {
76
- return d === 'machine_over_holds'
77
- }
78
-
79
- /**
80
- * Log-only runtime drift canary. Call at the authoritative gate read.
81
- * Emits a single grep-friendly `gw-trace gate-drift` line ONLY on the
82
- * dangerous over-hold direction. Returns the machine value UNCHANGED so
83
- * it can wrap the gate read without altering behaviour:
84
- *
85
- * return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
86
- *
87
- * @param log optional sink (default stderr) — test hook.
88
- */
89
- export function probeGateParity(
90
- machineInTurn: boolean,
91
- busyKeysSize: number,
92
- log: (line: string) => void = (line) => process.stderr.write(line),
93
- ): boolean {
94
- const d = gateParityDivergence(machineInTurn, busyKeysSize)
95
- if (isDangerousGateDivergence(d)) {
96
- log(
97
- `gw-trace gate-drift kind=${d} machineInTurn=${machineInTurn} ` +
98
- `busyKeys=${busyKeysSize} note=machine-authoritative-gate-holds-while-imperative-idle\n`,
99
- )
100
- }
101
- return machineInTurn
102
- }
@@ -1,192 +0,0 @@
1
- /**
2
- * #2787 Mechanism B — gateway-glue coverage for the `claudeBusyKeys` orphan
3
- * reaper and its lockstep shadow timestamp map.
4
- *
5
- * `gateway.ts` cannot be imported by a unit test — it boots the Telegram bot and
6
- * the IPC listener on module load. So this harness wires the SAME containers the
7
- * gateway owns (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`), the SAME
8
- * delivery-confirm queue used as the reaper's proof gate, and the REAL
9
- * `flushOnAgentDisconnect` used at bridge death — exactly as the gateway does
10
- * (see `markClaudeBusyForInbound`, `reapOrphanBusyKeysNow`, the delivery-confirm
11
- * sweep, and the `flushOnAgentDisconnect({ claudeBusyKeys, claudeBusyKeySince })`
12
- * call). It reproduces the two real defects the pure-helper tests could not:
13
- *
14
- * 1. Lockstep across a disconnect → reconnect → re-mark: a stale
15
- * pre-disconnect timestamp must NOT survive to make a freshly-marked key
16
- * look orphan-old.
17
- * 2. Slow-delivery safety: a merely-slow (up to ~5-min, #1922) delivery whose
18
- * key is still tracked in the confirm queue must NOT be reaped even long
19
- * past the grace, because `currentTurn == null` during the eager-mark→
20
- * enqueue window is not proof of idle.
21
- */
22
-
23
- import { describe, it, expect, vi } from 'vitest'
24
- import {
25
- markBusyKeyLockstep,
26
- reapOrphanBusyKeys,
27
- clearBusyKeyLockstep,
28
- } from '../gateway/busy-key-reaper.js'
29
- import { flushOnAgentDisconnect } from '../gateway/disconnect-flush.js'
30
- import {
31
- createDeliveryQueue,
32
- trackDelivery,
33
- ackDelivery,
34
- } from '../gateway/inbound-delivery-confirm.js'
35
-
36
- const TTL = 30_000
37
-
38
- /** The gateway's `reapOrphanBusyKeysNow` glue, reproduced verbatim: no turn in
39
- * flight, reap keyed on set membership + a delivery-queue proof gate. */
40
- function reap(
41
- keys: Set<string>,
42
- since: Map<string, number>,
43
- q: ReturnType<typeof createDeliveryQueue>,
44
- now: number,
45
- ttlMs = TTL,
46
- ): string[] {
47
- return reapOrphanBusyKeys(keys, since, now, {
48
- ttlMs,
49
- hasPendingDelivery: (key) => q.pending.has(key),
50
- log: () => {},
51
- })
52
- }
53
-
54
- /** Minimal disconnect-flush deps carrying the two containers under test — the
55
- * real bridge-death path that clears busy keys. Mirrors the gateway's call. */
56
- function flushDeps(claudeBusyKeys: Set<string>, claudeBusyKeySince: Map<string, number>) {
57
- return {
58
- agentName: 'clerk',
59
- activeStatusReactions: new Map(),
60
- activeReactionMsgIds: new Map(),
61
- activeTurnStartedAt: new Map<string, number>(),
62
- claudeBusyKeys,
63
- claudeBusyKeySince,
64
- activeDraftStreams: new Map(),
65
- clearActiveReactions: vi.fn(),
66
- disposeProgressDriver: vi.fn(),
67
- stopTurnTypingLoops: vi.fn(), // #2650: required dep — turn-typing sweep on bridge death
68
- log: vi.fn(),
69
- }
70
- }
71
-
72
- describe('#2787 Mechanism B — orphan reaper (gateway glue)', () => {
73
- it('does NOT reap a slow-but-real delivery still tracked in the confirm queue, even far past the grace', () => {
74
- const keys = new Set<string>()
75
- const since = new Map<string, number>()
76
- const q = createDeliveryQueue<{ text: string }>()
77
-
78
- // Eager mark at delivery + track for ack (the normal user-inbound glue).
79
- markBusyKeyLockstep(keys, since, '-100:4', 0)
80
- trackDelivery(q, '-100:4', { text: 'slow but real' }, 0, '9001')
81
-
82
- // currentTurn is still null 6 MINUTES later (the #1922 tail) — but the
83
- // delivery is still pending its enqueue ack. The proof gate must protect it.
84
- expect(reap(keys, since, q, 360_000)).toEqual([])
85
- expect(keys.has('-100:4')).toBe(true)
86
-
87
- // Once claude finally acks (enqueue lands), the queue entry clears; the
88
- // gateway would then hold currentTurn and clear busy at turn_end.
89
- expect(ackDelivery(q, '-100:4', '9001')).toBe(true)
90
- })
91
-
92
- it('reaps a TRUE orphan — busy-marked, no pending delivery, past the grace', () => {
93
- const keys = new Set<string>()
94
- const since = new Map<string, number>()
95
- const q = createDeliveryQueue()
96
-
97
- // A steer/interrupt inbound marks busy but is excluded from tracking
98
- // (shouldTrackDelivery=false) — no queue entry. Its turn vanished without
99
- // clearing busy: a genuine stuck marker.
100
- markBusyKeyLockstep(keys, since, '555:_', 0)
101
- expect(reap(keys, since, q, TTL)).toEqual(['555:_'])
102
- expect(keys.has('555:_')).toBe(false)
103
- expect(since.has('555:_')).toBe(false)
104
- })
105
-
106
- it('does not reap before the grace elapses', () => {
107
- const keys = new Set<string>()
108
- const since = new Map<string, number>()
109
- const q = createDeliveryQueue()
110
- markBusyKeyLockstep(keys, since, '555:_', 0)
111
- expect(reap(keys, since, q, TTL - 1)).toEqual([])
112
- expect(keys.has('555:_')).toBe(true)
113
- })
114
-
115
- it('disconnect → reconnect → re-mark: fresh timestamp, key NOT reaped as stale (the lockstep fix)', () => {
116
- const keys = new Set<string>()
117
- const since = new Map<string, number>()
118
- const q = createDeliveryQueue()
119
-
120
- // t=0 first mark.
121
- markBusyKeyLockstep(keys, since, '-100:4', 0)
122
-
123
- // Bridge dies at t=1000 — the REAL disconnect flush clears BOTH containers
124
- // in lockstep (belt-and-suspenders to the set-membership guard).
125
- flushOnAgentDisconnect(flushDeps(keys, since))
126
- expect(keys.size).toBe(0)
127
- expect(since.size).toBe(0)
128
-
129
- // Reconnect and re-mark the SAME key at t=300_000 (well after the old stamp).
130
- markBusyKeyLockstep(keys, since, '-100:4', 300_000)
131
-
132
- // Pre-fix (stale t=0 stamp surviving the disconnect + a `!since.has` guard
133
- // declining to re-stamp) the age would read 300_000ms ≫ grace and the key
134
- // would be reaped THE INSTANT it was re-marked — re-opening the idle-drain
135
- // while claude is about to process this very inbound. With the fix the stamp
136
- // is fresh (t=300_000), so at t just past the re-mark it is NOT reaped.
137
- expect(reap(keys, since, q, 305_000)).toEqual([]) // age 5s < 30s grace
138
- expect(keys.has('-100:4')).toBe(true)
139
-
140
- // ...and it DOES reap once the fresh grace genuinely elapses.
141
- expect(reap(keys, since, q, 300_000 + TTL)).toEqual(['-100:4'])
142
- })
143
-
144
- it('disconnect while a delivery is pending: reconnect re-mark stays protected by the proof gate', () => {
145
- const keys = new Set<string>()
146
- const since = new Map<string, number>()
147
- const q = createDeliveryQueue<{ text: string }>()
148
-
149
- markBusyKeyLockstep(keys, since, '-100:4', 0)
150
- trackDelivery(q, '-100:4', { text: 'inflight' }, 0, '42')
151
-
152
- // Bridge flap: disconnect clears busy state; the offline buffer / re-deliver
153
- // loop still owns the inbound. Reconnect re-marks + re-tracks it.
154
- flushOnAgentDisconnect(flushDeps(keys, since))
155
- markBusyKeyLockstep(keys, since, '-100:4', 5_000)
156
- trackDelivery(q, '-100:4', { text: 'inflight' }, 5_000, '42')
157
-
158
- // Even minutes later, still pending its ack → never reaped.
159
- expect(reap(keys, since, q, 5_000 + 400_000)).toEqual([])
160
- expect(keys.has('-100:4')).toBe(true)
161
- })
162
-
163
- it('prunes shadow-map entries whose key already left the set (no unbounded growth)', () => {
164
- const keys = new Set<string>()
165
- const since = new Map<string, number>()
166
- const q = createDeliveryQueue()
167
- // Simulate a direct clear that (hypothetically) left the map behind.
168
- since.set('ghost:_', 0)
169
- reap(keys, since, q, 999_999)
170
- expect(since.has('ghost:_')).toBe(false)
171
- })
172
-
173
- it('re-mark of an already-busy key does NOT reset its timestamp (measures the true dangle)', () => {
174
- const keys = new Set<string>()
175
- const since = new Map<string, number>()
176
- const q = createDeliveryQueue()
177
- markBusyKeyLockstep(keys, since, 'k:_', 0)
178
- markBusyKeyLockstep(keys, since, 'k:_', 20_000) // still busy → no re-stamp
179
- expect(since.get('k:_')).toBe(0)
180
- // Orphaned (no pending delivery) → reaps on the ORIGINAL stamp's grace.
181
- expect(reap(keys, since, q, TTL)).toEqual(['k:_'])
182
- })
183
-
184
- it('clearBusyKeyLockstep removes from both containers', () => {
185
- const keys = new Set<string>()
186
- const since = new Map<string, number>()
187
- markBusyKeyLockstep(keys, since, 'k:_', 0)
188
- clearBusyKeyLockstep(keys, since, 'k:_')
189
- expect(keys.has('k:_')).toBe(false)
190
- expect(since.has('k:_')).toBe(false)
191
- })
192
- })