switchroom 0.18.3 → 0.18.7
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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -345,6 +345,42 @@ describe('projectTranscriptLine', () => {
|
|
|
345
345
|
messageId: '103',
|
|
346
346
|
})
|
|
347
347
|
})
|
|
348
|
+
|
|
349
|
+
// ─── Live model capture (message.model) ──────────────────────────────
|
|
350
|
+
it('emits a model event (first) from message.model on an assistant line', () => {
|
|
351
|
+
const line = JSON.stringify({
|
|
352
|
+
type: 'assistant',
|
|
353
|
+
message: {
|
|
354
|
+
model: 'claude-opus-4-8',
|
|
355
|
+
content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_01', input: {} }],
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
// Model event is emitted BEFORE the content events so a same-batch render
|
|
359
|
+
// already reflects the current model.
|
|
360
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
361
|
+
{ kind: 'model', model: 'claude-opus-4-8' },
|
|
362
|
+
{ kind: 'tool_use', toolName: 'Bash', toolUseId: 'toolu_01', input: {} },
|
|
363
|
+
])
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('skips a synthetic model sentinel (keeps no model event)', () => {
|
|
367
|
+
const line = JSON.stringify({
|
|
368
|
+
type: 'assistant',
|
|
369
|
+
message: {
|
|
370
|
+
model: '<synthetic>',
|
|
371
|
+
content: [{ type: 'thinking', thinking: '...' }],
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('omits the model event when message.model is absent', () => {
|
|
378
|
+
const line = JSON.stringify({
|
|
379
|
+
type: 'assistant',
|
|
380
|
+
message: { content: [{ type: 'thinking', thinking: '...' }] },
|
|
381
|
+
})
|
|
382
|
+
expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
|
|
383
|
+
})
|
|
348
384
|
})
|
|
349
385
|
|
|
350
386
|
// ─── Bug 1 regression: per-file cursor state survives re-attachment ────
|
|
@@ -490,6 +526,34 @@ describe('projectSubagentLine', () => {
|
|
|
490
526
|
])
|
|
491
527
|
})
|
|
492
528
|
|
|
529
|
+
it('emits sub_agent_model (first) from message.model on a sub-agent assistant line', () => {
|
|
530
|
+
const st = { hasEmittedStart: true }
|
|
531
|
+
const line = JSON.stringify({
|
|
532
|
+
type: 'assistant',
|
|
533
|
+
message: {
|
|
534
|
+
model: 'sr-glm-5',
|
|
535
|
+
content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
const events = projectSubagentLine(line, 'X', st)
|
|
539
|
+
expect(events[0]).toEqual({ kind: 'sub_agent_model', agentId: 'X', model: 'sr-glm-5' })
|
|
540
|
+
expect(events[1].kind).toBe('sub_agent_tool_use')
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('skips a synthetic sub-agent model sentinel', () => {
|
|
544
|
+
const st = { hasEmittedStart: true }
|
|
545
|
+
const line = JSON.stringify({
|
|
546
|
+
type: 'assistant',
|
|
547
|
+
message: {
|
|
548
|
+
model: '<synthetic>',
|
|
549
|
+
content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
|
|
550
|
+
},
|
|
551
|
+
})
|
|
552
|
+
const events = projectSubagentLine(line, 'X', st)
|
|
553
|
+
expect(events.some((e) => e.kind === 'sub_agent_model')).toBe(false)
|
|
554
|
+
expect(events[0].kind).toBe('sub_agent_tool_use')
|
|
555
|
+
})
|
|
556
|
+
|
|
493
557
|
it('emits sub_agent_tool_use for regular tools; nested Agent fires ONLY nested_spawn', () => {
|
|
494
558
|
const st = { hasEmittedStart: true }
|
|
495
559
|
const line = JSON.stringify({
|
|
@@ -190,6 +190,59 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
190
190
|
expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
|
|
191
191
|
})
|
|
192
192
|
|
|
193
|
+
it('blocks + writes retryCount=1 when an early qualifying reply is followed by an undelivered verdict (trailing-content bug repro)', () => {
|
|
194
|
+
// Confirmed-incident shape: (1) a background-task notification
|
|
195
|
+
// arrives, (2) the agent calls reply ONCE early with a stale,
|
|
196
|
+
// notification-bearing ack ("running now" — disable_notification
|
|
197
|
+
// unset, so it satisfies isFinalAnswerReply regardless of length),
|
|
198
|
+
// (3) the agent then does more work and writes a large substantive
|
|
199
|
+
// verdict as plain assistant text with NO second reply call. Pre-fix,
|
|
200
|
+
// the hook's "reply called at least once this turn" check allowed
|
|
201
|
+
// this to slip through silently — the trailing verdict never reached
|
|
202
|
+
// the user. Post-fix the hook must block on this shape exactly like
|
|
203
|
+
// the zero-reply case.
|
|
204
|
+
const transcript = writeTranscript(tmp, [
|
|
205
|
+
ENQUEUE,
|
|
206
|
+
reply('running now'),
|
|
207
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] } },
|
|
208
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/x' } }] } },
|
|
209
|
+
{
|
|
210
|
+
type: 'assistant',
|
|
211
|
+
message: { content: [{ type: 'text', text: 'Here is the actual verdict: ' + 'X'.repeat(300) }] },
|
|
212
|
+
},
|
|
213
|
+
])
|
|
214
|
+
const r = runHook({
|
|
215
|
+
event: { session_id: 's1', transcript_path: transcript },
|
|
216
|
+
stateDir,
|
|
217
|
+
})
|
|
218
|
+
expect(r.status).toBe(0)
|
|
219
|
+
const out = JSON.parse(r.stdout)
|
|
220
|
+
expect(out.decision).toBe('block')
|
|
221
|
+
expect(out.reason).toMatch(/Send your final answer/)
|
|
222
|
+
const statePath = join(stateDir, 'silent-end-pending.json')
|
|
223
|
+
expect(existsSync(statePath)).toBe(true)
|
|
224
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
225
|
+
expect(state.retryCount).toBe(1)
|
|
226
|
+
expect(state.chatId).toBe('111')
|
|
227
|
+
expect(state.turnKey).toBe('111:_')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('does NOT false-positive on a normal single-reply turn ending on the reply tool_use', () => {
|
|
231
|
+
const transcript = writeTranscript(tmp, [
|
|
232
|
+
ENQUEUE,
|
|
233
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check.' }] } },
|
|
234
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] } },
|
|
235
|
+
reply('Here is your answer.', { disable_notification: false }),
|
|
236
|
+
])
|
|
237
|
+
const r = runHook({
|
|
238
|
+
event: { session_id: 's1', transcript_path: transcript },
|
|
239
|
+
stateDir,
|
|
240
|
+
})
|
|
241
|
+
expect(r.status).toBe(0)
|
|
242
|
+
expect(r.stdout.trim()).toBe('')
|
|
243
|
+
expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
|
|
244
|
+
})
|
|
245
|
+
|
|
193
246
|
it('NO_REPLY in transcript → allow stop, no state file written', () => {
|
|
194
247
|
const transcript = writeTranscript(tmp, [
|
|
195
248
|
ENQUEUE,
|
|
@@ -184,6 +184,144 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
|
|
|
184
184
|
})
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
+
// ── "at least once" bug regression — trailing content after an early
|
|
188
|
+
// qualifying reply must still block ────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe('scanTurnForFinalReply — trailing undelivered content after an early qualifying reply (bug repro)', () => {
|
|
191
|
+
it('early notification-bearing ack + later substantive plain text → block (not "reply called once" amnesty)', () => {
|
|
192
|
+
// Repro of the confirmed incident: the agent (1) got a background-task
|
|
193
|
+
// notification, (2) called reply ONCE early with a short, stale ack —
|
|
194
|
+
// notification-bearing (disable_notification unset/false), which
|
|
195
|
+
// ALWAYS qualifies as "final" under isFinalAnswerReply regardless of
|
|
196
|
+
// text length — then (3) wrote a large substantive verdict as plain
|
|
197
|
+
// assistant text with NO second reply call. The pre-fix scan returned
|
|
198
|
+
// 'allow' on the first qualifying block and never looked further; the
|
|
199
|
+
// fix must walk the whole turn and catch the undelivered trailing text.
|
|
200
|
+
const text = jsonl(
|
|
201
|
+
ENQUEUE,
|
|
202
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'running now' }),
|
|
203
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
204
|
+
assistantToolUse('Read', { file_path: '/tmp/x' }),
|
|
205
|
+
assistantText('Here is the actual verdict after investigation: ' + 'X'.repeat(300)),
|
|
206
|
+
)
|
|
207
|
+
const r = scanTurnForFinalReply(text)
|
|
208
|
+
expect(r.decided).toBe('block')
|
|
209
|
+
expect(r.reason).toBe('trailing-text-after-reply')
|
|
210
|
+
// turnKey/chatId must still be populated so the gateway's retry
|
|
211
|
+
// bookkeeping works exactly as it does for the zero-reply block path.
|
|
212
|
+
expect(r.chatId).toBe('111')
|
|
213
|
+
expect(r.turnKey).toBe('111:_')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('early qualifying reply + trailing SHORT pleasantry → allow (#2956 review: substance floor, no-spam)', () => {
|
|
217
|
+
// A short trailing text after a delivered reply (a closer like "let me
|
|
218
|
+
// know if you need anything else.") is NOT a dropped answer — it's a
|
|
219
|
+
// pleasantry the persona prompts discourage but which must not burn
|
|
220
|
+
// retry budget or force a redundant second reply. Only SUBSTANTIVE
|
|
221
|
+
// trailing text (≥ FINAL_ANSWER_MIN_CHARS) blocks. Pre-fix this
|
|
222
|
+
// false-positive blocked and re-prompted a healthy turn.
|
|
223
|
+
const text = jsonl(
|
|
224
|
+
ENQUEUE,
|
|
225
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
|
|
226
|
+
assistantText('actually, one more thing you should know'),
|
|
227
|
+
)
|
|
228
|
+
const r = scanTurnForFinalReply(text)
|
|
229
|
+
expect(r.decided).toBe('allow')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('early qualifying reply + trailing SUBSTANTIVE undelivered text (≥ floor) → block (at-least-once holds)', () => {
|
|
233
|
+
// The substance floor must NOT weaken the at-least-once guarantee for
|
|
234
|
+
// a real dropped answer: a long trailing verdict the model forgot to
|
|
235
|
+
// send still blocks. The trailing text here clears FINAL_ANSWER_MIN_CHARS.
|
|
236
|
+
const text = jsonl(
|
|
237
|
+
ENQUEUE,
|
|
238
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
|
|
239
|
+
assistantText('Here is the actual verdict after investigation: ' + 'X'.repeat(200)),
|
|
240
|
+
)
|
|
241
|
+
const r = scanTurnForFinalReply(text)
|
|
242
|
+
expect(r.decided).toBe('block')
|
|
243
|
+
expect(r.reason).toBe('trailing-text-after-reply')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('final qualifying reply is the LAST content block → allow (healthy shape, no false positive)', () => {
|
|
247
|
+
const text = jsonl(
|
|
248
|
+
ENQUEUE,
|
|
249
|
+
assistantText('Let me check that.'),
|
|
250
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
251
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
252
|
+
text: 'Here is your answer.',
|
|
253
|
+
disable_notification: false,
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
const r = scanTurnForFinalReply(text)
|
|
257
|
+
expect(r.decided).toBe('allow')
|
|
258
|
+
expect(r.reason).toBe('final-reply')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('turn ends on a tool_result with no trailing text at all → allow (not a normal-turn false positive)', () => {
|
|
262
|
+
// A turn whose very last assistant content is the delivering
|
|
263
|
+
// reply tool_use, followed only by a (user-role) tool_result line —
|
|
264
|
+
// never any further assistant text. Must not be flagged.
|
|
265
|
+
const text = jsonl(
|
|
266
|
+
ENQUEUE,
|
|
267
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
268
|
+
text: 'Done — here is the summary.',
|
|
269
|
+
disable_notification: false,
|
|
270
|
+
}),
|
|
271
|
+
JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', content: 'ok' }] } }),
|
|
272
|
+
)
|
|
273
|
+
const r = scanTurnForFinalReply(text)
|
|
274
|
+
expect(r.decided).toBe('allow')
|
|
275
|
+
expect(r.reason).toBe('final-reply')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('undelivered text sandwiched between two qualifying replies is superseded by the LATER reply → allow', () => {
|
|
279
|
+
const text = jsonl(
|
|
280
|
+
ENQUEUE,
|
|
281
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'first answer', disable_notification: false }),
|
|
282
|
+
assistantText('actually let me reconsider that'),
|
|
283
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'corrected final answer', disable_notification: false }),
|
|
284
|
+
)
|
|
285
|
+
const r = scanTurnForFinalReply(text)
|
|
286
|
+
expect(r.decided).toBe('allow')
|
|
287
|
+
expect(r.reason).toBe('final-reply')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('explicit trailing NO_REPLY after an earlier qualifying reply overrides → allow (intentional final silence)', () => {
|
|
291
|
+
const text = jsonl(
|
|
292
|
+
ENQUEUE,
|
|
293
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'draft answer', disable_notification: false }),
|
|
294
|
+
assistantText('actually, scrap that, nothing more to add\nNO_REPLY'),
|
|
295
|
+
)
|
|
296
|
+
const r = scanTurnForFinalReply(text)
|
|
297
|
+
expect(r.decided).toBe('allow')
|
|
298
|
+
expect(r.reason).toBe('silent-marker-text')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('reverse case: an EARLIER NO_REPLY silence marker followed by later undelivered prose → block', () => {
|
|
302
|
+
// Mirror image of the "trailing NO_REPLY overrides" allow-case above.
|
|
303
|
+
// Here the model signals silence FIRST — as plain transcript text,
|
|
304
|
+
// not through the reply tool — and then keeps going, writing a real
|
|
305
|
+
// substantive answer afterward that it never sent through `reply` or
|
|
306
|
+
// `stream_reply`. The NO_REPLY marker is a "deliver" event (silence is
|
|
307
|
+
// a valid outcome), but it must not amnesty content written AFTER it,
|
|
308
|
+
// same failure shape as the original "at least once" bug: a naive scan
|
|
309
|
+
// that stops at the FIRST qualifying delivery/silence event would
|
|
310
|
+
// return 'allow' here and the trailing answer would be silently
|
|
311
|
+
// dropped. The fix's "last delivery event, then check for trailing
|
|
312
|
+
// text" walk must still catch this.
|
|
313
|
+
const text = jsonl(
|
|
314
|
+
ENQUEUE,
|
|
315
|
+
assistantText('Nothing to report right now.\nNO_REPLY'),
|
|
316
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
317
|
+
assistantText('Wait, actually I found something you need to know: ' + 'Y'.repeat(300)),
|
|
318
|
+
)
|
|
319
|
+
const r = scanTurnForFinalReply(text)
|
|
320
|
+
expect(r.decided).toBe('block')
|
|
321
|
+
expect(r.reason).toBe('trailing-text-after-reply')
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
187
325
|
describe('scanTurnForFinalReply — silent-marker carve-out', () => {
|
|
188
326
|
it('NO_REPLY → allow', () => {
|
|
189
327
|
const text = jsonl(
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
recordSilentTurnEnd,
|
|
11
11
|
recordUndeliveredTurnEnd,
|
|
12
12
|
SILENT_END_MAX_RETRIES,
|
|
13
|
+
SILENT_END_STALE_RECORD_MAX_AGE_MS,
|
|
13
14
|
} from '../silent-end.js'
|
|
14
15
|
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
15
16
|
|
|
@@ -206,12 +207,56 @@ describe('recordSilentTurnEnd — #1161 exhaustion detection', () => {
|
|
|
206
207
|
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
207
208
|
retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
|
|
208
209
|
}))
|
|
209
|
-
|
|
210
|
+
// Pin `now` alongside the record's timestamp=0 so this exercises the
|
|
211
|
+
// genuine same-turn ladder, not fix #8's age-based staleness bound
|
|
212
|
+
// (which is covered by its own dedicated tests below).
|
|
213
|
+
const r = recordSilentTurnEnd(
|
|
214
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_' },
|
|
215
|
+
{ now: () => 0 },
|
|
216
|
+
)
|
|
210
217
|
expect(r.exhausted).toBe(true)
|
|
211
218
|
// State cleared so the Stop hook on this final turn allows the stop.
|
|
212
219
|
expect(readSilentEndState()).toBeNull()
|
|
213
220
|
})
|
|
214
221
|
|
|
222
|
+
it('fix #8: an exhausted record older than the plausible turn lifetime starts a fresh retry budget (no delivery evidence needed)', () => {
|
|
223
|
+
// A crash/interrupt bypassed the gateway's own exhaust-read-and-clear,
|
|
224
|
+
// so a spent (retryCount >= MAX) record from an OLD turn survives on
|
|
225
|
+
// disk. turnKey is the STABLE statusKey(chatId, threadId) — it matches
|
|
226
|
+
// this brand-new dark turn on the same chat/thread even though it
|
|
227
|
+
// belongs to a completely different turn instance.
|
|
228
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
229
|
+
writeFileSync(path, JSON.stringify({
|
|
230
|
+
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
231
|
+
retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
|
|
232
|
+
}))
|
|
233
|
+
const now = SILENT_END_STALE_RECORD_MAX_AGE_MS + 1000 // just past the age bound
|
|
234
|
+
const r = recordSilentTurnEnd(
|
|
235
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_' },
|
|
236
|
+
{ now: () => now },
|
|
237
|
+
)
|
|
238
|
+
// Must run its OWN re-prompt ladder, not immediately fall back.
|
|
239
|
+
expect(r.exhausted).toBe(false)
|
|
240
|
+
expect(readSilentEndState()).toMatchObject({ turnKey: 'c:_', retryCount: 0 })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('fix #8: a genuinely same-turn exhausted record (within the age bound) still reports exhausted — no regression', () => {
|
|
244
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
245
|
+
const recentTimestamp = 1_000_000
|
|
246
|
+
writeFileSync(path, JSON.stringify({
|
|
247
|
+
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
248
|
+
retryCount: SILENT_END_MAX_RETRIES, timestamp: recentTimestamp,
|
|
249
|
+
}))
|
|
250
|
+
// Well within the plausible single-turn retry-ladder window.
|
|
251
|
+
const now = recentTimestamp + 5000
|
|
252
|
+
const r = recordSilentTurnEnd(
|
|
253
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_' },
|
|
254
|
+
{ now: () => now },
|
|
255
|
+
)
|
|
256
|
+
expect(r.exhausted).toBe(true)
|
|
257
|
+
expect(readSilentEndState()).toBeNull()
|
|
258
|
+
})
|
|
259
|
+
|
|
215
260
|
it('treats a capped prior state for a DIFFERENT turn as a fresh silent-end', () => {
|
|
216
261
|
const path = join(stateDir, 'silent-end-pending.json')
|
|
217
262
|
writeFileSync(path, JSON.stringify({
|
|
@@ -108,6 +108,45 @@ describe('subagent-tracker-pretool', () => {
|
|
|
108
108
|
expect(row!.last_activity_at).toBe(row!.started_at)
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
it('persists tool_input.model as the first-paint model on the row', () => {
|
|
112
|
+
const event = {
|
|
113
|
+
session_id: 'sess-model',
|
|
114
|
+
tool_name: 'Agent',
|
|
115
|
+
tool_use_id: 'toolu_model001',
|
|
116
|
+
tool_input: {
|
|
117
|
+
subagent_type: 'worker',
|
|
118
|
+
description: 'Build with a pinned model',
|
|
119
|
+
run_in_background: true,
|
|
120
|
+
model: 'claude-opus-4-8',
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
const result = runHook(PRETOOL_SCRIPT, event)
|
|
124
|
+
expect(result.status).toBe(0)
|
|
125
|
+
|
|
126
|
+
const db = openDb()
|
|
127
|
+
const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_model001') as
|
|
128
|
+
| { model: string | null }
|
|
129
|
+
| undefined
|
|
130
|
+
expect(row?.model).toBe('claude-opus-4-8')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('leaves model null when the Agent dispatch carries no model', () => {
|
|
134
|
+
const event = {
|
|
135
|
+
session_id: 'sess-nomodel',
|
|
136
|
+
tool_name: 'Agent',
|
|
137
|
+
tool_use_id: 'toolu_nomodel001',
|
|
138
|
+
tool_input: { subagent_type: 'worker', description: 'no model', run_in_background: false },
|
|
139
|
+
}
|
|
140
|
+
const result = runHook(PRETOOL_SCRIPT, event)
|
|
141
|
+
expect(result.status).toBe(0)
|
|
142
|
+
|
|
143
|
+
const db = openDb()
|
|
144
|
+
const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_nomodel001') as
|
|
145
|
+
| { model: string | null }
|
|
146
|
+
| undefined
|
|
147
|
+
expect(row?.model ?? null).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
111
150
|
it('does not write a row when tool_name is not Agent', () => {
|
|
112
151
|
const event = {
|
|
113
152
|
session_id: 'sess-abc123',
|
|
@@ -37,14 +37,19 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
37
37
|
let currentTime = 100_000
|
|
38
38
|
// The file EXISTS AT BOOT with real in-flight activity: a prompt and two
|
|
39
39
|
// tool steps, still running (no turn_end). mtime is fresh (30s ago) so
|
|
40
|
-
// the freshness gate
|
|
41
|
-
|
|
40
|
+
// the freshness gate makes it ELIGIBLE — but fix #5 also requires actual
|
|
41
|
+
// post-boot growth before promoting (mtime freshness alone can't
|
|
42
|
+
// distinguish this from a worker killed moments before the restart).
|
|
43
|
+
// `content` grows below via the captured fs.watch callback to supply
|
|
44
|
+
// that confirmation.
|
|
45
|
+
let content = Buffer.from(buildJSONL(
|
|
42
46
|
{ type: 'user', message: { content: [{ type: 'text', text: 'long research task' }] } },
|
|
43
47
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
|
|
44
48
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'npm test' } }] } },
|
|
45
49
|
), 'utf-8')
|
|
46
50
|
|
|
47
51
|
let lastOpened: string | null = null
|
|
52
|
+
let watchCb: (() => void) | null = null
|
|
48
53
|
const mockFs = {
|
|
49
54
|
existsSync: ((p: fs.PathLike) => {
|
|
50
55
|
const ps = String(p)
|
|
@@ -69,7 +74,10 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
69
74
|
src.copy(buf as Buffer, offset)
|
|
70
75
|
return src.length
|
|
71
76
|
}) as unknown as typeof fs.readSync,
|
|
72
|
-
watch: ((
|
|
77
|
+
watch: ((_p: fs.PathLike, cb?: () => void) => {
|
|
78
|
+
watchCb = cb ?? null
|
|
79
|
+
return { close: vi.fn() } as unknown as fs.FSWatcher
|
|
80
|
+
}) as unknown as typeof fs.watch,
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
const progressCalls: Array<{ agentId: string; progressLine?: string; toolCount: number }> = []
|
|
@@ -86,10 +94,25 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
86
94
|
},
|
|
87
95
|
})
|
|
88
96
|
|
|
97
|
+
// Fix #5: registration alone (mtime-fresh, no observed growth yet) must
|
|
98
|
+
// NOT promote — it stays historical/pending until growth confirms it.
|
|
99
|
+
const preGrowthEntry = watcher.getRegistry().get(agentId)
|
|
100
|
+
expect(preGrowthEntry?.historical).toBe(true)
|
|
101
|
+
expect(preGrowthEntry?.bootPromotionPending).toBeDefined()
|
|
102
|
+
|
|
103
|
+
// Simulate the worker taking one more real step post-boot: the JSONL
|
|
104
|
+
// grows, and Claude Code's fs notification fires.
|
|
105
|
+
content = Buffer.concat([content, Buffer.from(buildJSONL(
|
|
106
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'still going' }] } },
|
|
107
|
+
), 'utf-8')])
|
|
108
|
+
currentTime += 1000
|
|
109
|
+
watchCb?.()
|
|
110
|
+
|
|
89
111
|
const entry = watcher.getRegistry().get(agentId)
|
|
90
112
|
expect(entry).toBeDefined()
|
|
91
|
-
// Promoted live
|
|
113
|
+
// Promoted live now that post-boot growth was actually observed.
|
|
92
114
|
expect(entry?.historical).toBe(false)
|
|
115
|
+
expect(entry?.bootPromotionPending).toBeUndefined()
|
|
93
116
|
// Replay rebuilt the entry WITHOUT double-counting the initial read.
|
|
94
117
|
expect(entry?.toolCount).toBe(2)
|
|
95
118
|
expect(entry?.lastTool?.name).toBe('Bash')
|
|
@@ -102,6 +125,86 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
102
125
|
watcher.stop()
|
|
103
126
|
})
|
|
104
127
|
|
|
128
|
+
it('fix #5: a running-at-boot file with fresh mtime but NO post-boot growth is never promoted (avoids a stale completed handback)', () => {
|
|
129
|
+
const agentId = 'inflight-no-growth-01'
|
|
130
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
131
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
132
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
133
|
+
const sessionDir = `${projectDir}/sess`
|
|
134
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
135
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
136
|
+
|
|
137
|
+
let currentTime = 100_000
|
|
138
|
+
// Fresh mtime (30s old) but the file NEVER grows again — indistinguishable
|
|
139
|
+
// from a worker that was killed moments before this restart.
|
|
140
|
+
const content = Buffer.from(buildJSONL(
|
|
141
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'task that got killed' }] } },
|
|
142
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
|
|
143
|
+
), 'utf-8')
|
|
144
|
+
|
|
145
|
+
let lastOpened: string | null = null
|
|
146
|
+
const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
|
|
147
|
+
const mockFs = {
|
|
148
|
+
existsSync: ((p: fs.PathLike) => {
|
|
149
|
+
const ps = String(p)
|
|
150
|
+
return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
|
|
151
|
+
}) as typeof fs.existsSync,
|
|
152
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
153
|
+
const ps = String(p)
|
|
154
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
155
|
+
if (ps === projectDir) return ['sess']
|
|
156
|
+
if (ps === sessionDir) return ['subagents']
|
|
157
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
158
|
+
return []
|
|
159
|
+
}) as unknown as typeof fs.readdirSync,
|
|
160
|
+
statSync: (() => ({ size: content.length, mtimeMs: 100_000 - 30_000 }) as fs.Stats) as typeof fs.statSync,
|
|
161
|
+
openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
|
|
162
|
+
closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
|
|
163
|
+
readSync: ((
|
|
164
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
165
|
+
): number => {
|
|
166
|
+
if (lastOpened !== jsonlPath) return 0
|
|
167
|
+
const src = content.slice(position ?? 0, (position ?? 0) + length)
|
|
168
|
+
src.copy(buf as Buffer, offset)
|
|
169
|
+
return src.length
|
|
170
|
+
}) as unknown as typeof fs.readSync,
|
|
171
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
175
|
+
const watcher = startSubagentWatcher({
|
|
176
|
+
agentDir,
|
|
177
|
+
fs: mockFs,
|
|
178
|
+
inflightPromoteMaxAgeMs: 60_000, // small bound so the test can cross it
|
|
179
|
+
now: () => currentTime,
|
|
180
|
+
setInterval: (fn, ms) => {
|
|
181
|
+
intervals.push({ fn, ms, fireAt: currentTime + ms })
|
|
182
|
+
return { ref: 0 }
|
|
183
|
+
},
|
|
184
|
+
clearInterval: () => {},
|
|
185
|
+
setTimeout: () => ({ ref: 0 }),
|
|
186
|
+
clearTimeout: () => {},
|
|
187
|
+
onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const entryAtBoot = watcher.getRegistry().get(agentId)
|
|
191
|
+
expect(entryAtBoot?.historical).toBe(true)
|
|
192
|
+
expect(entryAtBoot?.bootPromotionPending).toBeDefined()
|
|
193
|
+
|
|
194
|
+
// Advance well past the promotion window WITHOUT the file ever growing.
|
|
195
|
+
currentTime += 120_000
|
|
196
|
+
for (const iv of intervals) if (iv.fireAt <= currentTime) iv.fn()
|
|
197
|
+
|
|
198
|
+
const entry = watcher.getRegistry().get(agentId)
|
|
199
|
+
// Never promoted: still historical, no completed/failed handback ever
|
|
200
|
+
// synthesised from these pre-restart bytes.
|
|
201
|
+
expect(entry?.historical).toBe(true)
|
|
202
|
+
expect(entry?.bootPromotionPending).toBeUndefined() // gave up after the deadline
|
|
203
|
+
expect(finishCalls).toHaveLength(0)
|
|
204
|
+
|
|
205
|
+
watcher.stop()
|
|
206
|
+
})
|
|
207
|
+
|
|
105
208
|
it('a STALE running-at-boot file stays historical and fires no onProgress (no replay spam)', () => {
|
|
106
209
|
const agentId = 'stale-at-boot-01'
|
|
107
210
|
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
@@ -224,14 +224,22 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
|
|
|
224
224
|
expect(h.finishCalls[0].agentId).toBe('gap1-complete')
|
|
225
225
|
expect(h.finishCalls[0].outcome).toBe('completed') // pre-fix: 'orphan' → dropped
|
|
226
226
|
expect(h.finishCalls[0].resultText).toContain('root cause')
|
|
227
|
-
//
|
|
228
|
-
|
|
227
|
+
// Fix #5: promotion now requires an observed post-boot growth event
|
|
228
|
+
// (mtime freshness alone is refused) — the `append` above supplied it,
|
|
229
|
+
// and the promotion is logged so the path is observable in prod.
|
|
230
|
+
expect(h.logs.some((l) => l.includes('confirmed live') && l.includes('post-boot JSONL growth'))).toBe(true)
|
|
229
231
|
})
|
|
230
232
|
|
|
231
|
-
it('an in-flight-at-boot worker that dies silently is rescued by stall synthesis', () => {
|
|
233
|
+
it('an in-flight-at-boot worker that dies silently (after proving liveness) is rescued by stall synthesis', () => {
|
|
232
234
|
// Pre-fix, historical entries were skipped by stall detection, so a
|
|
233
235
|
// worker that crossed a restart and then went silent sat running
|
|
234
236
|
// forever — no handback ever. After promotion it gets the safety net.
|
|
237
|
+
//
|
|
238
|
+
// Fix #5: promotion additionally requires proof of post-boot liveness —
|
|
239
|
+
// a single real step (e.g. a narrative line) grows the JSONL past its
|
|
240
|
+
// boot-time snapshot, confirming this is NOT a worker killed moments
|
|
241
|
+
// before the restart. Only THEN does it get the stall-synthesis safety
|
|
242
|
+
// net when it subsequently goes silent for good.
|
|
235
243
|
const h = makeHarness({
|
|
236
244
|
agentId: 'gap1-silent',
|
|
237
245
|
bootLines: [subAgentUserMsg('bg task')],
|
|
@@ -239,6 +247,11 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
|
|
|
239
247
|
silentStallTerminalMs: 120_000,
|
|
240
248
|
})
|
|
241
249
|
|
|
250
|
+
h.append(subAgentText('still investigating'))
|
|
251
|
+
h.advance(600) // one poll observes the post-boot growth → promotes
|
|
252
|
+
|
|
253
|
+
expect(h.watcher.getRegistry().get('gap1-silent')?.historical).toBe(false)
|
|
254
|
+
|
|
242
255
|
h.advance(62_000) // stall threshold crossed
|
|
243
256
|
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
244
257
|
h.advance(121_000) // silent-stall terminal window elapses → synthesis
|
|
@@ -247,6 +260,30 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
|
|
|
247
260
|
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
248
261
|
})
|
|
249
262
|
|
|
263
|
+
it('fix #5: an in-flight-at-boot worker that NEVER shows post-boot growth is left historical (no stale completed handback)', () => {
|
|
264
|
+
// The bug this closes: a worker killed <15min before a restart is
|
|
265
|
+
// byte-for-byte indistinguishable from a live one by mtime alone. Absent
|
|
266
|
+
// any actual post-boot growth, it must NOT be promoted — so it can never
|
|
267
|
+
// synthesise a false 'completed' handback from the pre-restart bytes.
|
|
268
|
+
const h = makeHarness({
|
|
269
|
+
agentId: 'gap1-killed-before-restart',
|
|
270
|
+
bootLines: [subAgentUserMsg('bg task')],
|
|
271
|
+
stallThresholdMs: 60_000,
|
|
272
|
+
silentStallTerminalMs: 120_000,
|
|
273
|
+
inflightPromoteMaxAgeMs: 60_000,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// No append — the file never grows again after boot.
|
|
277
|
+
h.advance(62_000)
|
|
278
|
+
h.advance(121_000)
|
|
279
|
+
h.advance(600_000) // well past the promotion window too
|
|
280
|
+
|
|
281
|
+
expect(h.watcher.getRegistry().get('gap1-killed-before-restart')?.historical).toBe(true)
|
|
282
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
283
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
284
|
+
expect(h.logs.some((l) => l.includes('never observed post-boot JSONL growth'))).toBe(true)
|
|
285
|
+
})
|
|
286
|
+
|
|
250
287
|
it('a worker already DONE at boot stays suppressed (no spurious replay)', () => {
|
|
251
288
|
// The legitimate use of `historical`: a worker that finished in a prior
|
|
252
289
|
// session must NOT re-fire a handback on every restart. This is the
|
|
@@ -298,7 +335,8 @@ describe('Gap 1 freshness gate — v0.14.24 stale-replay regression', () => {
|
|
|
298
335
|
|
|
299
336
|
expect(h.finishCalls).toHaveLength(1)
|
|
300
337
|
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
301
|
-
|
|
338
|
+
// Fix #5: the append above supplies the required post-boot growth proof.
|
|
339
|
+
expect(h.logs.some((l) => l.includes('confirmed live'))).toBe(true)
|
|
302
340
|
})
|
|
303
341
|
|
|
304
342
|
it('kill-switch (bootPromoteEnabled=false) suppresses even a fresh running-at-boot worker', () => {
|