switchroom 0.7.15 → 0.10.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.
- package/README.md +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SubagentWatcher's post-stall terminal-synthesis path
|
|
3
|
+
* (RFC §Bug 6 — background `Agent` dispatches in some Claude Code
|
|
4
|
+
* versions write a JSONL that never ends with `system + turn_duration`,
|
|
5
|
+
* so the canonical `sub_agent_turn_end` event never fires). Locks the
|
|
6
|
+
* contract that:
|
|
7
|
+
*
|
|
8
|
+
* 1. After `silentStallTerminalMs` past the stall notification, the
|
|
9
|
+
* watcher synthesises terminal: flips `entry.state = 'done'`, fires
|
|
10
|
+
* `onStallTerminal`, fires the existing `onFinish` audit surface.
|
|
11
|
+
* 2. Synthesis is idempotent: each entry triggers it at most once per
|
|
12
|
+
* lifetime (no repeat fires on every poll tick once the window
|
|
13
|
+
* elapses).
|
|
14
|
+
* 3. A pre-window unstall (JSONL activity resumes) clears `stalledAt`
|
|
15
|
+
* and prevents synthesis. A subsequent re-stall starts the clock
|
|
16
|
+
* from scratch.
|
|
17
|
+
* 4. Synthesis is suppressed for entries whose state is already `done`
|
|
18
|
+
* or `failed` — only `running + stallNotified` entries qualify.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
22
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
23
|
+
import * as fs from 'fs'
|
|
24
|
+
|
|
25
|
+
function buildJSONL(...lines: object[]): string {
|
|
26
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
27
|
+
}
|
|
28
|
+
function subAgentUserMsg(promptText: string) {
|
|
29
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
30
|
+
}
|
|
31
|
+
function subAgentTurnEnd() {
|
|
32
|
+
return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
stallCalls: Array<{ agentId: string; idleMs: number }>
|
|
37
|
+
stallTerminalCalls: Array<{ agentId: string; description: string }>
|
|
38
|
+
finishCalls: Array<{ agentId: string; outcome: string }>
|
|
39
|
+
unstallCalls: Array<{ agentId: string }>
|
|
40
|
+
logs: string[]
|
|
41
|
+
advance: (ms: number) => void
|
|
42
|
+
watcher: ReturnType<typeof startSubagentWatcher>
|
|
43
|
+
fileContents: Map<string, Buffer>
|
|
44
|
+
jsonlPath: string
|
|
45
|
+
appendActivity: () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeHarness(opts: {
|
|
49
|
+
agentId?: string
|
|
50
|
+
stallThresholdMs?: number
|
|
51
|
+
silentStallTerminalMs?: number
|
|
52
|
+
rescanMs?: number
|
|
53
|
+
} = {}): Harness {
|
|
54
|
+
const {
|
|
55
|
+
agentId = 'bug6-agent',
|
|
56
|
+
stallThresholdMs = 60_000,
|
|
57
|
+
silentStallTerminalMs = 300_000,
|
|
58
|
+
rescanMs = 500,
|
|
59
|
+
} = opts
|
|
60
|
+
|
|
61
|
+
let currentTime = 1000
|
|
62
|
+
const stallCalls: Array<{ agentId: string; idleMs: number }> = []
|
|
63
|
+
const stallTerminalCalls: Array<{ agentId: string; description: string }> = []
|
|
64
|
+
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
65
|
+
const unstallCalls: Array<{ agentId: string }> = []
|
|
66
|
+
const logs: string[] = []
|
|
67
|
+
|
|
68
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
69
|
+
const sessionId = 'mock-session'
|
|
70
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
71
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
72
|
+
const sessionDir = `${projectDir}/${sessionId}`
|
|
73
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
74
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
75
|
+
|
|
76
|
+
const fileContents = new Map<string, Buffer>()
|
|
77
|
+
fileContents.set(
|
|
78
|
+
jsonlPath,
|
|
79
|
+
Buffer.from(buildJSONL(subAgentUserMsg('bg task')), 'utf-8'),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
let lastOpenedPath: string | null = null
|
|
83
|
+
const mockFs = {
|
|
84
|
+
existsSync: ((p: fs.PathLike) => {
|
|
85
|
+
const ps = String(p)
|
|
86
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
87
|
+
if (fileContents.has(ps)) return true
|
|
88
|
+
return false
|
|
89
|
+
}) as typeof fs.existsSync,
|
|
90
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
91
|
+
const ps = String(p)
|
|
92
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
93
|
+
if (ps === projectDir) return [sessionId]
|
|
94
|
+
if (ps === sessionDir) return ['subagents']
|
|
95
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
96
|
+
return []
|
|
97
|
+
}) as unknown as typeof fs.readdirSync,
|
|
98
|
+
statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
|
|
99
|
+
openSync: ((p: fs.PathLike) => {
|
|
100
|
+
lastOpenedPath = String(p)
|
|
101
|
+
return 42
|
|
102
|
+
}) as unknown as typeof fs.openSync,
|
|
103
|
+
closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
|
|
104
|
+
readSync: ((
|
|
105
|
+
_fd: number,
|
|
106
|
+
buf: NodeJS.ArrayBufferView,
|
|
107
|
+
offset: number,
|
|
108
|
+
length: number,
|
|
109
|
+
position: number | null,
|
|
110
|
+
): number => {
|
|
111
|
+
const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
|
|
112
|
+
if (!content) return 0
|
|
113
|
+
const pos = position ?? 0
|
|
114
|
+
const src = content.slice(pos, pos + length)
|
|
115
|
+
;(src as Buffer).copy(buf as Buffer, offset)
|
|
116
|
+
return src.length
|
|
117
|
+
}) as unknown as typeof fs.readSync,
|
|
118
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
|
|
122
|
+
let nextRef = 1
|
|
123
|
+
|
|
124
|
+
const watcher = startSubagentWatcher({
|
|
125
|
+
agentDir,
|
|
126
|
+
stallThresholdMs,
|
|
127
|
+
silentSynthesisStallThresholdMs: stallThresholdMs,
|
|
128
|
+
silentStallTerminalMs,
|
|
129
|
+
rescanMs,
|
|
130
|
+
sendNotification: () => {},
|
|
131
|
+
onStall: (id, idleMs) => stallCalls.push({ agentId: id, idleMs }),
|
|
132
|
+
onUnstall: (id) => unstallCalls.push({ agentId: id }),
|
|
133
|
+
onStallTerminal: (id, desc) => stallTerminalCalls.push({ agentId: id, description: desc }),
|
|
134
|
+
onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
|
|
135
|
+
now: () => currentTime,
|
|
136
|
+
setInterval: (fn, ms) => {
|
|
137
|
+
const ref = nextRef++
|
|
138
|
+
intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
|
|
139
|
+
return { ref }
|
|
140
|
+
},
|
|
141
|
+
clearInterval: (handle) => {
|
|
142
|
+
const { ref } = handle as { ref: number }
|
|
143
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
144
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
145
|
+
},
|
|
146
|
+
fs: mockFs,
|
|
147
|
+
log: (msg) => logs.push(msg),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const advance = (ms: number): void => {
|
|
151
|
+
currentTime += ms
|
|
152
|
+
for (;;) {
|
|
153
|
+
intervals.sort((a, b) => a.fireAt - b.fireAt)
|
|
154
|
+
const next = intervals[0]
|
|
155
|
+
if (!next || next.fireAt > currentTime) break
|
|
156
|
+
next.fireAt += next.ms
|
|
157
|
+
next.fn()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const appendActivity = (): void => {
|
|
162
|
+
// Append a `text` line so the watcher sees the JSONL grow and
|
|
163
|
+
// flips the entry out of "stalled" via the unstall path.
|
|
164
|
+
const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
|
|
165
|
+
const more = buildJSONL({
|
|
166
|
+
type: 'assistant',
|
|
167
|
+
message: { content: [{ type: 'text', text: 'still working' }] },
|
|
168
|
+
})
|
|
169
|
+
fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(more, 'utf-8')]))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
stallCalls,
|
|
174
|
+
stallTerminalCalls,
|
|
175
|
+
finishCalls,
|
|
176
|
+
unstallCalls,
|
|
177
|
+
logs,
|
|
178
|
+
advance,
|
|
179
|
+
watcher,
|
|
180
|
+
fileContents,
|
|
181
|
+
jsonlPath,
|
|
182
|
+
appendActivity,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Files present at boot are flagged historical=true and stall+synth
|
|
187
|
+
// detection is suppressed for those (don't flood chat on restart).
|
|
188
|
+
// Tests flip the flag to simulate a post-boot discovery — same pattern
|
|
189
|
+
// as the existing stall-notification tests.
|
|
190
|
+
function unmarkHistorical(harness: Harness, agentId: string): void {
|
|
191
|
+
const entry = harness.watcher.getRegistry().get(agentId)
|
|
192
|
+
if (entry) entry.historical = false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
describe('subagent-watcher post-stall terminal synthesis (RFC §Bug 6)', () => {
|
|
196
|
+
it('synthesises terminal after silentStallTerminalMs past stall notification', () => {
|
|
197
|
+
const agentId = 'bug6-synth-1'
|
|
198
|
+
const h = makeHarness({
|
|
199
|
+
agentId,
|
|
200
|
+
stallThresholdMs: 60_000,
|
|
201
|
+
silentStallTerminalMs: 300_000,
|
|
202
|
+
rescanMs: 500,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
h.advance(500) // register
|
|
206
|
+
unmarkHistorical(h, agentId)
|
|
207
|
+
h.advance(62_000) // stall fires
|
|
208
|
+
expect(h.stallCalls).toHaveLength(1)
|
|
209
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
210
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
211
|
+
|
|
212
|
+
// Advance to JUST BEFORE the post-stall window closes — synth must
|
|
213
|
+
// not fire yet.
|
|
214
|
+
h.advance(299_000)
|
|
215
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
216
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
217
|
+
|
|
218
|
+
// Cross the threshold — synthesis fires exactly once.
|
|
219
|
+
h.advance(2_000)
|
|
220
|
+
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
221
|
+
expect(h.stallTerminalCalls[0].agentId).toBe(agentId)
|
|
222
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
223
|
+
expect(h.finishCalls[0].agentId).toBe(agentId)
|
|
224
|
+
// The synthesised path uses outcome 'completed' so downstream
|
|
225
|
+
// consumers treat it the same as a real `sub_agent_turn_end` —
|
|
226
|
+
// the audit log entry distinguishes via the `synthesis` log line.
|
|
227
|
+
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('is idempotent — does not re-fire on subsequent poll ticks', () => {
|
|
231
|
+
const agentId = 'bug6-synth-idempotent'
|
|
232
|
+
const h = makeHarness({
|
|
233
|
+
agentId,
|
|
234
|
+
stallThresholdMs: 60_000,
|
|
235
|
+
silentStallTerminalMs: 60_000,
|
|
236
|
+
rescanMs: 500,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
h.advance(500)
|
|
240
|
+
unmarkHistorical(h, agentId)
|
|
241
|
+
h.advance(62_000)
|
|
242
|
+
h.advance(62_000) // synth fires
|
|
243
|
+
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
244
|
+
|
|
245
|
+
// Many more polls past the window — synth must not re-fire.
|
|
246
|
+
h.advance(60_000)
|
|
247
|
+
h.advance(60_000)
|
|
248
|
+
h.advance(60_000)
|
|
249
|
+
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
250
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('a pre-window unstall resets the synthesis clock', () => {
|
|
254
|
+
const agentId = 'bug6-synth-unstall'
|
|
255
|
+
const h = makeHarness({
|
|
256
|
+
agentId,
|
|
257
|
+
stallThresholdMs: 60_000,
|
|
258
|
+
silentStallTerminalMs: 60_000,
|
|
259
|
+
rescanMs: 500,
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
h.advance(500)
|
|
263
|
+
unmarkHistorical(h, agentId)
|
|
264
|
+
h.advance(62_000) // stall fires
|
|
265
|
+
expect(h.stallCalls).toHaveLength(1)
|
|
266
|
+
|
|
267
|
+
// 30s into the post-stall window — append activity so the watcher
|
|
268
|
+
// sees JSONL growth and fires onUnstall.
|
|
269
|
+
h.advance(30_000)
|
|
270
|
+
h.appendActivity()
|
|
271
|
+
h.advance(1_000) // next poll reads the new bytes, fires onUnstall
|
|
272
|
+
expect(h.unstallCalls).toHaveLength(1)
|
|
273
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
274
|
+
|
|
275
|
+
// Advance another 60s past unstall — would have synthesised if the
|
|
276
|
+
// clock hadn't reset. It should not.
|
|
277
|
+
h.advance(60_000)
|
|
278
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
279
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('does NOT synthesise when an explicit sub_agent_turn_end lands inside the window', () => {
|
|
283
|
+
const agentId = 'bug6-synth-explicit-end'
|
|
284
|
+
const h = makeHarness({
|
|
285
|
+
agentId,
|
|
286
|
+
stallThresholdMs: 60_000,
|
|
287
|
+
silentStallTerminalMs: 300_000,
|
|
288
|
+
rescanMs: 500,
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
h.advance(500)
|
|
292
|
+
unmarkHistorical(h, agentId)
|
|
293
|
+
h.advance(62_000) // stall fires
|
|
294
|
+
expect(h.stallCalls).toHaveLength(1)
|
|
295
|
+
|
|
296
|
+
// 100s into the post-stall window the worker writes its terminal
|
|
297
|
+
// JSONL line — the watcher's existing turn_end path flips state to
|
|
298
|
+
// 'done' and fires onFinish with outcome='completed'. The synth
|
|
299
|
+
// path then sees state !== 'running' and skips.
|
|
300
|
+
h.advance(100_000)
|
|
301
|
+
const cur = h.fileContents.get(h.jsonlPath) ?? Buffer.alloc(0)
|
|
302
|
+
h.fileContents.set(
|
|
303
|
+
h.jsonlPath,
|
|
304
|
+
Buffer.concat([cur, Buffer.from(buildJSONL(subAgentTurnEnd()), 'utf-8')]),
|
|
305
|
+
)
|
|
306
|
+
h.advance(1_000)
|
|
307
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
308
|
+
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
309
|
+
|
|
310
|
+
// Cross the synth threshold — synth must NOT fire (the explicit
|
|
311
|
+
// path already terminated the entry).
|
|
312
|
+
h.advance(300_000)
|
|
313
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
314
|
+
expect(h.finishCalls).toHaveLength(1) // unchanged
|
|
315
|
+
})
|
|
316
|
+
})
|
|
@@ -879,4 +879,267 @@ describe('startSubagentWatcher', () => {
|
|
|
879
879
|
h.watcher.stop()
|
|
880
880
|
})
|
|
881
881
|
})
|
|
882
|
+
|
|
883
|
+
// ─── Issue #1116 regressions ─────────────────────────────────────────────
|
|
884
|
+
|
|
885
|
+
describe('issue #1116 — project-dir slug filter (Bug A)', () => {
|
|
886
|
+
/**
|
|
887
|
+
* Claude Code keys its `.claude/projects/<slug>/` dirs off the cwd it
|
|
888
|
+
* was launched in. Over time an agent's home can accumulate stale
|
|
889
|
+
* project dirs from prior boots (or from a sibling agent that briefly
|
|
890
|
+
* shared the home via a wayward `CLAUDE_PROJECT_DIR`). Pre-#1116 the
|
|
891
|
+
* watcher enumerated every project dir under `<agentDir>/.claude/projects/`
|
|
892
|
+
* and registered any `agent-*.jsonl` it found, which produced phantom
|
|
893
|
+
* registry entries whose backing files vanish on the next session-cleanup
|
|
894
|
+
* tick (ENOENT spam + false stall notifications, per the issue's
|
|
895
|
+
* smoking-gun log).
|
|
896
|
+
*
|
|
897
|
+
* The fix: when `agentCwd` is supplied, the watcher restricts
|
|
898
|
+
* enumeration to the project dir whose slug matches
|
|
899
|
+
* `sanitizeCwdToProjectName(agentCwd)`. Foreign-slug shadow dirs are
|
|
900
|
+
* skipped entirely.
|
|
901
|
+
*/
|
|
902
|
+
|
|
903
|
+
let tmpRoot = ''
|
|
904
|
+
const startedWatchers: Array<{ stop(): void }> = []
|
|
905
|
+
|
|
906
|
+
beforeEach(() => {
|
|
907
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-1116-slug-'))
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
afterEach(() => {
|
|
911
|
+
while (startedWatchers.length) {
|
|
912
|
+
try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
|
|
913
|
+
}
|
|
914
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
function startWatcherSync(opts: { agentDir: string; agentCwd?: string }): {
|
|
918
|
+
notifications: string[]
|
|
919
|
+
logs: string[]
|
|
920
|
+
poll: () => void
|
|
921
|
+
watcher: ReturnType<typeof startSubagentWatcher>
|
|
922
|
+
} {
|
|
923
|
+
const notifications: string[] = []
|
|
924
|
+
const logs: string[] = []
|
|
925
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
926
|
+
const timeouts: Array<{ fn: () => void; ref: number }> = []
|
|
927
|
+
let nextRef = 1
|
|
928
|
+
const watcher = startSubagentWatcher({
|
|
929
|
+
agentDir: opts.agentDir,
|
|
930
|
+
...(opts.agentCwd !== undefined ? { agentCwd: opts.agentCwd } : {}),
|
|
931
|
+
sendNotification: (text) => notifications.push(text),
|
|
932
|
+
stallThresholdMs: 60_000,
|
|
933
|
+
rescanMs: 500,
|
|
934
|
+
now: () => Date.now(),
|
|
935
|
+
setInterval: (fn) => {
|
|
936
|
+
const ref = nextRef++
|
|
937
|
+
intervals.push({ fn, ref })
|
|
938
|
+
return { ref }
|
|
939
|
+
},
|
|
940
|
+
clearInterval: (handle) => {
|
|
941
|
+
const { ref } = handle as { ref: number }
|
|
942
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
943
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
944
|
+
},
|
|
945
|
+
setTimeout: (fn) => {
|
|
946
|
+
const ref = nextRef++
|
|
947
|
+
timeouts.push({ fn, ref })
|
|
948
|
+
return { ref }
|
|
949
|
+
},
|
|
950
|
+
clearTimeout: (handle) => {
|
|
951
|
+
const { ref } = handle as { ref: number }
|
|
952
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
953
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
954
|
+
},
|
|
955
|
+
log: (msg) => logs.push(msg),
|
|
956
|
+
})
|
|
957
|
+
startedWatchers.push(watcher)
|
|
958
|
+
return {
|
|
959
|
+
notifications,
|
|
960
|
+
logs,
|
|
961
|
+
poll: () => intervals[0]?.fn(),
|
|
962
|
+
watcher,
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
it('skips foreign-slug project dirs when agentCwd is provided', () => {
|
|
967
|
+
// Layout: an agent whose real cwd is /home/test/agent-own. Its
|
|
968
|
+
// home contains BOTH its own project dir (-home-test-agent-own)
|
|
969
|
+
// AND a stale foreign one (-home-test-agent-foreign) left over
|
|
970
|
+
// from a prior boot. Each contains a sub-agent JSONL with a
|
|
971
|
+
// distinct agentId.
|
|
972
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
973
|
+
const agentCwd = '/home/test/agent-own'
|
|
974
|
+
const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
|
|
975
|
+
const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-B', 'subagents')
|
|
976
|
+
mkdirSync(ownSubagents, { recursive: true })
|
|
977
|
+
mkdirSync(foreignSubagents, { recursive: true })
|
|
978
|
+
writeFileSync(
|
|
979
|
+
join(ownSubagents, 'agent-ownworker.jsonl'),
|
|
980
|
+
buildJSONL(subAgentUserMsg('legit work')),
|
|
981
|
+
)
|
|
982
|
+
writeFileSync(
|
|
983
|
+
join(foreignSubagents, 'agent-foreignworker.jsonl'),
|
|
984
|
+
buildJSONL(subAgentUserMsg('stale shadow')),
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
const h = startWatcherSync({ agentDir, agentCwd })
|
|
988
|
+
h.poll()
|
|
989
|
+
|
|
990
|
+
const reg = h.watcher.getRegistry()
|
|
991
|
+
expect(reg.has('ownworker')).toBe(true)
|
|
992
|
+
expect(reg.has('foreignworker')).toBe(false)
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it('does NOT emit "read error" ENOENT for foreign-slug files that vanish mid-scan', () => {
|
|
996
|
+
// Simulates the smoking-gun log line from the issue. Foreign-slug
|
|
997
|
+
// JSONL is deleted from disk before the watcher's first readSubTail.
|
|
998
|
+
// With the slug filter in place the foreign agent is never even
|
|
999
|
+
// registered, so no ENOENT log line is emitted.
|
|
1000
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
1001
|
+
const agentCwd = '/home/test/agent-own'
|
|
1002
|
+
const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
|
|
1003
|
+
const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-B', 'subagents')
|
|
1004
|
+
mkdirSync(ownSubagents, { recursive: true })
|
|
1005
|
+
mkdirSync(foreignSubagents, { recursive: true })
|
|
1006
|
+
writeFileSync(
|
|
1007
|
+
join(ownSubagents, 'agent-ownworker.jsonl'),
|
|
1008
|
+
buildJSONL(subAgentUserMsg('legit work')),
|
|
1009
|
+
)
|
|
1010
|
+
const foreignJsonl = join(foreignSubagents, 'agent-foreignworker.jsonl')
|
|
1011
|
+
writeFileSync(foreignJsonl, buildJSONL(subAgentUserMsg('about to vanish')))
|
|
1012
|
+
|
|
1013
|
+
const h = startWatcherSync({ agentDir, agentCwd })
|
|
1014
|
+
// Reap the foreign file the way Claude Code's session cleanup
|
|
1015
|
+
// would, *before* the first poll runs.
|
|
1016
|
+
rmSync(foreignJsonl)
|
|
1017
|
+
h.poll()
|
|
1018
|
+
|
|
1019
|
+
// Polls should not produce a "read error … ENOENT" log line.
|
|
1020
|
+
const enoentLogs = h.logs.filter((l) => l.includes('read error') && l.includes('ENOENT'))
|
|
1021
|
+
expect(enoentLogs).toHaveLength(0)
|
|
1022
|
+
// And the foreign agent must not be registered.
|
|
1023
|
+
expect(h.watcher.getRegistry().has('foreignworker')).toBe(false)
|
|
1024
|
+
})
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
describe('issue #1116 — terminal cleanup must not re-fire completion (Bug B)', () => {
|
|
1028
|
+
/**
|
|
1029
|
+
* Pre-#1116: after `TERMINAL_CLEANUP_GRACE_MS` (30s) elapsed,
|
|
1030
|
+
* `cleanupTerminalAgent` deleted the agentId from `registry` and
|
|
1031
|
+
* the JSONL path from `knownFiles`. The JSONL itself stayed on disk
|
|
1032
|
+
* (Claude Code keeps the file). The next `rescanSubagentDirs` poll
|
|
1033
|
+
* found the JSONL absent from `knownFiles`, re-added it, called
|
|
1034
|
+
* `registerAgent` with a fresh `completionNotified=false` entry,
|
|
1035
|
+
* read the terminal `turn_duration` line again, and fired a SECOND
|
|
1036
|
+
* `✓ Worker done` notification. This loops every grace-window
|
|
1037
|
+
* (~30s); for the operator it manifests as the same handful of
|
|
1038
|
+
* sub-agents announcing completion every ~6 min indefinitely.
|
|
1039
|
+
*/
|
|
1040
|
+
|
|
1041
|
+
let tmpRoot = ''
|
|
1042
|
+
const startedWatchers: Array<{ stop(): void }> = []
|
|
1043
|
+
|
|
1044
|
+
beforeEach(() => {
|
|
1045
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-1116-rerun-'))
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
afterEach(() => {
|
|
1049
|
+
while (startedWatchers.length) {
|
|
1050
|
+
try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
|
|
1051
|
+
}
|
|
1052
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
it('does NOT re-fire "Worker done" after terminal cleanup grace expires and rescan rediscovers the JSONL', () => {
|
|
1056
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
1057
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'sess-A', 'subagents')
|
|
1058
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
1059
|
+
const jsonlPath = join(subagentsDir, 'agent-loopme.jsonl')
|
|
1060
|
+
|
|
1061
|
+
// Boot with an empty subagents dir, then write a post-boot JSONL so
|
|
1062
|
+
// the agent is registered non-historical (otherwise the historical
|
|
1063
|
+
// shortcut suppresses the completion notification entirely).
|
|
1064
|
+
const notifications: string[] = []
|
|
1065
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
1066
|
+
const timeouts: Array<{ fn: () => void; ref: number }> = []
|
|
1067
|
+
let nextRef = 1
|
|
1068
|
+
const watcher = startSubagentWatcher({
|
|
1069
|
+
agentDir,
|
|
1070
|
+
sendNotification: (text) => notifications.push(text),
|
|
1071
|
+
stallThresholdMs: 60_000,
|
|
1072
|
+
rescanMs: 500,
|
|
1073
|
+
now: () => Date.now(),
|
|
1074
|
+
setInterval: (fn) => {
|
|
1075
|
+
const ref = nextRef++
|
|
1076
|
+
intervals.push({ fn, ref })
|
|
1077
|
+
return { ref }
|
|
1078
|
+
},
|
|
1079
|
+
clearInterval: (handle) => {
|
|
1080
|
+
const { ref } = handle as { ref: number }
|
|
1081
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
1082
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
1083
|
+
},
|
|
1084
|
+
setTimeout: (fn) => {
|
|
1085
|
+
const ref = nextRef++
|
|
1086
|
+
timeouts.push({ fn, ref })
|
|
1087
|
+
return { ref }
|
|
1088
|
+
},
|
|
1089
|
+
clearTimeout: (handle) => {
|
|
1090
|
+
const { ref } = handle as { ref: number }
|
|
1091
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
1092
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
1093
|
+
},
|
|
1094
|
+
log: () => {},
|
|
1095
|
+
})
|
|
1096
|
+
startedWatchers.push(watcher)
|
|
1097
|
+
|
|
1098
|
+
const poll = () => intervals[0]?.fn()
|
|
1099
|
+
const fireScheduledCleanups = () => {
|
|
1100
|
+
let fired = 0
|
|
1101
|
+
while (timeouts.length) {
|
|
1102
|
+
const next = timeouts.shift()!
|
|
1103
|
+
next.fn()
|
|
1104
|
+
fired++
|
|
1105
|
+
}
|
|
1106
|
+
return fired
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Step 1: post-boot, write an in-flight JSONL → poll registers it.
|
|
1110
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
1111
|
+
poll()
|
|
1112
|
+
expect(watcher.getRegistry().has('loopme')).toBe(true)
|
|
1113
|
+
expect(watcher.getRegistry().get('loopme')?.historical).toBe(false)
|
|
1114
|
+
|
|
1115
|
+
// Step 2: append turn_duration → poll → state=done → "Worker done" fires.
|
|
1116
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentTurnDuration()))
|
|
1117
|
+
poll()
|
|
1118
|
+
const firstRoundNotifs = notifications.filter((n) => n.includes('Worker done'))
|
|
1119
|
+
expect(firstRoundNotifs).toHaveLength(1)
|
|
1120
|
+
|
|
1121
|
+
// Step 3: fire the terminal-cleanup grace timer (drains the
|
|
1122
|
+
// scheduled cleanup). Registry entry should be gone.
|
|
1123
|
+
const fired = fireScheduledCleanups()
|
|
1124
|
+
expect(fired).toBeGreaterThan(0)
|
|
1125
|
+
expect(watcher.getRegistry().has('loopme')).toBe(false)
|
|
1126
|
+
|
|
1127
|
+
// Step 4: the JSONL is STILL on disk — Claude Code doesn't delete
|
|
1128
|
+
// it after the sub-agent finishes. The next poll re-scans the dir
|
|
1129
|
+
// and finds the file. Pre-fix: re-registers the agent and fires a
|
|
1130
|
+
// SECOND "Worker done". Post-fix: re-discovery is a no-op for
|
|
1131
|
+
// an agentId we've already announced as terminal.
|
|
1132
|
+
poll()
|
|
1133
|
+
const afterRescanNotifs = notifications.filter((n) => n.includes('Worker done'))
|
|
1134
|
+
expect(
|
|
1135
|
+
afterRescanNotifs.length,
|
|
1136
|
+
`Expected exactly 1 "Worker done" notification, got ${afterRescanNotifs.length}: ${JSON.stringify(afterRescanNotifs)}`,
|
|
1137
|
+
).toBe(1)
|
|
1138
|
+
|
|
1139
|
+
// And another poll for good measure — still exactly one.
|
|
1140
|
+
poll()
|
|
1141
|
+
poll()
|
|
1142
|
+
expect(notifications.filter((n) => n.includes('Worker done'))).toHaveLength(1)
|
|
1143
|
+
})
|
|
1144
|
+
})
|
|
882
1145
|
})
|
|
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest'
|
|
|
2
2
|
import {
|
|
3
3
|
reset,
|
|
4
4
|
noteSignal,
|
|
5
|
+
noteOutbound,
|
|
5
6
|
getLongestGap,
|
|
6
7
|
getLastSignalAt,
|
|
8
|
+
getOutboundMetrics,
|
|
7
9
|
clear,
|
|
8
10
|
__resetAllForTests,
|
|
9
11
|
} from '../turn-signal-tracker.js'
|
|
@@ -105,3 +107,82 @@ describe('turn-signal-tracker', () => {
|
|
|
105
107
|
expect(getLongestGap(k)).toBe(0)
|
|
106
108
|
})
|
|
107
109
|
})
|
|
110
|
+
|
|
111
|
+
describe('turn-signal-tracker — outbound metrics (#1122)', () => {
|
|
112
|
+
it('a turn with zero outbound messages reports ttfoMs=null, count=0, gap=0', () => {
|
|
113
|
+
reset('k', 1000)
|
|
114
|
+
const m = getOutboundMetrics('k')
|
|
115
|
+
expect(m.ttfoMs).toBeNull()
|
|
116
|
+
expect(m.outboundCount).toBe(0)
|
|
117
|
+
expect(m.longestOutboundGapMs).toBe(0)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('first noteOutbound() records TTFO = (now - turnStartedAt)', () => {
|
|
121
|
+
reset('k', 1000)
|
|
122
|
+
noteOutbound('k', 1750)
|
|
123
|
+
const m = getOutboundMetrics('k')
|
|
124
|
+
expect(m.ttfoMs).toBe(750)
|
|
125
|
+
expect(m.outboundCount).toBe(1)
|
|
126
|
+
expect(m.longestOutboundGapMs).toBe(0) // single message — no gap yet
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('multiple outbound messages compute the longest gap between them', () => {
|
|
130
|
+
reset('k', 0)
|
|
131
|
+
noteOutbound('k', 100) // ttfo=100, no gap
|
|
132
|
+
noteOutbound('k', 200) // gap=100
|
|
133
|
+
noteOutbound('k', 1700) // gap=1500 (longest)
|
|
134
|
+
noteOutbound('k', 1800) // gap=100
|
|
135
|
+
const m = getOutboundMetrics('k')
|
|
136
|
+
expect(m.ttfoMs).toBe(100)
|
|
137
|
+
expect(m.outboundCount).toBe(4)
|
|
138
|
+
expect(m.longestOutboundGapMs).toBe(1500)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('noteOutbound() on an unknown key is a no-op', () => {
|
|
142
|
+
noteOutbound('untracked', 1000)
|
|
143
|
+
const m = getOutboundMetrics('untracked')
|
|
144
|
+
expect(m.ttfoMs).toBeNull()
|
|
145
|
+
expect(m.outboundCount).toBe(0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('reset() clears outbound state from prior turn', () => {
|
|
149
|
+
reset('k', 0)
|
|
150
|
+
noteOutbound('k', 100)
|
|
151
|
+
noteOutbound('k', 5000) // big gap
|
|
152
|
+
reset('k', 10000) // new turn
|
|
153
|
+
noteOutbound('k', 10100)
|
|
154
|
+
const m = getOutboundMetrics('k')
|
|
155
|
+
expect(m.ttfoMs).toBe(100)
|
|
156
|
+
expect(m.outboundCount).toBe(1)
|
|
157
|
+
expect(m.longestOutboundGapMs).toBe(0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('clear() wipes outbound state too', () => {
|
|
161
|
+
reset('k', 0)
|
|
162
|
+
noteOutbound('k', 100)
|
|
163
|
+
clear('k')
|
|
164
|
+
const m = getOutboundMetrics('k')
|
|
165
|
+
expect(m.ttfoMs).toBeNull()
|
|
166
|
+
expect(m.outboundCount).toBe(0)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('outbound and signal counters track independently', () => {
|
|
170
|
+
reset('k', 0)
|
|
171
|
+
// Many signals (status reaction churn) but only one outbound
|
|
172
|
+
noteSignal('k', 100)
|
|
173
|
+
noteSignal('k', 500)
|
|
174
|
+
noteSignal('k', 2500)
|
|
175
|
+
noteOutbound('k', 3000) // first outbound, ttfo=3000
|
|
176
|
+
const m = getOutboundMetrics('k')
|
|
177
|
+
expect(m.ttfoMs).toBe(3000)
|
|
178
|
+
expect(m.outboundCount).toBe(1)
|
|
179
|
+
// Signal-gap reflects ALL signals (including reactions)
|
|
180
|
+
expect(getLongestGap('k')).toBeGreaterThanOrEqual(1000)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('TTFO = 0 when first outbound fires at exact turn-start tick', () => {
|
|
184
|
+
reset('k', 5000)
|
|
185
|
+
noteOutbound('k', 5000)
|
|
186
|
+
expect(getOutboundMetrics('k').ttfoMs).toBe(0)
|
|
187
|
+
})
|
|
188
|
+
})
|