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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SubagentWatcher re-registration after a SendMessage resume
|
|
3
|
+
* (issue #3315).
|
|
4
|
+
*
|
|
5
|
+
* A background worker that completes normally (a REAL `sub_agent_turn_end`)
|
|
6
|
+
* is swept out of the registry by `cleanupTerminalAgent`, which records the
|
|
7
|
+
* agent id in `terminatedAgentIds` so a rescan of the still-present JSONL
|
|
8
|
+
* doesn't re-register and re-fire a duplicate handback (issue #1116 Bug B).
|
|
9
|
+
*
|
|
10
|
+
* But a SendMessage-resume reuses the SAME `agent-<id>.jsonl`, appending a
|
|
11
|
+
* NEW turn to it. Pre-fix the `terminatedAgentIds` guard suppressed that file
|
|
12
|
+
* forever regardless of growth, so the resumed worker was never tracked again:
|
|
13
|
+
* no FSWatcher, no `onProgress`, no progress card — the exact UX failure the
|
|
14
|
+
* card exists to prevent (operator: "I don't see any progress cards, anything
|
|
15
|
+
* happening??").
|
|
16
|
+
*
|
|
17
|
+
* This suite pins BOTH directions of the deterministic size-based mechanism:
|
|
18
|
+
*
|
|
19
|
+
* (a) terminal cleanup → resume (JSONL grows past the terminal size) ⇒ the
|
|
20
|
+
* watcher re-registers the agent as a LIVE worker and emits progress
|
|
21
|
+
* cues again. The re-registration reads only the NEW turn — it does NOT
|
|
22
|
+
* replay the already-handed-back completed turn (no duplicate onFinish
|
|
23
|
+
* until the resumed turn genuinely ends).
|
|
24
|
+
* (b) terminal cleanup with NO resume (JSONL static) ⇒ the agent stays
|
|
25
|
+
* cleaned up: no re-registration, no duplicate onFinish, no leaked card.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
29
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
30
|
+
import * as fs from 'fs'
|
|
31
|
+
|
|
32
|
+
function buildJSONL(...lines: object[]): string {
|
|
33
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
34
|
+
}
|
|
35
|
+
function subAgentUserMsg(promptText: string) {
|
|
36
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
37
|
+
}
|
|
38
|
+
function subAgentToolUse(name: string, id: string, input: Record<string, unknown> = {}) {
|
|
39
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input }] } }
|
|
40
|
+
}
|
|
41
|
+
function subAgentTurnEnd() {
|
|
42
|
+
return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const RESCAN_MS = 500
|
|
46
|
+
const CLEANUP_GRACE_MS = 30_000 // TERMINAL_CLEANUP_GRACE_MS in subagent-watcher.ts
|
|
47
|
+
|
|
48
|
+
interface ProgressCall {
|
|
49
|
+
agentId: string
|
|
50
|
+
skeleton: boolean
|
|
51
|
+
progressLine?: string
|
|
52
|
+
toolCount: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeHarness(opts: { agentId?: string } = {}) {
|
|
56
|
+
const { agentId = 'resume-agent' } = opts
|
|
57
|
+
|
|
58
|
+
let currentTime = 1000
|
|
59
|
+
const progressCalls: ProgressCall[] = []
|
|
60
|
+
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
61
|
+
const terminalCleanupCalls: string[] = []
|
|
62
|
+
const logs: string[] = []
|
|
63
|
+
|
|
64
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
65
|
+
const sessionId = 'mock-session'
|
|
66
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
67
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
68
|
+
const sessionDir = `${projectDir}/${sessionId}`
|
|
69
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
70
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
71
|
+
|
|
72
|
+
// The agent JSONL is NOT present at construction, so the boot scan registers
|
|
73
|
+
// nothing and the file — when it later appears — registers post-boot as a
|
|
74
|
+
// LIVE (non-historical) worker, exactly like a freshly-dispatched one.
|
|
75
|
+
const fileContents = new Map<string, Buffer>()
|
|
76
|
+
|
|
77
|
+
let lastOpenedPath: string | null = null
|
|
78
|
+
const mockFs = {
|
|
79
|
+
existsSync: ((p: fs.PathLike) => {
|
|
80
|
+
const ps = String(p)
|
|
81
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
82
|
+
return fileContents.has(ps)
|
|
83
|
+
}) as typeof fs.existsSync,
|
|
84
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
85
|
+
const ps = String(p)
|
|
86
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
87
|
+
if (ps === projectDir) return [sessionId]
|
|
88
|
+
if (ps === sessionDir) return ['subagents']
|
|
89
|
+
if (ps === subagentsDir) {
|
|
90
|
+
// Dynamic: list the agent-*.jsonl files currently present on "disk".
|
|
91
|
+
const names: string[] = []
|
|
92
|
+
for (const key of fileContents.keys()) {
|
|
93
|
+
if (key.startsWith(subagentsDir + '/agent-') && key.endsWith('.jsonl')) {
|
|
94
|
+
names.push(key.slice(subagentsDir.length + 1))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return names
|
|
98
|
+
}
|
|
99
|
+
return []
|
|
100
|
+
}) as unknown as typeof fs.readdirSync,
|
|
101
|
+
statSync: ((p: fs.PathLike) =>
|
|
102
|
+
({ size: fileContents.get(String(p))?.length ?? 0, mtimeMs: currentTime }) as fs.Stats) as typeof fs.statSync,
|
|
103
|
+
openSync: ((p: fs.PathLike) => {
|
|
104
|
+
lastOpenedPath = String(p)
|
|
105
|
+
return 42
|
|
106
|
+
}) as unknown as typeof fs.openSync,
|
|
107
|
+
closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
|
|
108
|
+
readSync: ((
|
|
109
|
+
_fd: number,
|
|
110
|
+
buf: NodeJS.ArrayBufferView,
|
|
111
|
+
offset: number,
|
|
112
|
+
length: number,
|
|
113
|
+
position: number | null,
|
|
114
|
+
): number => {
|
|
115
|
+
const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
|
|
116
|
+
if (!content) return 0
|
|
117
|
+
const pos = position ?? 0
|
|
118
|
+
const src = content.slice(pos, pos + length)
|
|
119
|
+
;(src as Buffer).copy(buf as Buffer, offset)
|
|
120
|
+
return src.length
|
|
121
|
+
}) as unknown as typeof fs.readSync,
|
|
122
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
|
|
126
|
+
const timeouts: Array<{ fn: () => void; ref: number; fireAt: number }> = []
|
|
127
|
+
let nextRef = 1
|
|
128
|
+
|
|
129
|
+
const watcher = startSubagentWatcher({
|
|
130
|
+
agentDir,
|
|
131
|
+
rescanMs: RESCAN_MS,
|
|
132
|
+
onProgress: ({ agentId: id, skeleton, progressLine, toolCount }) =>
|
|
133
|
+
progressCalls.push({ agentId: id, skeleton: skeleton === true, progressLine, toolCount }),
|
|
134
|
+
onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
|
|
135
|
+
onTerminalCleanup: (id) => terminalCleanupCalls.push(id),
|
|
136
|
+
now: () => currentTime,
|
|
137
|
+
setInterval: (fn, ms) => {
|
|
138
|
+
const ref = nextRef++
|
|
139
|
+
intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
|
|
140
|
+
return { ref }
|
|
141
|
+
},
|
|
142
|
+
clearInterval: (handle) => {
|
|
143
|
+
const { ref } = handle as { ref: number }
|
|
144
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
145
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
146
|
+
},
|
|
147
|
+
setTimeout: (fn, ms) => {
|
|
148
|
+
const ref = nextRef++
|
|
149
|
+
timeouts.push({ fn, ref, fireAt: currentTime + ms })
|
|
150
|
+
return { ref }
|
|
151
|
+
},
|
|
152
|
+
clearTimeout: (handle) => {
|
|
153
|
+
const { ref } = handle as { ref: number }
|
|
154
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
155
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
156
|
+
},
|
|
157
|
+
fs: mockFs,
|
|
158
|
+
log: (msg) => logs.push(msg),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const advance = (ms: number): void => {
|
|
162
|
+
currentTime += ms
|
|
163
|
+
for (;;) {
|
|
164
|
+
intervals.sort((a, b) => a.fireAt - b.fireAt)
|
|
165
|
+
timeouts.sort((a, b) => a.fireAt - b.fireAt)
|
|
166
|
+
const nextI = intervals[0]
|
|
167
|
+
const nextT = timeouts[0]
|
|
168
|
+
const iReady = nextI && nextI.fireAt <= currentTime
|
|
169
|
+
const tReady = nextT && nextT.fireAt <= currentTime
|
|
170
|
+
if (!iReady && !tReady) break
|
|
171
|
+
if (tReady && (!iReady || nextT!.fireAt <= nextI!.fireAt)) {
|
|
172
|
+
timeouts.shift()
|
|
173
|
+
nextT!.fn()
|
|
174
|
+
} else {
|
|
175
|
+
nextI!.fireAt += nextI!.ms
|
|
176
|
+
nextI!.fn()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const setFile = (content: string): void => {
|
|
182
|
+
fileContents.set(jsonlPath, Buffer.from(content, 'utf-8'))
|
|
183
|
+
}
|
|
184
|
+
const append = (content: string): void => {
|
|
185
|
+
const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
|
|
186
|
+
fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(content, 'utf-8')]))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
agentId,
|
|
191
|
+
progressCalls,
|
|
192
|
+
finishCalls,
|
|
193
|
+
terminalCleanupCalls,
|
|
194
|
+
logs,
|
|
195
|
+
advance,
|
|
196
|
+
watcher,
|
|
197
|
+
setFile,
|
|
198
|
+
append,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
describe('subagent-watcher resume re-registration (issue #3315)', () => {
|
|
203
|
+
it('(a) re-registers a terminal-then-resumed worker so its progress card resumes', () => {
|
|
204
|
+
const agentId = 'resume-live'
|
|
205
|
+
const h = makeHarness({ agentId })
|
|
206
|
+
|
|
207
|
+
// A worker is dispatched post-boot: its JSONL appears with a running turn.
|
|
208
|
+
h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
|
|
209
|
+
h.advance(RESCAN_MS) // rescan discovers the file → LIVE registration
|
|
210
|
+
|
|
211
|
+
const registered = h.watcher.getRegistry().get(agentId)
|
|
212
|
+
expect(registered).toBeDefined()
|
|
213
|
+
expect(registered!.state).toBe('running')
|
|
214
|
+
expect(registered!.historical).toBe(false)
|
|
215
|
+
|
|
216
|
+
// It completes normally with a REAL turn_end.
|
|
217
|
+
h.append(buildJSONL(subAgentTurnEnd()))
|
|
218
|
+
h.advance(RESCAN_MS) // poll reads turn_end → done → onFinish(completed)
|
|
219
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
220
|
+
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
221
|
+
|
|
222
|
+
// Grace elapses → the entry is swept out of the registry.
|
|
223
|
+
h.advance(CLEANUP_GRACE_MS)
|
|
224
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
225
|
+
expect(h.terminalCleanupCalls).toContain(agentId)
|
|
226
|
+
|
|
227
|
+
const progressBeforeResume = h.progressCalls.length
|
|
228
|
+
|
|
229
|
+
// SendMessage resume: Claude Code appends a NEW turn to the SAME JSONL.
|
|
230
|
+
h.append(buildJSONL(
|
|
231
|
+
subAgentUserMsg('follow-up question'),
|
|
232
|
+
subAgentToolUse('Read', 't2', { file_path: '/tmp/x' }),
|
|
233
|
+
))
|
|
234
|
+
h.advance(RESCAN_MS) // rescan sees growth past terminal size → re-register
|
|
235
|
+
|
|
236
|
+
// The worker is tracked again — live, running, non-historical.
|
|
237
|
+
const revived = h.watcher.getRegistry().get(agentId)
|
|
238
|
+
expect(revived).toBeDefined()
|
|
239
|
+
expect(revived!.state).toBe('running')
|
|
240
|
+
expect(revived!.historical).toBe(false)
|
|
241
|
+
expect(h.logs.some((l) => l.includes('resumed after terminal cleanup') && l.includes(agentId))).toBe(true)
|
|
242
|
+
|
|
243
|
+
// Progress cues resume (the card updates again) — the user-visible fix.
|
|
244
|
+
expect(h.progressCalls.length).toBeGreaterThan(progressBeforeResume)
|
|
245
|
+
expect(h.progressCalls.slice(progressBeforeResume).some((c) => c.agentId === agentId)).toBe(true)
|
|
246
|
+
|
|
247
|
+
// The completed turn was NOT replayed — no duplicate onFinish fired on
|
|
248
|
+
// re-registration (the old turn_end sits before the resume cursor).
|
|
249
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
250
|
+
|
|
251
|
+
// When the resumed turn genuinely ends, onFinish fires again for the new
|
|
252
|
+
// turn — the resumed work gets its own handback.
|
|
253
|
+
h.append(buildJSONL(subAgentTurnEnd()))
|
|
254
|
+
h.advance(RESCAN_MS)
|
|
255
|
+
expect(h.finishCalls).toHaveLength(2)
|
|
256
|
+
expect(h.finishCalls[1].outcome).toBe('completed')
|
|
257
|
+
|
|
258
|
+
h.watcher.stop()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('(b) a terminal worker that is NOT resumed stays cleaned up (no re-register, no duplicate handback)', () => {
|
|
262
|
+
const agentId = 'stay-clean'
|
|
263
|
+
const h = makeHarness({ agentId })
|
|
264
|
+
|
|
265
|
+
h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
|
|
266
|
+
h.advance(RESCAN_MS)
|
|
267
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(true)
|
|
268
|
+
|
|
269
|
+
h.append(buildJSONL(subAgentTurnEnd()))
|
|
270
|
+
h.advance(RESCAN_MS)
|
|
271
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
272
|
+
|
|
273
|
+
h.advance(CLEANUP_GRACE_MS) // sweep
|
|
274
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
275
|
+
const finishAfterCleanup = h.finishCalls.length
|
|
276
|
+
const progressAfterCleanup = h.progressCalls.length
|
|
277
|
+
|
|
278
|
+
// The JSONL stays on disk, static (Claude Code leaves it), and the worker
|
|
279
|
+
// is never resumed. Many rescans must NOT re-register it or re-fire the
|
|
280
|
+
// handback (issue #1116 Bug B guard preserved) — the reverse-bug direction.
|
|
281
|
+
for (let i = 0; i < 20; i++) h.advance(RESCAN_MS)
|
|
282
|
+
|
|
283
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
284
|
+
expect(h.finishCalls).toHaveLength(finishAfterCleanup) // no duplicate
|
|
285
|
+
expect(h.progressCalls).toHaveLength(progressAfterCleanup) // no card churn
|
|
286
|
+
// Cleanup fired exactly once — no leaked/duplicated terminal sweep.
|
|
287
|
+
expect(h.terminalCleanupCalls.filter((id) => id === agentId)).toHaveLength(1)
|
|
288
|
+
|
|
289
|
+
h.watcher.stop()
|
|
290
|
+
})
|
|
291
|
+
})
|
|
@@ -395,4 +395,36 @@ describe('subagent-watcher card resurrection (issue #3023)', () => {
|
|
|
395
395
|
h.advance(500)
|
|
396
396
|
expect(h.resurrectCalls).toHaveLength(1)
|
|
397
397
|
})
|
|
398
|
+
|
|
399
|
+
it('(f) a false finish resumed after cleanup grace is owned by resurrection, not the #3315 resume path', () => {
|
|
400
|
+
// Non-interference guard (issue #3315 × #3023): once the terminal-cleanup
|
|
401
|
+
// grace has elapsed, a falsely-finalised worker's id sits in BOTH
|
|
402
|
+
// `terminatedAgentIds` (the #3315 resume-detection map) AND
|
|
403
|
+
// `falseFinishTracker`. Its JSONL resuming must be handled by the
|
|
404
|
+
// resurrection path (bounded-chain + onResurrect semantics), NOT
|
|
405
|
+
// double-handled by the scanSubagentsDir resume branch. The branch defers
|
|
406
|
+
// when a false-finish record exists.
|
|
407
|
+
const agentId = 'false-finish-post-grace'
|
|
408
|
+
const h = makeHarness({ agentId })
|
|
409
|
+
|
|
410
|
+
h.advance(500)
|
|
411
|
+
unmarkHistorical(h, agentId)
|
|
412
|
+
|
|
413
|
+
driveToFalseFinish(h, agentId)
|
|
414
|
+
// Let the 30s terminal-cleanup grace elapse so the entry is swept — its id
|
|
415
|
+
// is now in BOTH terminatedAgentIds and falseFinishTracker.
|
|
416
|
+
h.advance(30_000)
|
|
417
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
418
|
+
|
|
419
|
+
// JSONL resumes growing.
|
|
420
|
+
h.appendActivity()
|
|
421
|
+
h.advance(500)
|
|
422
|
+
|
|
423
|
+
// Resurrected exactly once via the #3023 path...
|
|
424
|
+
expect(h.resurrectCalls).toHaveLength(1)
|
|
425
|
+
expect(h.watcher.getRegistry().get(agentId)?.state).toBe('running')
|
|
426
|
+
// ...and NOT re-registered by the #3315 resume branch (its log marker is
|
|
427
|
+
// absent — the branch deferred because a false-finish record existed).
|
|
428
|
+
expect(h.logs.some((l) => l.includes('resumed after terminal cleanup'))).toBe(false)
|
|
429
|
+
})
|
|
398
430
|
})
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Outcome contract: if a pre-purge op in the turn_end handler body THROWS
|
|
5
5
|
* before the canonical purge (endCurrentTurnAtomic → purgeReactionTracking)
|
|
6
6
|
* runs, the guarded finally must still clear this turn's gate state
|
|
7
|
-
* (activeTurnStartedAt
|
|
8
|
-
* gate re-opens for the next inbound. On the happy path —
|
|
9
|
-
* clears the key itself — the backstop must be a no-op (no
|
|
10
|
-
* the original error must always propagate.
|
|
7
|
+
* (activeTurnStartedAt; the purge also emits the machine turnEnd) so the
|
|
8
|
+
* #1556 inbound gate re-opens for the next inbound. On the happy path —
|
|
9
|
+
* where the body clears the key itself — the backstop must be a no-op (no
|
|
10
|
+
* double purge), and the original error must always propagate.
|
|
11
11
|
*/
|
|
12
12
|
import { describe, it, expect } from 'vitest'
|
|
13
13
|
import { withTurnEndGateBackstop } from '../gateway/turn-end-gate-backstop.js'
|
|
@@ -22,19 +22,16 @@ function statusKey(chatId: string, threadId?: number): string {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** Mirror of the production gate state + a purge that clears it, as
|
|
25
|
-
* purgeReactionTracking does (activeTurnStartedAt.delete
|
|
26
|
-
* drain for the key). */
|
|
25
|
+
* purgeReactionTracking does (activeTurnStartedAt.delete for the key). */
|
|
27
26
|
function freshGate(turn: Turn) {
|
|
28
27
|
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
29
28
|
const activeTurnStartedAt = new Map<string, number>([[key, Date.now()]])
|
|
30
|
-
const claudeBusyKeys = new Set<string>([key])
|
|
31
29
|
const purgeCalls: Array<{ key: string; endingTurn: Turn | undefined }> = []
|
|
32
30
|
const purge = (k: string, endingTurn: Turn | undefined) => {
|
|
33
31
|
purgeCalls.push({ key: k, endingTurn })
|
|
34
32
|
activeTurnStartedAt.delete(k)
|
|
35
|
-
claudeBusyKeys.delete(k)
|
|
36
33
|
}
|
|
37
|
-
return { key, activeTurnStartedAt,
|
|
34
|
+
return { key, activeTurnStartedAt, purge, purgeCalls }
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
function deps(gate: ReturnType<typeof freshGate>) {
|
|
@@ -58,10 +55,9 @@ describe('withTurnEndGateBackstop (#2094 finding 1)', () => {
|
|
|
58
55
|
}, deps(gate)),
|
|
59
56
|
).toThrow('redactOutboundText blew up')
|
|
60
57
|
|
|
61
|
-
// Outcome: gate is OPEN again —
|
|
62
|
-
// gate no longer wedges the next inbound.
|
|
58
|
+
// Outcome: gate is OPEN again — the turn map is cleared, so the #1556
|
|
59
|
+
// inbound gate no longer wedges the next inbound.
|
|
63
60
|
expect(gate.activeTurnStartedAt.has(gate.key)).toBe(false)
|
|
64
|
-
expect(gate.claudeBusyKeys.has(gate.key)).toBe(false)
|
|
65
61
|
// The backstop fired exactly once, forwarding the ending turn.
|
|
66
62
|
expect(gate.purgeCalls).toEqual([{ key: gate.key, endingTurn: turn }])
|
|
67
63
|
})
|
|
@@ -267,16 +267,18 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
|
267
267
|
// (`normalizeOutboundBody`). The reply path delegates to it via
|
|
268
268
|
// `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`; the
|
|
269
269
|
// redact→normalize→scrub ordering is now pinned in the module source.
|
|
270
|
-
const replyDelegates = gatewaySrc.indexOf(
|
|
271
|
-
`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
|
|
272
|
-
gatewaySrc.indexOf('async function executeReply('),
|
|
273
|
-
)
|
|
274
|
-
expect(replyDelegates).toBeGreaterThan(0)
|
|
275
|
-
|
|
276
270
|
const moduleSrc = readFileSync(
|
|
277
271
|
new URL('../gateway/outbound-send-path.ts', import.meta.url),
|
|
278
272
|
'utf8',
|
|
279
273
|
)
|
|
274
|
+
// #2996 P2: the reply orchestration itself now lives in the module
|
|
275
|
+
// (`sendReply`); the delegation line is pinned there.
|
|
276
|
+
const replyDelegates = moduleSrc.indexOf(
|
|
277
|
+
`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
|
|
278
|
+
moduleSrc.indexOf('export async function sendReply('),
|
|
279
|
+
)
|
|
280
|
+
expect(replyDelegates).toBeGreaterThan(0)
|
|
281
|
+
|
|
280
282
|
const start = moduleSrc.indexOf('export function normalizeOutboundBody(')
|
|
281
283
|
const redactIdx = moduleSrc.indexOf('redact(text, site)', start)
|
|
282
284
|
const normIdx = moduleSrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1/S2/S4 WIRING (fable red-team 2026-07-17, fixed in #3299 @ a6e09dfb).
|
|
3
|
+
*
|
|
4
|
+
* The behavioural suite `turn-flush-suppression.test.ts` proves the scoped
|
|
5
|
+
* suppression predicate in isolation, and `answer-ready-flush.test.ts` proves
|
|
6
|
+
* the quiescence controller's reset/re-arm semantics — but neither says
|
|
7
|
+
* whether the GATEWAY actually calls them, or what it does with the answer.
|
|
8
|
+
* That decision-vs-delivery gap is exactly how the original S1 bug shipped:
|
|
9
|
+
* every routine was individually correct while the gateway's call site
|
|
10
|
+
* counted ANY chat-wide outbound and then CLOSED the obligation, silently
|
|
11
|
+
* dropping the user's real answer.
|
|
12
|
+
*
|
|
13
|
+
* gateway.ts is a side-effecting IIFE that cannot be imported in a unit test
|
|
14
|
+
* (same constraint as activity-card-wiring.test.ts /
|
|
15
|
+
* silence-liveness-wiring.test.ts), so these are STRUCTURAL assertions that
|
|
16
|
+
* pin the load-bearing call sites. They COMPLEMENT the behavioural suites —
|
|
17
|
+
* the outcomes are proven at routine level; this guards the wiring those
|
|
18
|
+
* routines depend on. Each test names the regression it would catch.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from 'vitest'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { resolve } from 'node:path'
|
|
23
|
+
|
|
24
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
25
|
+
|
|
26
|
+
function between(src: string, startMarker: string, endMarker: string): string {
|
|
27
|
+
const after = src.split(startMarker)[1] ?? ''
|
|
28
|
+
return after.split(endMarker)[0] ?? ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Strip // comment lines so prose quoting the OLD (buggy) shape can't
|
|
32
|
+
* satisfy — or trip — an assertion about the CODE. */
|
|
33
|
+
function codeOnly(src: string): string {
|
|
34
|
+
return src
|
|
35
|
+
.split('\n')
|
|
36
|
+
.filter((l) => !l.trim().startsWith('//'))
|
|
37
|
+
.filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
|
|
38
|
+
.join('\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('S1 wiring — turn-flush suppression call site', () => {
|
|
42
|
+
// The whole suppression branch: predicate call through the end of the
|
|
43
|
+
// `if (suppress)` early-return.
|
|
44
|
+
const branch = between(
|
|
45
|
+
gatewaySrc,
|
|
46
|
+
'const { shouldSuppressTurnFlush }',
|
|
47
|
+
'// #3276 guard 5',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it('the gateway asks the SCOPED predicate, not a chat-wide outbound count', () => {
|
|
51
|
+
expect(branch.length).toBeGreaterThan(100)
|
|
52
|
+
const code = codeOnly(branch)
|
|
53
|
+
// The oracle is the thread/length-scoped history query…
|
|
54
|
+
expect(code).toMatch(/hasSubstantiveOutbound:\s*hasOutboundDeliveredSince/)
|
|
55
|
+
// …with an EXPLICIT null thread for chat-root turns (undefined would
|
|
56
|
+
// match rows in ANY topic and re-open the cross-topic false positive)…
|
|
57
|
+
expect(code).toMatch(/threadId:\s*backstopThreadId\s*\?\?\s*null/)
|
|
58
|
+
// …and the captured answer's length driving the substantive floor.
|
|
59
|
+
expect(code).toMatch(/answerLength:\s*capturedText\.length/)
|
|
60
|
+
// The OLD chat-wide predicate must never come back anywhere in the branch.
|
|
61
|
+
expect(code).not.toMatch(/getRecentOutboundCount/)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('suppression leaves the obligation OPEN (noteTurnEnded) — never closes it', () => {
|
|
65
|
+
const code = codeOnly(branch)
|
|
66
|
+
// A false-positive suppression must stay recoverable: the sweep/liveness
|
|
67
|
+
// floor arbitrates. Closing here made the drop PERMANENT (the S1 bug).
|
|
68
|
+
expect(code).toMatch(/obligationLedger\.noteTurnEnded\(turn\.turnId/)
|
|
69
|
+
expect(code).not.toMatch(/obligationLedger\.close\(/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('the old predicate is gone from the whole gateway turn-flush path', () => {
|
|
73
|
+
// getRecentOutboundCount must not be consulted anywhere in gateway.ts —
|
|
74
|
+
// any reintroduction re-opens the chat-wide suppression class.
|
|
75
|
+
expect(codeOnly(gatewaySrc)).not.toMatch(/getRecentOutboundCount/)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('S2 wiring — thinking events re-arm the answer-ready quiescence flush', () => {
|
|
80
|
+
it("the 'thinking' event arm calls resetAnswerReadyFlushTimeout()", () => {
|
|
81
|
+
// Without this, "prose → >1s thinking pause → trailing NO_REPLY" lets the
|
|
82
|
+
// quiescence timer fire mid-pause and deliver a turn the model intended
|
|
83
|
+
// silent. Scope to the thinking case arm only.
|
|
84
|
+
const arm = between(gatewaySrc, "case 'thinking': {", "case 'tool_use': {")
|
|
85
|
+
expect(arm.length).toBeGreaterThan(50)
|
|
86
|
+
expect(codeOnly(arm)).toMatch(/resetAnswerReadyFlushTimeout\(\)/)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('S4 wiring — flushed answers quote-anchor to the inbound they answer', () => {
|
|
91
|
+
it('deliverAnswer anchors the FIRST chunk with reply_parameters + allow_sending_without_reply', () => {
|
|
92
|
+
const sendChunk = between(
|
|
93
|
+
gatewaySrc,
|
|
94
|
+
'const sendChunk = async (chunkIndex: number',
|
|
95
|
+
'buildSendOpts:',
|
|
96
|
+
)
|
|
97
|
+
expect(sendChunk.length).toBeGreaterThan(50)
|
|
98
|
+
const code = codeOnly(sendChunk)
|
|
99
|
+
// First chunk only, and only when the turn has an inbound to anchor to.
|
|
100
|
+
expect(code).toMatch(/chunkIndex === 0 && args\.replyToMessageId != null/)
|
|
101
|
+
// allow_sending_without_reply keeps the send alive if the user deleted
|
|
102
|
+
// their message — without it the whole flush would fail on a dangling id.
|
|
103
|
+
expect(code).toMatch(/allow_sending_without_reply:\s*true/)
|
|
104
|
+
expect(code).toMatch(/reply_parameters/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('the turn-flush call site passes the turn’s inbound id (bare for synthesized turns)', () => {
|
|
108
|
+
// The anchor must be the message this TURN answers — turn.sourceMessageId
|
|
109
|
+
// is null for cron/handback turns, which therefore still send bare.
|
|
110
|
+
expect(codeOnly(gatewaySrc)).toMatch(/replyToMessageId:\s*turn\.sourceMessageId/)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 fix (fable red-team 2026-07-17) — turn-flush pre-delivery suppression.
|
|
3
|
+
*
|
|
4
|
+
* The old gateway predicate (`getRecentOutboundCount(chatId, 2) > 0`) counted
|
|
5
|
+
* ANY assistant row in the WHOLE chat, so a worker progress ping, a command
|
|
6
|
+
* ack, or a reply in a different forum topic suppressed the flush and closed
|
|
7
|
+
* the obligation — silently dropping the user's real answer. These tests pin
|
|
8
|
+
* the scoped replacement: the oracle must be asked for a SAME-THREAD row of
|
|
9
|
+
* substantive length within the 2s window, and oracle failures fail OPEN
|
|
10
|
+
* (deliver, don't drop). The oracle's own thread/length SQL semantics are
|
|
11
|
+
* pinned separately in `history.test.ts` (`hasOutboundDeliveredSince`).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
shouldSuppressTurnFlush,
|
|
16
|
+
FLUSH_SUPPRESSION_WINDOW_MS,
|
|
17
|
+
} from '../gateway/turn-flush-suppression.js'
|
|
18
|
+
import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
19
|
+
|
|
20
|
+
type OracleCall = { chatId: string; sinceMs: number; threadId: number | null; minChars: number }
|
|
21
|
+
|
|
22
|
+
function capture(result: boolean) {
|
|
23
|
+
const calls: OracleCall[] = []
|
|
24
|
+
const deps = {
|
|
25
|
+
hasSubstantiveOutbound: (chatId: string, sinceMs: number, threadId: number | null, minChars: number) => {
|
|
26
|
+
calls.push({ chatId, sinceMs, threadId, minChars })
|
|
27
|
+
return result
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
return { calls, deps }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('shouldSuppressTurnFlush', () => {
|
|
34
|
+
it('suppresses iff the oracle finds a substantive same-thread outbound', () => {
|
|
35
|
+
const yes = capture(true)
|
|
36
|
+
expect(
|
|
37
|
+
shouldSuppressTurnFlush(yes.deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 }),
|
|
38
|
+
).toBe(true)
|
|
39
|
+
const no = capture(false)
|
|
40
|
+
expect(
|
|
41
|
+
shouldSuppressTurnFlush(no.deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 }),
|
|
42
|
+
).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('scopes the oracle query to the turn thread and the 2s window', () => {
|
|
46
|
+
const { calls, deps } = capture(true)
|
|
47
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 })
|
|
48
|
+
expect(calls).toHaveLength(1)
|
|
49
|
+
expect(calls[0]!.chatId).toBe('-100')
|
|
50
|
+
// Explicit thread id — never `undefined` (undefined would match rows in
|
|
51
|
+
// ANY topic and re-open the cross-topic false positive).
|
|
52
|
+
expect(calls[0]!.threadId).toBe(7)
|
|
53
|
+
expect(calls[0]!.sinceMs).toBe(10_000 - FLUSH_SUPPRESSION_WINDOW_MS)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('passes an explicit null thread for DM / chat-root turns', () => {
|
|
57
|
+
const { calls, deps } = capture(false)
|
|
58
|
+
shouldSuppressTurnFlush(deps, { chatId: '55', threadId: null, answerLength: 300, nowMs: 5_000 })
|
|
59
|
+
expect(calls[0]!.threadId).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('caps the length floor at FINAL_ANSWER_MIN_CHARS for long answers (a short ping can never suppress a long answer)', () => {
|
|
63
|
+
const { calls, deps } = capture(false)
|
|
64
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 5_000, nowMs: 10_000 })
|
|
65
|
+
expect(calls[0]!.minChars).toBe(FINAL_ANSWER_MIN_CHARS)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('lowers the floor to the answer length for terse answers (their own stream materialization still suppresses a duplicate)', () => {
|
|
69
|
+
const { calls, deps } = capture(true)
|
|
70
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 12, nowMs: 10_000 })
|
|
71
|
+
expect(calls[0]!.minChars).toBe(12)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('clamps the floor to >= 1 for a degenerate empty answer', () => {
|
|
75
|
+
const { calls, deps } = capture(false)
|
|
76
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 0, nowMs: 10_000 })
|
|
77
|
+
expect(calls[0]!.minChars).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('fails open (no suppression) when the oracle throws — deliver beats drop', () => {
|
|
81
|
+
const deps = {
|
|
82
|
+
hasSubstantiveOutbound: () => {
|
|
83
|
+
throw new Error('sqlite unavailable')
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
expect(
|
|
87
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 300, nowMs: 10_000 }),
|
|
88
|
+
).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -414,8 +414,8 @@ describe('approve vs deny shape invariants', () => {
|
|
|
414
414
|
// grant/save-outcome inbound must carry that topic so the resumed turn's
|
|
415
415
|
// reply lands back in it — not General. The carrier is two-fold and BOTH
|
|
416
416
|
// halves are load-bearing:
|
|
417
|
-
// - top-level `threadId` → the gateway's per-topic
|
|
418
|
-
//
|
|
417
|
+
// - top-level `threadId` → the gateway's per-topic deliver-until-acked
|
|
418
|
+
// keying (chatKeyForInbound reads it).
|
|
419
419
|
// - `meta.message_thread_id` (stringified) → rendered into the
|
|
420
420
|
// `<channel message_thread_id="…">` XML, which session-tail's
|
|
421
421
|
// parseChannelMeta re-extracts to set currentTurn.sessionThreadId, which
|