switchroom 0.16.46 → 0.17.0

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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Flagship harness for NESTED (depth-2+) sub-agent card visibility — the
3
+ * unified progress-card fix. Mirrors
4
+ * worker-visibility-prose-silent-harness.test.ts: it wires the REAL
5
+ * `startSubagentWatcher` to the REAL `createWorkerActivityFeed` on ONE
6
+ * virtual clock, with a REAL registry DB (bun:sqlite) and REAL files in a
7
+ * tempdir, and mirrors the gateway's onProgress/onFinish routing
8
+ * (resolveWorkerFeedDispatch + resolveSubagentOriginTurnKey).
9
+ *
10
+ * Scenario (the live incident shape): a depth-1 BACKGROUND worker —
11
+ * attributed to its originating turn at dispatch — spawns a nested worker
12
+ * AFTER the main turn has ended. The PreToolUse hook cannot attribute the
13
+ * nested dispatch (turn-active.json is gone) and may lose the row entirely.
14
+ * Pre-fix: the nested card posted, heartbeat-edited every ~6s, and stayed
15
+ * frozen on "starting…" forever, routed to the owner DM.
16
+ *
17
+ * Asserts the required end-state behaviour for the nested permutation:
18
+ * (a) a registry row EXISTS for the nested worker (watcher-recorded from
19
+ * the parent's JSONL), keyed so the child's meta.json links it,
20
+ * (b) the nested card reaches LIVE TOOL ACTIVITY (real steps, climbing
21
+ * tool count) — never frozen "starting…",
22
+ * (c) the card routes to the ORIGINATING chat (transitive turn-key
23
+ * inheritance), not the owner-DM fallback,
24
+ * (d) clean finalization on turn_end (card edits to done, handle dropped),
25
+ * (e) parallel-worker isolation: the depth-1 parent's own feed message is
26
+ * independent of the nested child's (no cross-worker interleaving).
27
+ *
28
+ * bun:sqlite — run under Bun:
29
+ * bun test telegram-plugin/tests/nested-worker-visibility-harness.test.ts
30
+ */
31
+
32
+ import { describe, it, expect, afterEach } from 'bun:test'
33
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, appendFileSync } from 'fs'
34
+ import { tmpdir } from 'os'
35
+ import { join } from 'path'
36
+ import { startSubagentWatcher } from '../subagent-watcher.js'
37
+ import { createWorkerActivityFeed, type WorkerActivityView } from '../worker-activity-feed.js'
38
+ import { openTurnsDbInMemory } from '../registry/turns-schema.js'
39
+ import {
40
+ applySubagentsSchema,
41
+ getSubagent,
42
+ getSubagentByJsonlId,
43
+ resolveSubagentOriginTurnKey,
44
+ } from '../registry/subagents-schema.js'
45
+ import { resolveWorkerFeedDispatch } from '../gateway/worker-feed-dispatch.js'
46
+
47
+ const OWNER_DM = 'owner-dm-fallback'
48
+ const ORIGIN_CHAT = '-1001234567890'
49
+ const ORIGIN_TURN_KEY = `${ORIGIN_CHAT}:_:1783202199395`
50
+
51
+ function jline(o: object): string {
52
+ return JSON.stringify(o) + '\n'
53
+ }
54
+ function userMsg(text: string): string {
55
+ return jline({ type: 'user', message: { content: [{ type: 'text', text }] } })
56
+ }
57
+ function toolUse(id: string, name: string, input: Record<string, unknown>): string {
58
+ return jline({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name, input }] } })
59
+ }
60
+ function turnEnd(): string {
61
+ return jline({ type: 'system', subtype: 'turn_duration', durationMs: 1234 })
62
+ }
63
+
64
+ interface FakeBot {
65
+ sent: Array<{ chatId: string; text: string }>
66
+ edits: Array<{ chatId: string; messageId: number; text: string }>
67
+ sendMessage: (chatId: string, text: string) => Promise<{ message_id: number }>
68
+ editMessageText: (chatId: string, messageId: number, text: string) => Promise<unknown>
69
+ }
70
+ function makeFakeBot(): FakeBot {
71
+ let nextId = 7000
72
+ const fb: FakeBot = {
73
+ sent: [],
74
+ edits: [],
75
+ sendMessage: async (chatId, text) => {
76
+ fb.sent.push({ chatId, text })
77
+ return { message_id: nextId++ }
78
+ },
79
+ editMessageText: async (chatId, messageId, text) => {
80
+ fb.edits.push({ chatId, messageId, text })
81
+ return {}
82
+ },
83
+ }
84
+ return fb
85
+ }
86
+
87
+ const flush = async (): Promise<void> => {
88
+ for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0))
89
+ }
90
+
91
+ let cleanupDirs: string[] = []
92
+ let stoppers: Array<() => void> = []
93
+ afterEach(() => {
94
+ for (const s of stoppers) { try { s() } catch { /* ignore */ } }
95
+ stoppers = []
96
+ for (const d of cleanupDirs) { try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ } }
97
+ cleanupDirs = []
98
+ })
99
+
100
+ function makeHarness() {
101
+ const agentDir = mkdtempSync(join(tmpdir(), 'nested-harness-'))
102
+ cleanupDirs.push(agentDir)
103
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'proj', 'sess-1', 'subagents')
104
+ mkdirSync(subagentsDir, { recursive: true })
105
+
106
+ const db = openTurnsDbInMemory()
107
+ applySubagentsSchema(db)
108
+ // The originating turn — ENDED before the nested dispatch happens.
109
+ db.prepare(`
110
+ INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
111
+ VALUES (?, ?, NULL, 500, 900, 500, 900)
112
+ `).run(ORIGIN_TURN_KEY, ORIGIN_CHAT)
113
+ // Depth-1 background worker row — stamped by the pretool hook while the
114
+ // main turn was live (the mechanism PRs #2085/#2075 already fixed).
115
+ db.prepare(`
116
+ INSERT INTO subagents
117
+ (id, parent_session_id, parent_turn_key, agent_type, description,
118
+ background, started_at, last_activity_at, status, jsonl_agent_id, parent_agent_id)
119
+ VALUES ('toolu_parent', 'sess-1', ?, 'worker', 'depth-1 orchestrator', 1, 800, 800, 'running', NULL, NULL)
120
+ `).run(ORIGIN_TURN_KEY)
121
+
122
+ let currentTime = 1000
123
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
124
+ let nextRef = 1
125
+ const sharedSetInterval = (fn: () => void, ms: number): unknown => {
126
+ const ref = nextRef++
127
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
128
+ return { ref }
129
+ }
130
+ const sharedClearInterval = (h: unknown): void => {
131
+ const { ref } = h as { ref: number }
132
+ const idx = intervals.findIndex((i) => i.ref === ref)
133
+ if (idx !== -1) intervals.splice(idx, 1)
134
+ }
135
+ const sharedSetTimeout = sharedSetInterval
136
+ const sharedClearTimeout = sharedClearInterval
137
+
138
+ const bot = makeFakeBot()
139
+ const feed = createWorkerActivityFeed({
140
+ bot,
141
+ now: () => currentTime,
142
+ firstPaintMinMs: 0, // paint immediately — routing/content is under test, not paint gating
143
+ minEditIntervalMs: 0,
144
+ heartbeatTickMs: 6000,
145
+ setInterval: sharedSetInterval,
146
+ clearInterval: sharedClearInterval,
147
+ })
148
+
149
+ const finishCalls: Array<{ agentId: string; outcome: string }> = []
150
+
151
+ // Mirror the gateway's routing: registry-derived dispatch + transitive
152
+ // origin resolution, worker feed for background OR nested, orphan-DM
153
+ // fallback otherwise.
154
+ const routeChat = (agentId: string): { chatId: string } => {
155
+ const key = resolveSubagentOriginTurnKey(db, agentId)
156
+ if (key == null) return { chatId: OWNER_DM }
157
+ const turn = db.prepare('SELECT chat_id FROM turns WHERE turn_key = ?').get(key) as { chat_id: string } | null
158
+ return { chatId: turn?.chat_id ?? OWNER_DM }
159
+ }
160
+
161
+ const watcher = startSubagentWatcher({
162
+ agentDir,
163
+ db,
164
+ rescanMs: 500,
165
+ stallThresholdMs: 60_000,
166
+ silentSynthesisStallThresholdMs: 300_000,
167
+ silentStallTerminalMs: 300_000,
168
+ now: () => currentTime,
169
+ setInterval: sharedSetInterval,
170
+ clearInterval: sharedClearInterval,
171
+ setTimeout: sharedSetTimeout,
172
+ clearTimeout: sharedClearTimeout,
173
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, lastTool, toolCount, progressLine }) => {
174
+ const dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(db, agentId), description)
175
+ const isWorkerSurface = dispatch.isBackground || dispatch.isNested || !dispatch.hasRow
176
+ if (!isWorkerSurface) return // foreground-nest path — out of scope here
177
+ const { chatId } = routeChat(agentId)
178
+ const view: WorkerActivityView = {
179
+ description: dispatch.feedDescription,
180
+ lastTool,
181
+ toolCount,
182
+ latestSummary: progressLine != null && progressLine.length > 0 ? progressLine : latestSummary,
183
+ elapsedMs,
184
+ state: 'running',
185
+ }
186
+ void feed.update(agentId, chatId, view)
187
+ },
188
+ onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
189
+ finishCalls.push({ agentId, outcome })
190
+ const dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(db, agentId), description)
191
+ void feed.finish(agentId, {
192
+ description: dispatch.feedDescription,
193
+ lastTool: null,
194
+ toolCount,
195
+ latestSummary: resultText,
196
+ elapsedMs: durationMs,
197
+ state: outcome === 'failed' ? 'failed' : 'done',
198
+ })
199
+ },
200
+ })
201
+ stoppers.push(() => watcher.stop(), () => feed.stop())
202
+
203
+ const advance = (ms: number): void => {
204
+ currentTime += ms
205
+ for (;;) {
206
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
207
+ const next = intervals[0]
208
+ if (!next || next.fireAt > currentTime) break
209
+ next.fireAt += next.ms
210
+ next.fn()
211
+ }
212
+ }
213
+
214
+ const writeWorker = (stem: string, toolUseId: string, description: string, firstLines: string): void => {
215
+ writeFileSync(join(subagentsDir, `agent-${stem}.meta.json`), JSON.stringify({
216
+ agentType: 'general-purpose', description, toolUseId,
217
+ }))
218
+ writeFileSync(join(subagentsDir, `agent-${stem}.jsonl`), firstLines)
219
+ }
220
+ const appendWorker = (stem: string, lines: string): void => {
221
+ appendFileSync(join(subagentsDir, `agent-${stem}.jsonl`), lines)
222
+ }
223
+
224
+ return { db, bot, feed, watcher, advance, writeWorker, appendWorker, finishCalls }
225
+ }
226
+
227
+ describe('nested (depth-2+) worker — end-to-end visibility harness', () => {
228
+ it('records the row, shows live tool activity, routes to the origin chat, and finalizes', async () => {
229
+ const h = makeHarness()
230
+ h.advance(500) // boot scan (empty dir)
231
+
232
+ // Depth-1 parent JSONL appears (post-boot → live) and immediately
233
+ // dispatches a NESTED worker. The main turn ended at t=900 — there is no
234
+ // turn-active marker, and the hook "lost" the child row entirely (we
235
+ // never insert it): the exact frozen-card incident shape.
236
+ h.writeWorker('parent01', 'toolu_parent', 'depth-1 orchestrator',
237
+ userMsg('orchestrate the nested probe')
238
+ + toolUse('toolu_child', 'Task', {
239
+ description: 'nested probe', subagent_type: 'general-purpose', run_in_background: false,
240
+ }))
241
+ h.advance(500)
242
+ await flush()
243
+
244
+ // (a) The watcher recorded the nested dispatch from the parent's JSONL.
245
+ const childRow = getSubagent(h.db, 'toolu_child')
246
+ expect(childRow).not.toBeNull()
247
+ expect(childRow?.parent_agent_id).toBe('parent01')
248
+ expect(childRow?.parent_turn_key).toBe(ORIGIN_TURN_KEY)
249
+
250
+ // The child JSONL appears and runs REAL tools (no prose).
251
+ h.writeWorker('child01', 'toolu_child', 'nested probe',
252
+ userMsg('probe the nested structure')
253
+ + toolUse('t1', 'Read', { file_path: '/repo/src/index.ts' }))
254
+ h.advance(500)
255
+ await flush()
256
+
257
+ // Linked + transitively attributed.
258
+ expect(getSubagent(h.db, 'toolu_child')?.jsonl_agent_id).toBe('child01')
259
+ expect(resolveSubagentOriginTurnKey(h.db, 'child01')).toBe(ORIGIN_TURN_KEY)
260
+
261
+ // (b)+(c) The nested card painted in the ORIGINATING chat with a REAL
262
+ // tool step — not the owner DM, not frozen "starting…".
263
+ const childMsgs = [...h.bot.sent, ...h.bot.edits].filter((m) => m.text.includes('nested probe'))
264
+ expect(childMsgs.length).toBeGreaterThanOrEqual(1)
265
+ for (const m of childMsgs) expect(m.chatId).toBe(ORIGIN_CHAT)
266
+ const lastChild = childMsgs[childMsgs.length - 1]!
267
+ expect(lastChild.text).not.toContain('starting…')
268
+ expect(lastChild.text).toContain('index.ts')
269
+
270
+ // More tool activity → climbing tool count, still live.
271
+ h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'ls -la /repo' }))
272
+ h.advance(1000)
273
+ await flush()
274
+ const afterSecondTool = [...h.bot.edits].filter((m) => m.text.includes('nested probe')).pop()
275
+ expect(afterSecondTool?.text).toContain('2 tools')
276
+
277
+ // (e) Parallel isolation: the parent's own card (if painted) never
278
+ // absorbed the child's steps, and vice versa.
279
+ const parentMsgs = [...h.bot.sent, ...h.bot.edits].filter((m) => m.text.includes('depth-1 orchestrator'))
280
+ for (const m of parentMsgs) {
281
+ expect(m.text).not.toContain('index.ts')
282
+ }
283
+ for (const m of [...h.bot.sent, ...h.bot.edits].filter((x) => x.text.includes('nested probe'))) {
284
+ expect(m.text).not.toContain('orchestrate the nested probe')
285
+ }
286
+
287
+ // (d) turn_end → clean finalize: the card edits to done and the feed
288
+ // handle is dropped (never heartbeated frozen forever).
289
+ h.appendWorker('child01', turnEnd())
290
+ h.advance(1000)
291
+ await flush()
292
+ expect(h.finishCalls.some((f) => f.agentId === 'child01')).toBe(true)
293
+ const finalEdit = h.bot.edits.filter((m) => m.text.includes('nested probe')).pop()
294
+ expect(finalEdit?.text).toContain('done')
295
+ expect(h.feed.messageIdOf('child01')).toBeNull()
296
+ })
297
+
298
+ it('links late (backfill retry) when the child JSONL appears BEFORE the parent dispatch line is read', async () => {
299
+ const h = makeHarness()
300
+ h.advance(500) // boot scan
301
+
302
+ // Child JSONL first — registration backfill finds NO row ("Phase 2 Pre
303
+ // hook pending", the 2-minute freeze window from the live gateway log).
304
+ h.writeWorker('child01', 'toolu_child', 'nested probe',
305
+ userMsg('probe') + toolUse('t1', 'Read', { file_path: '/repo/a.ts' }))
306
+ h.advance(500)
307
+ await flush()
308
+ expect(getSubagent(h.db, 'toolu_child')).toBeNull()
309
+
310
+ // Parent's dispatch line lands → watcher records the child row.
311
+ h.writeWorker('parent01', 'toolu_parent', 'depth-1 orchestrator',
312
+ userMsg('orchestrate')
313
+ + toolUse('toolu_child', 'Task', { description: 'nested probe', run_in_background: false }))
314
+ h.advance(500)
315
+ await flush()
316
+ expect(getSubagent(h.db, 'toolu_child')).not.toBeNull()
317
+
318
+ // The child's JSONL grows → the liveness-path backfill RETRY links it
319
+ // (registration's one-shot backfill already missed).
320
+ h.advance(3000) // past BACKFILL_RETRY_INTERVAL_MS
321
+ h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'pwd' }))
322
+ h.advance(1000)
323
+ await flush()
324
+ const row = getSubagent(h.db, 'toolu_child')
325
+ expect(row?.jsonl_agent_id).toBe('child01')
326
+ expect(row?.parent_turn_key).toBe(ORIGIN_TURN_KEY)
327
+ expect(resolveSubagentOriginTurnKey(h.db, 'child01')).toBe(ORIGIN_TURN_KEY)
328
+ })
329
+ })
@@ -95,6 +95,59 @@ describe('pending-inbound-buffer', () => {
95
95
  expect(drained.map((m) => m.meta?.source)).toEqual(['m3', 'm4', 'm5'])
96
96
  })
