switchroom 0.18.23 โ†’ 0.18.25

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 (45) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1608 -841
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +524 -16
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/reply-owner-resolve.ts +160 -0
  15. package/telegram-plugin/session-tail.ts +185 -0
  16. package/telegram-plugin/subagent-watcher.ts +45 -0
  17. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  18. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  19. package/telegram-plugin/tests/history.test.ts +91 -0
  20. package/telegram-plugin/tests/model-command.test.ts +189 -12
  21. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  22. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
  24. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  25. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  26. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  27. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  28. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  29. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  30. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
  31. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  32. package/telegram-plugin/tool-activity-summary.ts +54 -3
  33. package/telegram-plugin/worker-activity-feed.ts +222 -10
  34. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  35. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  36. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  37. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  38. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  39. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  40. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  42. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  45. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -7,7 +7,9 @@ import {
7
7
  renderActivityFeed,
8
8
  renderActivityFeedWithNested,
9
9
  renderActivityHeader,
10
+ renderCombinedWorkerFeed,
10
11
  formatFeedElapsed,
12
+ formatTokenCount,
11
13
  formatStepSuffix,
12
14
  STEP_TIMER_MIN_MS,
13
15
  type SessionActivityHeader,
@@ -745,6 +747,80 @@ describe("renderActivityHeader โ€” two-line header builder", () => {
745
747
  const [, h2synth] = renderActivityHeader("๐Ÿค–", "Agent", "", 15_000, 7, "running", "<synthetic>");
746
748
  expect(h2synth).toBe("_15s ยท 7 tools_");
747
749
  });
750
+
751
+ it("inserts the token segment between tool count and model (running)", () => {
752
+ const [, h2] = renderActivityHeader("๐Ÿ› ", "Worker", "run tests", 120_000, 14, "running", "claude-opus-4-8", 12_400);
753
+ expect(h2).toBe("_2m00s ยท 14 tools ยท 12.4k tok ยท opus 4.8_");
754
+ });
755
+
756
+ it("inserts the token segment before elapsed on the done line", () => {
757
+ const [, h2] = renderActivityHeader("๐Ÿ› ", "Worker", "run tests", 65_000, 3, "done", undefined, 940);
758
+ expect(h2).toBe("_done ยท 3 tools ยท 940 tok ยท 1m05s_");
759
+ });
760
+
761
+ it("omits the token segment when total is 0 / undefined (both variants consistent)", () => {
762
+ const [, running] = renderActivityHeader("๐Ÿ› ", "Worker", "", 15_000, 7, "running", undefined, 0);
763
+ expect(running).toBe("_15s ยท 7 tools_");
764
+ const [, undef] = renderActivityHeader("๐Ÿ› ", "Worker", "", 15_000, 7, "running");
765
+ expect(undef).toBe("_15s ยท 7 tools_");
766
+ const [, doneZero] = renderActivityHeader("๐Ÿ› ", "Worker", "", 65_000, 3, "done", undefined, 0);
767
+ expect(doneZero).toBe("_done ยท 3 tools ยท 1m05s_");
768
+ });
769
+
770
+ it("renders tokens and model together, in order", () => {
771
+ const [, h2] = renderActivityHeader("๐Ÿ› ", "Worker", "", 10_000, 2, "running", "sr-glm-5", 1_200_000);
772
+ expect(h2).toBe("_10s ยท 2 tools ยท 1.2M tok ยท sr-glm-5_");
773
+ });
774
+ });
775
+
776
+ describe("formatTokenCount โ€” compact token formatter", () => {
777
+ it("renders sub-1000 as a raw integer", () => {
778
+ expect(formatTokenCount(0)).toBe("0");
779
+ expect(formatTokenCount(1)).toBe("1");
780
+ expect(formatTokenCount(999)).toBe("999");
781
+ });
782
+
783
+ it("renders >=1000 as one-decimal k at the boundary", () => {
784
+ expect(formatTokenCount(1000)).toBe("1.0k");
785
+ expect(formatTokenCount(12_400)).toBe("12.4k");
786
+ expect(formatTokenCount(999_499)).toBe("999.5k");
787
+ });
788
+
789
+ it("renders >=1e6 as one-decimal M at the boundary", () => {
790
+ expect(formatTokenCount(1_000_000)).toBe("1.0M");
791
+ expect(formatTokenCount(1_200_000)).toBe("1.2M");
792
+ });
793
+
794
+ it("promotes the kโ†’M rounding boundary (never renders 1000.0k)", () => {
795
+ // 999_949 rounds down to 999.9k; from 999_950 the 1-decimal k rounds to
796
+ // 1000.0 which must roll into the M branch as 1.0M.
797
+ expect(formatTokenCount(999_949)).toBe("999.9k");
798
+ expect(formatTokenCount(999_950)).toBe("1.0M");
799
+ expect(formatTokenCount(999_999)).toBe("1.0M");
800
+ expect(formatTokenCount(1_000_000)).toBe("1.0M");
801
+ });
802
+
803
+ it("clamps negatives / non-finite to 0", () => {
804
+ expect(formatTokenCount(-5)).toBe("0");
805
+ expect(formatTokenCount(Number.NaN)).toBe("0");
806
+ expect(formatTokenCount(Number.POSITIVE_INFINITY)).toBe("0");
807
+ });
808
+ });
809
+
810
+ describe("renderCombinedWorkerFeed โ€” token segment on the row header", () => {
811
+ it("shows `ยท N tok` per worker row and omits it at 0", () => {
812
+ const body = renderCombinedWorkerFeed(
813
+ [
814
+ { description: "alpha", elapsedMs: 15_000, toolCount: 3, currentStep: "Reading a.ts", totalTokens: 12_400 },
815
+ { description: "beta", elapsedMs: 20_000, toolCount: 5, currentStep: "Running tests", totalTokens: 0 },
816
+ ],
817
+ { maxRows: 5 },
818
+ );
819
+ expect(body).not.toBeNull();
820
+ expect(body!).toContain("**alpha** _ยท 15s ยท 3 tools ยท 12.4k tok_");
821
+ expect(body!).toContain("**beta** _ยท 20s ยท 5 tools_");
822
+ expect(body!).not.toContain("beta** _ยท 20s ยท 5 tools ยท 0 tok");
823
+ });
748
824
  });
