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.
- package/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +4 -2
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +708 -255
- package/dist/host-control/main.js +5 -3
- package/dist/vault/approvals/kernel-server.js +4 -2
- package/dist/vault/broker/server.js +4 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +142 -7
- package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +5479 -7069
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/subagent-watcher.ts +111 -28
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +27 -3
- package/telegram-plugin/worker-activity-feed.ts +24 -0
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- 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
|
-
|
|
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
|
-
})
|