97
97
 
98
+ // #2789 A: a >cap burst mid-turn used to evict the oldest SILENTLY —
99
+ // the durable spool copy only replays at boot / escalates after 15 min,
100
+ // so within a live session the evicted message was just gone with no
101
+ // user-visible signal. onEvict makes the eviction non-silent so the
102
+ // caller can surface a coalesced "messages deferred" notice.
103
+ it('#2789 A: fires onEvict with the evicted message on cap eviction (not a silent drop)', () => {
104
+ const evicted: InboundMessage[] = []
105
+ const buf = createPendingInboundBuffer({
106
+ capPerAgent: 3,
107
+ log: () => {},
108
+ onEvict: (_agent, m) => evicted.push(m),
109
+ })
110
+ // Fill to cap — no eviction yet, no notice.
111
+ buf.push('a', inbound('m1', 1))
112
+ buf.push('a', inbound('m2', 2))
113
+ buf.push('a', inbound('m3', 3))
114
+ expect(evicted).toHaveLength(0)
115
+ // The 4th push overflows the cap → oldest (m1) evicted → onEvict fires.
116
+ buf.push('a', inbound('m4', 4))
117
+ expect(evicted.map((m) => m.meta?.source)).toEqual(['m1'])
118
+ // The 5th evicts m2.
119
+ buf.push('a', inbound('m5', 5))
120
+ expect(evicted.map((m) => m.meta?.source)).toEqual(['m1', 'm2'])
121
+ })
122
+
123
+ it('#2789 A: a >32 burst produces one onEvict per evicted entry, never a silent drop', () => {
124
+ const evicted: InboundMessage[] = []
125
+ const buf = createPendingInboundBuffer({
126
+ log: () => {}, // default cap = 32
127
+ onEvict: (_agent, m) => evicted.push(m),
128
+ })
129
+ // 40 messages into a 32-cap buffer → exactly 8 evictions, all reported.
130
+ for (let i = 1; i <= 40; i++) buf.push('a', inbound(`m${i}`, i))
131
+ expect(buf.depth('a')).toBe(32)
132
+ expect(evicted).toHaveLength(8)
133
+ expect(evicted.map((m) => m.meta?.source)).toEqual([
134
+ 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8',
135
+ ])
136
+ })
137
+
138
+ it('#2789 A: a throwing onEvict never breaks the push hot path', () => {
139
+ const buf = createPendingInboundBuffer({
140
+ capPerAgent: 1,
141
+ log: () => {},
142
+ onEvict: () => {
143
+ throw new Error('notice failed')
144
+ },
145
+ })
146
+ buf.push('a', inbound('m1', 1))
147
+ expect(() => buf.push('a', inbound('m2', 2))).not.toThrow()
148
+ expect(buf.depth('a')).toBe(1)
149
+ })
150
+
98
151
  it('push returns false when eviction occurred', () => {
99
152
  const buf = createPendingInboundBuffer({ capPerAgent: 2, log: () => {} })
100
153
  expect(buf.push('a', inbound('m1'))).toBe(true)
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { redact } from '../secret-detect/redact.js'
3
+
4
+ /**
5
+ * Behavioral coverage for the progress_update outbound secret-scrub gap.
6
+ *
7
+ * progress_update was the ONLY send site in gateway.ts that did not call
8
+ * `redactOutboundText` — so a secret the agent echoed into a progress line
9
+ * reached Telegram (and history) unmasked, while reply / stream_reply /
10
+ * edit_message / turn_flush all redact. The fix adds
11
+ * `redactOutboundText(text, 'progress_update')` — and, critically, places it
12
+ * BEFORE the 300-char truncation the tool applies. Ordering matters: if the
13
+ * truncation ran first, a secret straddling the 300-char cut would be sliced
14
+ * into a partial token the shape detector no longer matches, leaking the
15
+ * visible bytes.
16
+ *
17
+ * These tests reproduce the gateway's `executeProgressUpdate` send prep — the
18
+ * exact `redact` engine used in production, in the same order the fix uses
19
+ * (redact → truncate) — and pin both the ordinary mask and the
20
+ * straddling-the-boundary case, plus a contrast showing the WRONG order leaks.
21
+ */
22
+
23
+ // Build the token fixture by concatenation so the source file never contains a
24
+ // contiguous secret-shaped literal (repo Push Protection / no-pii lint).
25
+ const ID = '17'
26
+ const BODY40 = 'aB3dE6fH9j'.repeat(4) // 40 base62 chars — Sanctum's Str::random(40)
27
+ const SANCTUM = `${ID}|${BODY40}` // e.g. 17|aB3dE6fH9j…
28
+
29
+ /** The production send-prep order: redact the FULL text, THEN truncate. */
30
+ function progressSendPrep(text: string): string {
31
+ let out = redact(text)
32
+ if (out.length > 300) out = out.slice(0, 299) + '…'
33
+ return out
34
+ }
35
+
36
+ /** The buggy order the fix guards against: truncate first, then redact. */
37
+ function truncateFirstThenRedact(text: string): string {
38
+ let out = text
39
+ if (out.length > 300) out = out.slice(0, 299) + '…'
40
+ return redact(out)
41
+ }
42
+
43
+ describe('progress_update outbound secret-scrub', () => {
44
+ it('masks a secret-shaped token in an ordinary (short) progress line', () => {
45
+ const out = progressSendPrep(`Deploying with token ${SANCTUM} now — hold tight`)
46
+ expect(out).not.toContain(SANCTUM)
47
+ expect(out).not.toContain(BODY40)
48
+ expect(out).toContain('[REDACTED:laravel_sanctum_token]')
49
+ // Surrounding prose survives.
50
+ expect(out).toContain('Deploying with token')
51
+ expect(out).toContain('hold tight')
52
+ })
53
+
54
+ it('masks a secret that STRADDLES the 300-char truncation boundary', () => {
55
+ // Pad so the token spans char 300: 286 chars of prose, a space, then the
56
+ // 43-char token. The 299/300 cut lands mid-token.
57
+ const pad = 'progress: still working on the deploy, '.repeat(8).slice(0, 286)
58
+ const text = `${pad} ${SANCTUM} and continuing after the token here`
59
+ // Sanity: the token really does straddle the cut point.
60
+ const cut = 299
61
+ expect(text.indexOf(SANCTUM)).toBeLessThan(cut)
62
+ expect(text.indexOf(SANCTUM) + SANCTUM.length).toBeGreaterThan(cut)
63
+
64
+ const out = progressSendPrep(text)
65
+ // Because redact runs on the FULL text first, the whole token is masked
66
+ // before any slice — no secret bytes reach the wire. (The mask MARKER
67
+ // itself may fall across the 300-char cut and be truncated — harmless,
68
+ // since it carries no secret — so assert the leading marker survives, not
69
+ // the full literal.)
70
+ expect(out).not.toContain(SANCTUM)
71
+ expect(out).not.toContain(BODY40)
72
+ expect(out).toContain('[REDACTED')
73
+ })
74
+
75
+ it('proves the ordering matters: truncate-FIRST would leak the straddling secret', () => {
76
+ // A regression fence: if someone reorders the fix so truncation precedes
77
+ // redaction, the cut splits the token, the shape detector misses the
78
+ // fragment, and partial secret bytes survive. This asserts the wrong order
79
+ // leaks — the exact failure the production ordering prevents.
80
+ const pad = 'progress: still working on the deploy, '.repeat(8).slice(0, 286)
81
+ const text = `${pad} ${SANCTUM} and continuing after the token here`
82
+
83
+ const wrong = truncateFirstThenRedact(text)
84
+ // The leading fragment of the token body (pre-cut) survives unmasked.
85
+ const leakedPrefix = BODY40.slice(0, 6)
86
+ expect(wrong).toContain(leakedPrefix)
87
+
88
+ // The correct order masks it — the fragment does NOT survive.
89
+ const right = progressSendPrep(text)
90
+ expect(right).not.toContain(leakedPrefix)
91
+ })
92
+
93
+ it('idempotent: a masked progress line has no secret bytes on a second pass', () => {
94
+ const once = progressSendPrep(`token ${SANCTUM}`)
95
+ const twice = progressSendPrep(once)
96
+ expect(twice).not.toContain(SANCTUM)
97
+ expect(twice).not.toContain(BODY40)
98
+ })
99
+ })
@@ -19,6 +19,8 @@ import {
19
19
  findOrphanedTurns,
20
20
  markOrphanedWithTimeoutClassification,
21
21
  findLatestTurnIfInterrupted,
22
+ markTurnResumed,
23
+ getTurnByKey,
22
24
  } from '../registry/turns-schema.js'
23
25
 
24
26
  // Convenience: the boot reaper with no live hang marker — every open turn
@@ -506,4 +508,69 @@ describe('findLatestTurnIfInterrupted', () => {
506
508
  expect(findLatestTurnIfInterrupted(db)).toBeNull()
507
509
  db.close()
508
510
  })
511
+
512
+ // ── #2793 part A: resume is at-most-once via the `resumed_at` ledger ──
513
+ // Repro: without the ledger, an interrupted turn whose resume ran its
514
+ // side effects but never reached a clean 'stop' (process died before the
515
+ // follow-up turn wrote ended_at, or the resume inbound was accepted but
516
+ // never consumed) stays "latest + not clean" — so every subsequent boot
517
+ // re-mints a fresh resume and DOUBLE-EXECUTES. The ledger makes it null
518
+ // after the first commit.
519
+ it('re-fires an interrupted turn until it is stamped resumed (double-exec repro)', () => {
520
+ const db = openTurnsDbInMemory()
521
+ recordTurnStart(db, { turnKey: 'res:1', chatId: 'res' })
522
+ recordTurnEnd(db, { turnKey: 'res:1', endedVia: 'restart' })
523
+ // Boot 1: the turn is interrupted and still unresumed → it is returned
524
+ // (the gateway will mint a resume for it).
525
+ const first = findLatestTurnIfInterrupted(db)
526
+ expect(first).not.toBeNull()
527
+ expect(first!.turn_key).toBe('res:1')
528
+ expect(first!.resumed_at).toBeNull()
529
+ // Gateway durably queues the resume, then stamps the ledger.
530
+ markTurnResumed(db, 'res:1', 1_700_000_000_000)
531
+ // Boot 2 (crash happened after side effects, turn still 'restart' with
532
+ // no clean follow-up): the SAME turn must NOT be resumed again.
533
+ expect(findLatestTurnIfInterrupted(db)).toBeNull()
534
+ db.close()
535
+ })
536
+
537
+ it('does not re-fire even an OPEN (ended_at IS NULL) turn once resumed', () => {
538
+ const db = openTurnsDbInMemory()
539
+ recordTurnStart(db, { turnKey: 'res:2', chatId: 'res' })
540
+ // Still open (never reaped to a terminal ended_via), but already resumed
541
+ // once — the accept-but-never-consumed window. Must not re-fire.
542
+ markTurnResumed(db, 'res:2')
543
+ expect(findLatestTurnIfInterrupted(db)).toBeNull()
544
+ db.close()
545
+ })
546
+ })
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // markTurnResumed — the at-most-once resume ledger (#2793 part A)
550
+ // ---------------------------------------------------------------------------
551
+
552
+ describe('markTurnResumed', () => {
553
+ it('stamps resumed_at on the target turn', () => {
554
+ const db = openTurnsDbInMemory()
555
+ recordTurnStart(db, { turnKey: 'mk:1', chatId: 'mk' })
556
+ recordTurnEnd(db, { turnKey: 'mk:1', endedVia: 'restart' })
557
+ markTurnResumed(db, 'mk:1', 1_700_000_000_000)
558
+ expect(getTurnByKey(db, 'mk:1')!.resumed_at).toBe(1_700_000_000_000)
559
+ db.close()
560
+ })
561
+
562
+ it('is first-write-wins: a second stamp does not overwrite the first', () => {
563
+ const db = openTurnsDbInMemory()
564
+ recordTurnStart(db, { turnKey: 'mk:2', chatId: 'mk' })
565
+ markTurnResumed(db, 'mk:2', 1_700_000_000_000)
566
+ markTurnResumed(db, 'mk:2', 1_800_000_000_000)
567
+ expect(getTurnByKey(db, 'mk:2')!.resumed_at).toBe(1_700_000_000_000)
568
+ db.close()
569
+ })
570
+
571
+ it('no-ops for an unknown turn_key', () => {
572
+ const db = openTurnsDbInMemory()
573
+ expect(() => markTurnResumed(db, 'nope:1')).not.toThrow()
574
+ db.close()
575
+ })
509
576
  })