749
825
 
750
826
  describe("agent flat path routes through the shared step-feed primitive", () => {
@@ -852,6 +928,39 @@ describe("renderActivityFeed โ€” header param (main-session card fix)", () => {
852
928
  expect(nested).toContain("ยท opus 4.8_");
853
929
  });
854
930
 
931
+ it("threads the parent's OWN totalTokens through the flat and nested turn-card paths", () => {
932
+ const header: SessionActivityHeader = {
933
+ label: "Agent",
934
+ elapsedMs: 120_000,
935
+ toolCount: 14,
936
+ state: "running",
937
+ model: "claude-opus-4-8",
938
+ totalTokens: 12_400,
939
+ };
940
+ // Flat path (no foreground sub-agent).
941
+ const flat = renderActivityFeed(["Searching memory"], false, "", undefined, header)!;
942
+ expect(flat).toContain("_2m00s ยท 14 tools ยท 12.4k tok ยท opus 4.8_");
943
+ // Nested path (with a child line) carries the same token segment.
944
+ const nested = renderActivityFeedWithNested(["Reading"], ["nested step"], false, "", undefined, header)!;
945
+ expect(nested).toContain("ยท 12.4k tok ยท");
946
+ });
947
+
948
+ it("omits the token segment on the turn card when totalTokens is 0 / undefined", () => {
949
+ const zero: SessionActivityHeader = {
950
+ label: "Agent",
951
+ elapsedMs: 15_000,
952
+ toolCount: 7,
953
+ state: "running",
954
+ totalTokens: 0,
955
+ };
956
+ const outZero = renderActivityFeed(["Searching memory"], false, "", undefined, zero)!;
957
+ expect(outZero).toContain("_15s ยท 7 tools_");
958
+ expect(outZero).not.toContain("tok");
959
+ // undefined behaves identically.
960
+ const undef: SessionActivityHeader = { label: "Agent", elapsedMs: 15_000, toolCount: 7, state: "running" };
961
+ expect(renderActivityFeed(["Searching memory"], false, "", undefined, undef)).toBe(outZero);
962
+ });
963
+
855
964
  it("prepends the done header when final=true", () => {
856
965
  const header: SessionActivityHeader = {
857
966
  label: "Agent",
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { projectTrailingAnswerFromTranscript } from '../session-tail.js'
3
+
4
+ /**
5
+ * Pins the crash-survival redelivery transcript projector: recovering the
6
+ * finished-but-never-sent trailing answer from a session JSONL after a
7
+ * pre-flush crash, and refusing to redeliver a dangling mid-tool preamble.
8
+ */
9
+
10
+ function assistantText(text: string): string {
11
+ return JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } })
12
+ }
13
+ function assistantToolUse(name: string): string {
14
+ return JSON.stringify({
15
+ type: 'assistant',
16
+ message: { content: [{ type: 'tool_use', name, id: 'toolu_x', input: {} }] },
17
+ })
18
+ }
19
+ function enqueue(text: string): string {
20
+ return JSON.stringify({ type: 'queue-operation', operation: 'enqueue', content: text })
21
+ }
22
+ /** A real inbound user message line (string content) โ€” a turn separator the
23
+ * projection kernel emits NO event for, unlike an `enqueue`. */
24
+ function userMessage(text: string): string {
25
+ return JSON.stringify({ type: 'user', message: { role: 'user', content: text } })
26
+ }
27
+ /** A tool_result carrier user line โ€” NOT a turn boundary. */
28
+ function toolResult(toolUseId: string): string {
29
+ return JSON.stringify({
30
+ type: 'user',
31
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'ok' }] },
32
+ })
33
+ }
34
+
35
+ describe('projectTrailingAnswerFromTranscript', () => {
36
+ it('recovers the trailing answer text after the last tool_use', () => {
37
+ const transcript = [
38
+ enqueue('what is the deploy status?'),
39
+ assistantText('Let me check.'),
40
+ assistantToolUse('Bash'),
41
+ assistantText('The deploy finished โ€” all three services are green.'),
42
+ ].join('\n')
43
+ const r = projectTrailingAnswerFromTranscript(transcript)
44
+ expect(r.trailingIsText).toBe(true)
45
+ expect(r.text).toBe('The deploy finished โ€” all three services are green.')
46
+ })
47
+
48
+ it('does NOT treat a dangling tool_use as a redeliverable answer', () => {
49
+ const transcript = [
50
+ enqueue('run the migration'),
51
+ assistantText('On it โ€” running the migration now.'),
52
+ assistantToolUse('Bash'),
53
+ ].join('\n')
54
+ const r = projectTrailingAnswerFromTranscript(transcript)
55
+ expect(r.trailingIsText).toBe(false)
56
+ // the preamble was reset by the tool_use; nothing trailing to send
57
+ expect(r.text).toBe('')
58
+ })
59
+
60
+ it('scopes to the LAST turn only (a prior turn answer is not resurfaced)', () => {
61
+ const transcript = [
62
+ enqueue('first question'),
63
+ assistantText('First answer.'),
64
+ enqueue('second question'),
65
+ assistantText('Second answer.'),
66
+ ].join('\n')
67
+ const r = projectTrailingAnswerFromTranscript(transcript)
68
+ expect(r.text).toBe('Second answer.')
69
+ })
70
+
71
+ it('suppresses an isApiErrorMessage synthetic line (never redeliver an error string)', () => {
72
+ const transcript = [
73
+ enqueue('do the thing'),
74
+ JSON.stringify({
75
+ type: 'assistant',
76
+ isApiErrorMessage: true,
77
+ message: { content: [{ type: 'text', text: "You've hit your limit ยท resets 5pm" }] },
78
+ }),
79
+ ].join('\n')
80
+ const r = projectTrailingAnswerFromTranscript(transcript)
81
+ expect(r.text).toBe('')
82
+ expect(r.trailingIsText).toBe(false)
83
+ })
84
+
85
+ // diff-review defect #2 โ€” a real claude .jsonl separates turns with a plain
86
+ // `type:"user"` message line the kernel ignores, NOT always an interleaved
87
+ // enqueue queue-operation. Two turns with no intervening tool_use must not
88
+ // concatenate; only the LAST turn's trailing text is projected.
89
+ it('breaks turns on a plain user-message separator (no enqueue, no tool_use)', () => {
90
+ const transcript = [
91
+ userMessage('first question'),
92
+ assistantText('First answer.'),
93
+ userMessage('second question'),
94
+ assistantText('Second answer.'),
95
+ ].join('\n')
96
+ const r = projectTrailingAnswerFromTranscript(transcript)
97
+ expect(r.text).toBe('Second answer.')
98
+ expect(r.trailingIsText).toBe(true)
99
+ })
100
+
101
+ it('does NOT treat a tool_result user line as a turn boundary (answer still accretes)', () => {
102
+ const transcript = [
103
+ userMessage('do the thing'),
104
+ assistantToolUse('Bash'),
105
+ toolResult('toolu_x'),
106
+ assistantText('All done โ€” the thing is done.'),
107
+ ].join('\n')
108
+ const r = projectTrailingAnswerFromTranscript(transcript)
109
+ expect(r.text).toBe('All done โ€” the thing is done.')
110
+ expect(r.trailingIsText).toBe(true)
111
+ })
112
+
113
+ it('concatenates multiple trailing text blocks of the final answer', () => {
114
+ const transcript = [
115
+ enqueue('q'),
116
+ assistantToolUse('Read'),
117
+ assistantText('Part one. '),
118
+ assistantText('Part two.'),
119
+ ].join('\n')
120
+ const r = projectTrailingAnswerFromTranscript(transcript)
121
+ expect(r.text).toBe('Part one. Part two.')
122
+ expect(r.trailingIsText).toBe(true)
123
+ })
124
+ })
@@ -16,6 +16,8 @@ import { describe, it, expect } from 'vitest'
16
16
  import {
17
17
  buildVaultGrantApprovedInbound,
18
18
  buildVaultGrantApprovedCardText,
19
+ normalizeGrantReason,
20
+ MAX_GRANT_REASON_CHARS,
19
21
  buildVaultGrantDeniedInbound,
20
22
  buildVaultSaveCompletedInbound,
21
23
  buildVaultSaveFailedInbound,
@@ -85,6 +87,55 @@ describe('buildVaultGrantApprovedCardText โ€” the [object Object] regression gua
85
87
  expect(noFooter.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
86
88
  })
87
89
 
90
+ it('renders the reason as a trailing italic clause when provided, before the footer', () => {
91
+ const text = buildVaultGrantApprovedCardText({
92
+ ...BASE,
93
+ reasonEscaped: 'deploy the staging build',
94
+ footer: '\n_Approver verified by Telegram identity._',
95
+ })
96
+ expect(text).toContain('_Reason: deploy the staging build_')
97
+ // reason clause sits BEFORE the footer (auth-mode note stays last)
98
+ expect(text.indexOf('_Reason:')).toBeLessThan(text.indexOf('_Approver verified'))
99
+ // still one clean string
100
+ const rich = richMessage(text)
101
+ expect(rich.markdown).toBe(text)
102
+ expect(rich.markdown).not.toContain('[object Object]')
103
+ })
104
+
105
+ it('omits the reason clause when reason is undefined or empty โ€” no dangling "Reason:" label', () => {
106
+ const none = buildVaultGrantApprovedCardText(BASE)
107
+ expect(none).not.toContain('Reason:')
108
+ expect(none.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
109
+
110
+ const empty = buildVaultGrantApprovedCardText({ ...BASE, reasonEscaped: '' })
111
+ expect(empty).not.toContain('Reason:')
112
+ expect(empty.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
113
+ })
114
+
115
+ it('reason clause + reason-less both stay clean when combined with a footer', () => {
116
+ const footer = '\n_Approver verified by Telegram identity._'
117
+ const withReason = buildVaultGrantApprovedCardText({ ...BASE, reasonEscaped: 'x', footer })
118
+ expect(withReason.endsWith(footer)).toBe(true)
119
+ const noReason = buildVaultGrantApprovedCardText({ ...BASE, footer })
120
+ expect(noReason.endsWith(footer)).toBe(true)
121
+ expect(noReason).not.toContain('Reason:')
122
+ })
123
+
124
+ it('carries an already-escaped reason verbatim โ€” HTML-special chars stay escaped, no corruption', () => {
125
+ // The callsite escapes agent-supplied free text with escapeHtmlForTg
126
+ // BEFORE passing it (same contract as agentEscaped). Simulate that:
127
+ // `<b> & "co" *_` โ†’ escaped form. The builder must not re-corrupt it,
128
+ // and the single-richMessage wrap must still hold.
129
+ const escaped = '&lt;b&gt; &amp; drop &amp;&amp; run *_'
130
+ const text = buildVaultGrantApprovedCardText({ ...BASE, reasonEscaped: escaped })
131
+ expect(text).toContain(`_Reason: ${escaped}_`)
132
+ // No raw unescaped angle brackets leaked into the card text.
133
+ expect(text).not.toContain('<b>')
134
+ const rich = richMessage(text)
135
+ expect(rich.markdown).toBe(text)
136
+ expect(rich.markdown).not.toContain('[object Object]')
137
+ })
138
+
88
139
  it('DOCUMENTS the original bug: bare-string + richMessage() coerces to [object Object]', () => {
89
140
  // Demonstrates why the fix matters โ€” if a future edit reintroduces the
90
141
  // "concatenate a string onto the richMessage object" pattern, the result
@@ -94,6 +145,80 @@ describe('buildVaultGrantApprovedCardText โ€” the [object Object] regression gua
94
145
  })
95
146
  })
96
147
 
148
+ describe('normalizeGrantReason โ€” single-line, trimmed, length-capped', () => {
149
+ it('returns "" for undefined, null, empty, or whitespace-only input (clean no-op)', () => {
150
+ expect(normalizeGrantReason(undefined)).toBe('')
151
+ expect(normalizeGrantReason(null)).toBe('')
152
+ expect(normalizeGrantReason('')).toBe('')
153
+ expect(normalizeGrantReason(' ')).toBe('')
154
+ expect(normalizeGrantReason('\n\t \n')).toBe('')
155
+ })
156
+
157
+ it('collapses newlines and whitespace runs to a single space (protects the _โ€ฆ_ emphasis)', () => {
158
+ expect(normalizeGrantReason('deploy\nthe staging\tbuild')).toBe('deploy the staging build')
159
+ // no newline survives to break the italic clause
160
+ expect(normalizeGrantReason('a\nb')).not.toContain('\n')
161
+ })
162
+
163
+ it('trims leading/trailing whitespace', () => {
164
+ expect(normalizeGrantReason(' clone the repo ')).toBe('clone the repo')
165
+ })
166
+
167
+ it('caps over-long input with a trailing ellipsis, at MAX_GRANT_REASON_CHARS', () => {
168
+ const long = 'x'.repeat(MAX_GRANT_REASON_CHARS + 50)
169
+ const out = normalizeGrantReason(long)
170
+ expect(out.length).toBe(MAX_GRANT_REASON_CHARS)
171
+ expect(out.endsWith('โ€ฆ')).toBe(true)
172
+ expect(out.slice(0, -1)).toBe('x'.repeat(MAX_GRANT_REASON_CHARS - 1))
173
+ })
174
+
175
+ it('leaves an at-limit reason untouched (no spurious ellipsis)', () => {
176
+ const exact = 'y'.repeat(MAX_GRANT_REASON_CHARS)
177
+ const out = normalizeGrantReason(exact)
178
+ expect(out).toBe(exact)
179
+ expect(out.endsWith('โ€ฆ')).toBe(false)
180
+ })
181
+
182
+ it('feeds cleanly through the card builder: whitespace-only โ†’ no clause, newline โ†’ single-line clause', () => {
183
+ // whitespace-only normalizes to '' โ†’ builder omits the clause
184
+ const wsCard = buildVaultGrantApprovedCardText({
185
+ agentEscaped: 'gymbro',
186
+ scope: 'read',
187
+ key: 'k',
188
+ days: 30,
189
+ grantId: 'vg_x',
190
+ reasonEscaped: normalizeGrantReason(' ') || undefined,
191
+ })
192
+ expect(wsCard).not.toContain('Reason:')
193
+
194
+ // newline reason renders as a single-line italic clause, no broken emphasis
195
+ const nlCard = buildVaultGrantApprovedCardText({
196
+ agentEscaped: 'gymbro',
197
+ scope: 'read',
198
+ key: 'k',
199
+ days: 30,
200
+ grantId: 'vg_x',
201
+ reasonEscaped: normalizeGrantReason('line one\nline two') || undefined,
202
+ })
203
+ expect(nlCard).toContain('_Reason: line one line two_')
204
+ expect(nlCard).not.toContain('\nline two')
205
+ })
206
+
207
+ it('over-long reason renders truncated with ellipsis inside the clause', () => {
208
+ const reason = normalizeGrantReason('z'.repeat(MAX_GRANT_REASON_CHARS + 100)) || undefined
209
+ const card = buildVaultGrantApprovedCardText({
210
+ agentEscaped: 'gymbro',
211
+ scope: 'read',
212
+ key: 'k',
213
+ days: 30,
214
+ grantId: 'vg_x',
215
+ reasonEscaped: reason,
216
+ })
217
+ expect(card).toContain('โ€ฆ_')
218
+ expect(card).toContain(`_Reason: ${'z'.repeat(MAX_GRANT_REASON_CHARS - 1)}โ€ฆ_`)
219
+ })
220
+ })
221
+
97
222
  describe('buildVaultGrantApprovedInbound', () => {
98
223
  it('emits the canonical envelope (type, chat_id, user, userId, ts, messageId)', () => {
99
224
  const msg = buildVaultGrantApprovedInbound({
@@ -768,7 +768,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
768
768
  * authoritative terminal sweep (`terminate`, driven by `onTerminalCleanup`)
769
769
  * PLUS a backstop TTL sweep. These assert the OUTCOMES, not the code paths.
770
770
  */
771
- function ghostHarness(opts: { staleWorkerTtlMs?: number; now: () => number }) {
771
+ function ghostHarness(opts: { staleWorkerTtlMs?: number; absoluteRowLifetimeCapMs?: number; now: () => number }) {
772
772
  const edits: { messageId: number; text: string }[] = []
773
773
  const sends: { text: string }[] = []
774
774
  const pins: { messageId: number | null }[] = []
@@ -792,6 +792,7 @@ function ghostHarness(opts: { staleWorkerTtlMs?: number; now: () => number }) {
792
792
  setInterval: () => 0,
793
793
  clearInterval: () => {},
794
794
  staleWorkerTtlMs: opts.staleWorkerTtlMs,
795
+ absoluteRowLifetimeCapMs: opts.absoluteRowLifetimeCapMs,
795
796
  reconcilePin: ({ messageId }) => pins.push({ messageId }),
796
797
  })
797
798
  return { feed, edits, sends, pins }
@@ -891,6 +892,121 @@ describe('worker-feed ghost-leak โ€” deterministic terminal removal + backstop',
891
892
  expect(feed.size).toBe(1)
892
893
  })
893
894
 
895
+ // โ”€โ”€ ABSOLUTE row-lifetime cap (Carrie 5h zombie pin, v0.18.23) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
896
+ // The silence-keyed `staleWorkerTtlMs` backstop is defeated by a leaked row
897
+ // that never finishes AND keeps receiving `update()` cues every heartbeat:
898
+ // each cue resets `lastUpdateAt`, so the silence sweep can NEVER match and the
899
+ // pinned card lives forever (observed: 5h, re-edited 3000+ times). The
900
+ // absolute cap is anchored to the row's IMMUTABLE creation time, so it reaps
901
+ // the row regardless of how recently it updated.
902
+ it('ABSOLUTE cap reaps an immortal-but-UPDATING row that resets lastUpdateAt every tick, then unpins', async () => {
903
+ let t = 0
904
+ // Silence TTL huge (would never fire); absolute cap small. The row keeps
905
+ // updating just under the silence TTL each step, so ONLY the absolute cap
906
+ // (immune to the lastUpdateAt reset) can catch it.
907
+ const { feed, edits, pins } = ghostHarness({
908
+ staleWorkerTtlMs: 1_000_000,
909
+ absoluteRowLifetimeCapMs: 5000,
910
+ now: () => t,
911
+ })
912
+ await feed.update('z', 'chat', view('zombie task', 's0', 0))
913
+ await drain()
914
+ expect(feed.size).toBe(1)
915
+ expect(pins.at(-1)?.messageId).not.toBeNull()
916
+
917
+ // Drive continuous updates past the absolute cap โ€” each resets lastUpdateAt,
918
+ // so the silence sweep stays defeated (exactly the Carrie failure mode).
919
+ for (let step = 1; step <= 8; step++) {
920
+ t = step * 1000
921
+ await feed.update('z', 'chat', view('zombie task', `s${step}`, t))
922
+ await drain()
923
+ feed.heartbeatTick()
924
+ await drain()
925
+ }
926
+ // The absolute cap (5s) has been crossed while the row kept updating โ†’
927
+ // reaped despite the fresh cues, and the shared card UNPINNED.
928
+ expect(feed.size).toBe(0)
929
+ expect(pins.at(-1)?.messageId).toBeNull()
930
+
931
+ // Immortality closed for good: the finalized gate blocks a late cue from
932
+ // resurrecting the row, and further heartbeats produce no edits.
933
+ await feed.update('z', 'chat', view('zombie task', 's-late', t))
934
+ await drain()
935
+ expect(feed.size).toBe(0)
936
+ const after = edits.length
937
+ t += 10000
938
+ feed.heartbeatTick()
939
+ await drain()
940
+ expect(edits.length).toBe(after)
941
+ })
942
+
943
+ it('ABSOLUTE cap reaps a row past the cap even when the silence TTL would never fire', async () => {
944
+ let t = 0
945
+ const { feed, pins } = ghostHarness({
946
+ staleWorkerTtlMs: 1_000_000,
947
+ absoluteRowLifetimeCapMs: 5000,
948
+ now: () => t,
949
+ })
950
+ await feed.update('f', 'chat', view('task f', 's0', 0))
951
+ await drain()
952
+ expect(pins.at(-1)?.messageId).not.toBeNull()
953
+ // Past the absolute cap with no update at all โ€” the age anchor alone reaps.
954
+ t = 6000
955
+ feed.heartbeatTick()
956
+ await drain()
957
+ expect(feed.size).toBe(0)
958
+ expect(pins.at(-1)?.messageId).toBeNull()
959
+ })
960
+
961
+ // โ”€โ”€ Boot/reconnect purge (Ken's key ask) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
962
+ // Every tracked row is a dead child sub-agent after a restart/reconnect. On a
963
+ // bare bridge reconnect the OLD feed's `wk:group:` pins are orphaned โ€” the new
964
+ // empty feed never knew those groups, so it can never unpin them, and no full-
965
+ // boot pin sweep runs. `purgeAllOnBoot()` must reconcile the feed to empty AND
966
+ // release the pin unconditionally.
967
+ it('purgeAllOnBoot empties a restored running row + pinned card and UNPINS', async () => {
968
+ let t = 0
969
+ const { feed, pins } = ghostHarness({ now: () => t })
970
+ // A running worker with a live pinned card (the "restored at startup" state).
971
+ await feed.update('p', 'chat', view('task p', 'working', 0))
972
+ await drain()
973
+ expect(feed.size).toBe(1)
974
+ expect(pins.at(-1)?.messageId).not.toBeNull()
975
+
976
+ feed.purgeAllOnBoot()
977
+
978
+ // Feed reconciled to empty AND the coalesced card unpinned (messageId null).
979
+ expect(feed.size).toBe(0)
980
+ expect(pins.at(-1)?.messageId).toBeNull()
981
+
982
+ // A late cue on the torn-down feed must NOT resurrect a row (finalized gate).
983
+ t = 100
984
+ await feed.update('p', 'chat', view('task p', 'zombie tick', 100))
985
+ await drain()
986
+ expect(feed.size).toBe(0)
987
+ })
988
+
989
+ it('purgeAllOnBoot unpins EVERY group (multiple chats) and is a no-op when already empty', async () => {
990
+ let t = 0
991
+ const { feed, pins } = ghostHarness({ now: () => t })
992
+ await feed.update('g1', 'chatA', view('a', 's', 0))
993
+ await feed.update('g2', 'chatB', view('b', 's', 0))
994
+ await drain()
995
+ expect(feed.size).toBe(2)
996
+
997
+ const pinsBefore = pins.length
998
+ feed.purgeAllOnBoot()
999
+ expect(feed.size).toBe(0)
1000
+ // Both groups emitted a final unpin (messageId null).
1001
+ const unpinsAfter = pins.slice(pinsBefore).filter((p) => p.messageId === null)
1002
+ expect(unpinsAfter.length).toBe(2)
1003
+
1004
+ // Idempotent: a second purge on an empty feed emits no further pin calls.
1005
+ const afterFirst = pins.length
1006
+ feed.purgeAllOnBoot()
1007
+ expect(pins.length).toBe(afterFirst)
1008
+ })
1009
+
894
1010
  it('no-op re-render is skipped (byte-identical body โ†’ no redundant edit)', async () => {
895
1011
  let t = 0
896
1012
  const { feed, edits } = ghostHarness({ now: () => t })