@@ -37,14 +37,14 @@ describe("shouldSuppressRepresent — #2472 duplicate-represent guard", () => {
37
37
  });
38
38
 
39
39
  it("does NOT suppress the FIRST represent — genuine plain-text-no-reply still represents once", () => {
40
- // First represent: lastRepresentedAt is undefined. Even though an assistant
41
- // message (the original plain-text answer) exists in history, the single
42
- // re-ask must still fire — the agent never called the reply tool.
43
- const o = obligation({ lastRepresentedAt: undefined });
40
+ // First represent: lastRepresentedAt is undefined, no reply tool call was
41
+ // ever recorded (the genuine plain-text-no-reply case no outbound row, so
42
+ // the predicate reports false). The single re-ask must still fire.
43
+ const o = obligation({ openedAt: 0, lastRepresentedAt: undefined });
44
44
  const suppress = shouldSuppressRepresent(o, {
45
45
  historyEnabled: true,
46
- // history WOULD report an outbound exists, but the first represent ignores it
47
- hasOutboundDeliveredSince: () => true,
46
+ // No outbound row exists for a plain-text answer predicate is false.
47
+ hasOutboundDeliveredSince: () => false,
48
48
  });
49
49
  expect(suppress).toBe(false); // represent fires exactly once
50
50
  });
@@ -70,6 +70,42 @@ describe("shouldSuppressRepresent — #2472 duplicate-represent guard", () => {
70
70
  expect(suppress).toBe(false);
71
71
  });
72
72
 
73
+ it("#2788 Gap B — SUPPRESSES the FIRST represent when a genuine reply was delivered since openedAt", () => {
74
+ // The narrow false "you never answered" window: a real reply landed at
75
+ // t=1500 after the obligation was raised at t=1000, but its routing didn't
76
+ // resolve back to the origin so the ledger's close path missed it. The FIRST
77
+ // represent must now dedup against outbound history (cutoff = openedAt) and
78
+ // suppress, instead of emitting a false "you never answered".
79
+ const o = obligation({ openedAt: 1000, lastRepresentedAt: undefined });
80
+ const suppress = shouldSuppressRepresent(o, {
81
+ historyEnabled: true,
82
+ hasOutboundDeliveredSince: replyDeliveredAt(1500),
83
+ });
84
+ expect(suppress).toBe(true); // first represent deduped → no false "you never answered"
85
+ });
86
+
87
+ it("#2788 Gap B — first represent still fires when the only reply PREDATES openedAt", () => {
88
+ // A reply at t=500 answered an EARLIER turn, before this obligation was
89
+ // raised at t=1000. It is not evidence THIS obligation was answered → the
90
+ // first represent must still fire.
91
+ const o = obligation({ openedAt: 1000, lastRepresentedAt: undefined });
92
+ const suppress = shouldSuppressRepresent(o, {
93
+ historyEnabled: true,
94
+ hasOutboundDeliveredSince: replyDeliveredAt(500),
95
+ });
96
+ expect(suppress).toBe(false);
97
+ });
98
+
99
+ it("#2788 Gap B — first represent falls back to firing when openedAt is unknown", () => {
100
+ // Without an openedAt cutoff we cannot dedup safely — never suppress on doubt.
101
+ const o = obligation({ openedAt: undefined, lastRepresentedAt: undefined });
102
+ const suppress = shouldSuppressRepresent(o, {
103
+ historyEnabled: true,
104
+ hasOutboundDeliveredSince: () => true,
105
+ });
106
+ expect(suppress).toBe(false);
107
+ });
108
+
73
109
  it("never suppresses when history is unavailable (safe: re-ask rather than silently drop)", () => {
74
110
  const o = obligation({ lastRepresentedAt: 1000 });
75
111
  const suppress = shouldSuppressRepresent(o, {
@@ -37,6 +37,7 @@ function makeTurn(overrides: Partial<Turn> = {}): Turn {
37
37
  assistant_reply_preview: null,
38
38
  tool_call_count: null,
39
39
  interrupt_reason: null,
40
+ resumed_at: null,
40
41
  created_at: 1_000_000,
41
42
  updated_at: 1_000_000,
42
43
  ...overrides,
@@ -509,7 +509,16 @@ describe('projectSubagentLine', () => {
509
509
  // rendering."
510
510
  expect(events.length).toBe(2)
511
511
  expect(events[0].kind).toBe('sub_agent_tool_use')
512
- expect(events[1]).toEqual({ kind: 'sub_agent_nested_spawn', agentId: 'X' })
512
+ // The nested_spawn now ALSO carries the dispatch toolUseId + input so
513
+ // the watcher can key the nested worker's registry row
514
+ // (recordNestedSubagentDispatch — the depth-2+ card fix). Rendering is
515
+ // unchanged: still no sub_agent_tool_use for the nested Agent.
516
+ expect(events[1]).toEqual({
517
+ kind: 'sub_agent_nested_spawn',
518
+ agentId: 'X',
519
+ toolUseId: 'toolu_b',
520
+ input: { description: 'nested', prompt: 'nested-p' },
521
+ })
513
522
  })
514
523
 
515
524
  it('emits sub_agent_text + sub_agent_tool_use in source order for [text, tool_use]', () => {