@selesai/code 0.5.20 → 0.5.21
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/extensions/handoff-new.test.ts +8 -9
- package/dist/extensions/handoff-new.ts +6 -8
- package/dist/extensions/pi-subagents/CHANGELOG.md +1367 -0
- package/dist/extensions/pi-subagents/README.md +602 -220
- package/dist/extensions/pi-subagents/banner.png +0 -0
- package/dist/extensions/pi-subagents/index.ts +1 -0
- package/dist/extensions/pi-subagents/install.mjs +3 -3
- package/dist/extensions/pi-subagents/package-lock.json +3405 -0
- package/dist/extensions/pi-subagents/package.json +26 -14
- package/dist/extensions/pi-subagents/prompts/gather-context-and-clarify.md +1 -1
- package/dist/extensions/pi-subagents/prompts/parallel-cleanup.md +9 -9
- package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +3 -3
- package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +7 -7
- package/dist/extensions/pi-subagents/prompts/parallel-research.md +6 -6
- package/dist/extensions/pi-subagents/prompts/parallel-review.md +8 -8
- package/dist/extensions/pi-subagents/prompts/review-loop.md +13 -11
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +267 -176
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +89 -16
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +19 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +218 -120
- package/dist/extensions/pi-subagents/src/agents/frontmatter.ts +67 -13
- package/dist/extensions/pi-subagents/src/agents/proactive-skills.ts +2 -2
- package/dist/extensions/pi-subagents/src/agents/skills.ts +25 -12
- package/dist/extensions/pi-subagents/src/api/background-work.ts +197 -0
- package/dist/extensions/pi-subagents/src/api/capability-ceiling.ts +17 -0
- package/dist/extensions/pi-subagents/src/api/delegation.ts +285 -0
- package/dist/extensions/pi-subagents/src/api/preflight.ts +399 -0
- package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +165 -0
- package/dist/extensions/pi-subagents/src/extension/config.ts +7 -1
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +15 -0
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -1
- package/dist/extensions/pi-subagents/src/extension/index.ts +113 -157
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +41 -4
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +45 -15
- package/dist/extensions/pi-subagents/src/extension/steering-notices.ts +35 -0
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +28 -15
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -3
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +225 -29
- package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +11 -0
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +8 -10
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +452 -62
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +60 -9
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +198 -52
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +119 -21
- package/dist/extensions/pi-subagents/src/runs/background/auto-drain.ts +67 -0
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +16 -8
- package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +6 -4
- package/dist/extensions/pi-subagents/src/runs/background/completion-dedupe.ts +2 -11
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +260 -13
- package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +32 -6
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +171 -90
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +149 -88
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +14 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +33 -17
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +9 -1
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +38 -10
- package/dist/extensions/pi-subagents/src/runs/background/steering.ts +237 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +1297 -307
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +610 -0
- package/dist/extensions/pi-subagents/src/runs/background/top-level-async.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/background/wait-config.ts +36 -0
- package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +26 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +230 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +22 -6
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +228 -140
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +452 -138
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +90 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +1001 -416
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +443 -132
- package/dist/extensions/pi-subagents/src/runs/shared/agent-contract.ts +38 -0
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +178 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +121 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +41 -93
- package/dist/extensions/pi-subagents/src/runs/shared/context-mode.ts +44 -0
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +11 -9
- package/dist/extensions/pi-subagents/src/runs/shared/long-running-guard.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +36 -0
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +44 -7
- package/dist/extensions/pi-subagents/src/runs/shared/nested-render.ts +4 -1
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +154 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +18 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +174 -54
- package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +38 -31
- package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +90 -5
- package/dist/extensions/pi-subagents/src/runs/shared/session-lease.ts +299 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +61 -6
- package/dist/extensions/pi-subagents/src/runs/shared/spawn-budget.ts +128 -0
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +112 -7
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +149 -36
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +181 -0
- package/dist/extensions/pi-subagents/src/runs/shared/tool-availability.ts +83 -0
- package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +11 -5
- package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +50 -4
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +63 -14
- package/dist/extensions/pi-subagents/src/shared/accessible-dir.ts +25 -0
- package/dist/extensions/pi-subagents/src/shared/artifacts.ts +37 -7
- package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +18 -43
- package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +52 -0
- package/dist/extensions/pi-subagents/src/shared/env.ts +16 -0
- package/dist/extensions/pi-subagents/src/shared/file-system-retry.ts +47 -0
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +28 -3
- package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +123 -0
- package/dist/extensions/pi-subagents/src/shared/model-info.ts +8 -5
- package/dist/extensions/pi-subagents/src/shared/settings.ts +9 -1
- package/dist/extensions/pi-subagents/src/shared/status-format.ts +7 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +522 -51
- package/dist/extensions/pi-subagents/src/shared/utils.ts +59 -53
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +604 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-json.ts +108 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-request.ts +249 -0
- package/dist/extensions/pi-subagents/src/slash/prompt-template-bridge.ts +353 -345
- package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +2 -2
- package/dist/extensions/pi-subagents/src/slash/selector.ts +147 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +265 -23
- package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +2 -2
- package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +428 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +362 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +472 -0
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +664 -0
- package/dist/extensions/pi-subagents/src/tui/render.ts +115 -31
- package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +220 -0
- package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +205 -0
- package/dist/extensions/pi-subagents/src/watchdog/emission-guard.ts +123 -0
- package/dist/extensions/pi-subagents/src/watchdog/lsp-diagnostics.ts +532 -0
- package/dist/extensions/pi-subagents/src/watchdog/model-selection.ts +167 -0
- package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +117 -0
- package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +433 -0
- package/dist/extensions/pi-subagents/src/watchdog/render.ts +54 -0
- package/dist/extensions/pi-subagents/src/watchdog/review.ts +298 -0
- package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +714 -0
- package/dist/extensions/pi-subagents/src/watchdog/settings.ts +528 -0
- package/dist/extensions/pi-subagents/src/watchdog/tool-actions.ts +155 -0
- package/dist/extensions/pi-subagents/src/watchdog/turn-delta.ts +161 -0
- package/dist/extensions/pi-subagents/src/watchdog/types.ts +188 -0
- package/dist/extensions/pi-subagents/src/watchdog/warning-format.ts +73 -0
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +130 -2
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +439 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +1632 -61
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +54 -1
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +112 -0
- package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +98 -1
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +179 -9
- package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +16 -9
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +6 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +589 -9
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +528 -21
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +227 -3
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +235 -7
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +172 -2
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1502 -18
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +993 -4
- package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +7 -1
- package/dist/extensions/pi-subagents/test/support/helpers.ts +32 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +59 -2
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +32 -6
- package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +25 -24
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +59 -35
- package/dist/extensions/pi-subagents/test/support/session-lease-child.mjs +42 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +703 -69
- package/dist/extensions/pi-subagents/test/unit/accessible-dir.test.ts +82 -0
- package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +15 -15
- package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +66 -66
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +478 -19
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +228 -7
- package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +198 -78
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +15 -1
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +102 -7
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +439 -6
- package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +41 -1
- package/dist/extensions/pi-subagents/test/unit/auto-drain.test.ts +79 -0
- package/dist/extensions/pi-subagents/test/unit/background-work.test.ts +285 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-pi-args.test.ts +84 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +68 -0
- package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +326 -0
- package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +82 -0
- package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +46 -4
- package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +9 -17
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +175 -3
- package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +10 -5
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +126 -6
- package/dist/extensions/pi-subagents/test/unit/default-extensions.test.ts +219 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +913 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +13 -2
- package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +351 -0
- package/dist/extensions/pi-subagents/test/unit/fleet-transcript.test.ts +280 -0
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +575 -0
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +94 -1
- package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/host-peer-runtime-imports.test.ts +157 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +367 -5
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +11 -0
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +25 -3
- package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +341 -10
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +16 -0
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +168 -7
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +85 -7
- package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +175 -0
- package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +243 -16
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +79 -36
- package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +174 -56
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +322 -0
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +204 -0
- package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +20 -12
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +117 -4
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +40 -7
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +48 -1
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +21 -3
- package/dist/extensions/pi-subagents/test/unit/selector.test.ts +57 -0
- package/dist/extensions/pi-subagents/test/unit/session-lease.test.ts +326 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +101 -2
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +111 -1
- package/dist/extensions/pi-subagents/test/unit/spawn-budget.test.ts +124 -0
- package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +6 -4
- package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +7 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +397 -0
- package/dist/extensions/pi-subagents/test/unit/steering-notices.test.ts +58 -0
- package/dist/extensions/pi-subagents/test/unit/steering.test.ts +173 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +25 -4
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +390 -25
- package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +837 -0
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +86 -0
- package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +15 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +47 -1
- package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +56 -10
- package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +387 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-child-status.test.ts +110 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-emission-guard.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-lsp-diagnostics.test.ts +144 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-model-selection.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-render.test.ts +76 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-review.test.ts +327 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +863 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +290 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-tool-actions.test.ts +91 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-turn-delta.test.ts +130 -0
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +32 -4
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +5 -1
- package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +30 -0
- package/dist/extensions/question/batch.ts +39 -0
- package/dist/extensions/question/dialog-adapter.ts +23 -1
- package/dist/extensions/question/index.ts +237 -7
- package/dist/extensions/question/schemas.ts +28 -15
- package/dist/extensions/question/tests/batch.test.ts +26 -0
- package/dist/extensions/question/tests/ui-protocol.test.ts +23 -3
- package/dist/extensions/question/tui-adapter.ts +2 -3
- package/dist/extensions/question/types.ts +37 -0
- package/dist/extensions/question/ui-protocol.ts +3 -3
- package/dist/skills/batch-grill-me/SKILL.md +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
formatSpawnBudget,
|
|
5
|
+
getSpawnBudgetSnapshot,
|
|
6
|
+
grantSpawnBudget,
|
|
7
|
+
preflightSpawnBudget,
|
|
8
|
+
reserveSpawnBudget,
|
|
9
|
+
} from "../../src/runs/shared/spawn-budget.ts";
|
|
10
|
+
import type { ExtensionConfig, SubagentState } from "../../src/shared/types.ts";
|
|
11
|
+
|
|
12
|
+
function makeState(sessionId = "session-a"): SubagentState {
|
|
13
|
+
return {
|
|
14
|
+
baseCwd: "",
|
|
15
|
+
currentSessionId: sessionId,
|
|
16
|
+
asyncJobs: new Map(),
|
|
17
|
+
foregroundControls: new Map(),
|
|
18
|
+
lastForegroundControlId: null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const capped: ExtensionConfig = { maxSubagentSpawnsPerSession: 4 };
|
|
23
|
+
|
|
24
|
+
describe("spawn budget", () => {
|
|
25
|
+
it("keeps unset and zero caps unlimited without cumulative accounting", () => {
|
|
26
|
+
for (const config of [{}, { maxSubagentSpawnsPerSession: 0 }]) {
|
|
27
|
+
const state = makeState();
|
|
28
|
+
const reserved = reserveSpawnBudget(state, config, "session-a", 50);
|
|
29
|
+
assert.equal(reserved.error, undefined);
|
|
30
|
+
assert.deepEqual(reserved.snapshot, {
|
|
31
|
+
used: 0,
|
|
32
|
+
configuredLimit: null,
|
|
33
|
+
granted: 0,
|
|
34
|
+
limit: null,
|
|
35
|
+
remaining: null,
|
|
36
|
+
grantRemaining: null,
|
|
37
|
+
grantHistory: [],
|
|
38
|
+
});
|
|
39
|
+
assert.equal(formatSpawnBudget(reserved.snapshot), "Spawn budget: unlimited");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("preflights declared capacity without reserving partial work", () => {
|
|
44
|
+
const state = makeState();
|
|
45
|
+
state.subagentSpawns = { sessionId: "session-a", count: 2 };
|
|
46
|
+
|
|
47
|
+
const checked = preflightSpawnBudget(state, capped, "session-a", 3);
|
|
48
|
+
assert.match(checked.error ?? "", /2\/4 used, 3 requested\).*2 remaining/);
|
|
49
|
+
assert.equal(state.subagentSpawns.count, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reserves cumulative declared capacity", () => {
|
|
53
|
+
const state = makeState();
|
|
54
|
+
const first = reserveSpawnBudget(state, capped, "session-a", 3);
|
|
55
|
+
assert.equal(first.error, undefined);
|
|
56
|
+
assert.equal(first.snapshot.used, 3);
|
|
57
|
+
assert.equal(first.snapshot.remaining, 1);
|
|
58
|
+
|
|
59
|
+
const rejected = reserveSpawnBudget(state, capped, "session-a", 2);
|
|
60
|
+
assert.match(rejected.error ?? "", /3\/4 used, 2 requested\).*1 remaining/);
|
|
61
|
+
assert.equal(state.subagentSpawns?.count, 3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("grants at most the original configured limit and keeps bounded audit records", () => {
|
|
65
|
+
const state = makeState();
|
|
66
|
+
state.subagentSpawns = { sessionId: "session-a", count: 4 };
|
|
67
|
+
|
|
68
|
+
const first = grantSpawnBudget(state, capped, "session-a", 3, 100);
|
|
69
|
+
assert.equal(first.error, undefined);
|
|
70
|
+
assert.equal(first.snapshot.limit, 7);
|
|
71
|
+
assert.equal(first.snapshot.remaining, 3);
|
|
72
|
+
assert.equal(first.snapshot.grantRemaining, 1);
|
|
73
|
+
assert.deepEqual(first.snapshot.grantHistory, [
|
|
74
|
+
{ sessionId: "session-a", amount: 3, grantedAt: 100, previousLimit: 4, limit: 7 },
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const rejected = grantSpawnBudget(state, capped, "session-a", 2, 200);
|
|
78
|
+
assert.match(rejected.error ?? "", /2 requested but only 1/);
|
|
79
|
+
assert.equal(rejected.snapshot.limit, 7);
|
|
80
|
+
|
|
81
|
+
const final = grantSpawnBudget(state, capped, "session-a", 1, 300);
|
|
82
|
+
assert.equal(final.error, undefined);
|
|
83
|
+
assert.equal(final.snapshot.limit, 8);
|
|
84
|
+
assert.equal(final.snapshot.granted, 4);
|
|
85
|
+
assert.equal(final.snapshot.grantRemaining, 0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("bounds grant history while preserving aggregate granted capacity", () => {
|
|
89
|
+
const state = makeState();
|
|
90
|
+
const config = { maxSubagentSpawnsPerSession: 100 };
|
|
91
|
+
for (let index = 0; index < 25; index += 1) {
|
|
92
|
+
const granted = grantSpawnBudget(state, config, "session-a", 1, index);
|
|
93
|
+
assert.equal(granted.error, undefined);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const snapshot = getSpawnBudgetSnapshot(state, config, "session-a");
|
|
97
|
+
assert.equal(snapshot.granted, 25);
|
|
98
|
+
assert.equal(snapshot.limit, 125);
|
|
99
|
+
assert.equal(snapshot.grantHistory.length, 20);
|
|
100
|
+
assert.equal(snapshot.grantHistory[0]?.grantedAt, 5);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rejects meaningless or invalid grants", () => {
|
|
104
|
+
const state = makeState();
|
|
105
|
+
assert.match(grantSpawnBudget(state, {}, "session-a", 1).error ?? "", /no configured spawn cap/);
|
|
106
|
+
assert.match(grantSpawnBudget(state, capped, "session-a", 0).error ?? "", /positive integer/);
|
|
107
|
+
assert.match(grantSpawnBudget(state, capped, "session-a", 1.5).error ?? "", /positive integer/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("resets usage and grants only when the logical session changes", () => {
|
|
111
|
+
const state = makeState();
|
|
112
|
+
reserveSpawnBudget(state, capped, "session-a", 2);
|
|
113
|
+
grantSpawnBudget(state, capped, "session-a", 2, 100);
|
|
114
|
+
|
|
115
|
+
const sameSession = getSpawnBudgetSnapshot(state, capped, "session-a");
|
|
116
|
+
assert.equal(sameSession.used, 2);
|
|
117
|
+
assert.equal(sameSession.granted, 2);
|
|
118
|
+
|
|
119
|
+
const newSession = getSpawnBudgetSnapshot(state, capped, "session-b");
|
|
120
|
+
assert.equal(newSession.used, 0);
|
|
121
|
+
assert.equal(newSession.granted, 0);
|
|
122
|
+
assert.equal(newSession.limit, 4);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -153,8 +153,8 @@ describe("async stale-run reconciliation", () => {
|
|
|
153
153
|
startedAt: 1000,
|
|
154
154
|
lastUpdate: 1000,
|
|
155
155
|
steps: [
|
|
156
|
-
{ agent: "scout", status: "running", startedAt: 1000 },
|
|
157
|
-
{ agent: "worker", status: "running", startedAt: 1100 },
|
|
156
|
+
{ agent: "scout", status: "running", startedAt: 1000, model: "planned-scout", attemptedModels: ["planned-scout"] },
|
|
157
|
+
{ agent: "worker", status: "running", startedAt: 1100, model: "planned-worker", attemptedModels: ["planned-worker"] },
|
|
158
158
|
],
|
|
159
159
|
});
|
|
160
160
|
const scoutSession = path.join(root, "scout.jsonl");
|
|
@@ -164,8 +164,8 @@ describe("async stale-run reconciliation", () => {
|
|
|
164
164
|
success: false,
|
|
165
165
|
state: "failed",
|
|
166
166
|
results: [
|
|
167
|
-
{ agent: "scout", success: true, sessionFile: scoutSession, model: "fast" },
|
|
168
|
-
{ agent: "worker", success: false, error: "boom", sessionFile: workerSession, model: "careful" },
|
|
167
|
+
{ agent: "scout", success: true, sessionFile: scoutSession, model: "fast", attemptedModels: ["planned-scout", "fast"] },
|
|
168
|
+
{ agent: "worker", success: false, error: "boom", sessionFile: workerSession, model: "careful", attemptedModels: ["planned-worker", "careful"] },
|
|
169
169
|
],
|
|
170
170
|
}, null, 2), "utf-8");
|
|
171
171
|
|
|
@@ -180,11 +180,13 @@ describe("async stale-run reconciliation", () => {
|
|
|
180
180
|
assert.equal(result.status?.steps?.[0]?.status, "complete");
|
|
181
181
|
assert.equal(result.status?.steps?.[0]?.exitCode, 0);
|
|
182
182
|
assert.equal(result.status?.steps?.[0]?.model, "fast");
|
|
183
|
+
assert.deepEqual(result.status?.steps?.[0]?.attemptedModels, ["planned-scout", "fast"]);
|
|
183
184
|
assert.equal(result.status?.steps?.[0]?.sessionFile, scoutSession);
|
|
184
185
|
assert.equal(result.status?.steps?.[1]?.status, "failed");
|
|
185
186
|
assert.equal(result.status?.steps?.[1]?.exitCode, 1);
|
|
186
187
|
assert.equal(result.status?.steps?.[1]?.error, "boom");
|
|
187
188
|
assert.equal(result.status?.steps?.[1]?.model, "careful");
|
|
189
|
+
assert.deepEqual(result.status?.steps?.[1]?.attemptedModels, ["planned-worker", "careful"]);
|
|
188
190
|
assert.equal(result.status?.steps?.[1]?.sessionFile, workerSession);
|
|
189
191
|
} finally {
|
|
190
192
|
fs.rmSync(root, { recursive: true, force: true });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { formatModelThinking } from "../../src/shared/formatters.ts";
|
|
3
4
|
import { aggregateStepStatus, formatActivityLabel, formatParallelOutcome } from "../../src/shared/status-format.ts";
|
|
4
5
|
import type { AsyncJobStep } from "../../src/shared/types.ts";
|
|
5
6
|
|
|
@@ -8,6 +9,12 @@ describe("status format helpers", () => {
|
|
|
8
9
|
assert.equal(formatActivityLabel(1_000, undefined, 1_500), "active now");
|
|
9
10
|
assert.equal(formatActivityLabel(1_000, "needs_attention", 4_000), "no activity for 3s");
|
|
10
11
|
assert.equal(formatActivityLabel(undefined, "active_long_running", 4_000), "active but long-running");
|
|
12
|
+
assert.equal(formatActivityLabel(4_000, "active_long_running", 4_000), "active but long-running · last activity now");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("formats max thinking from model suffixes and explicit metadata", () => {
|
|
16
|
+
assert.equal(formatModelThinking("openai/gpt-5:max"), "gpt-5 · thinking max");
|
|
17
|
+
assert.equal(formatModelThinking("openai/gpt-5", "max"), "gpt-5 · thinking max");
|
|
11
18
|
});
|
|
12
19
|
|
|
13
20
|
it("aggregates step status and parallel outcomes", () => {
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { writeAtomicJson, writePrivateAtomicJson } from "../../src/shared/atomic-json.ts";
|
|
7
|
+
import { closeSteerInbox, interruptRequestPath, steerRequestsDir, writeSteerAck, type SteerRequest } from "../../src/runs/background/control-channel.ts";
|
|
8
|
+
import { steerAsyncRun } from "../../src/runs/foreground/async-steering-action.ts";
|
|
9
|
+
import { createSteeringStatus, recordSteeringRequest, updateSteeringTarget } from "../../src/runs/background/steering.ts";
|
|
10
|
+
import { ASYNC_DIR, type AsyncStatus, type Details, type SteeringRecoveryDescriptor, type SteeringTargetState, type SubagentState } from "../../src/shared/types.ts";
|
|
11
|
+
|
|
12
|
+
function createState(): SubagentState {
|
|
13
|
+
return {
|
|
14
|
+
baseCwd: "",
|
|
15
|
+
currentSessionId: "session",
|
|
16
|
+
asyncJobs: new Map(),
|
|
17
|
+
foregroundRuns: new Map(),
|
|
18
|
+
foregroundControls: new Map(),
|
|
19
|
+
lastForegroundControlId: null,
|
|
20
|
+
pendingForegroundControlNotices: new Map(),
|
|
21
|
+
cleanupTimers: new Map(),
|
|
22
|
+
lastUiContext: null,
|
|
23
|
+
poller: null,
|
|
24
|
+
completionSeen: new Map(),
|
|
25
|
+
watcher: null,
|
|
26
|
+
watcherRestartTimer: null,
|
|
27
|
+
resultFileCoalescer: { schedule: () => false, clear: () => {} },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeStatus(asyncDir: string, status: AsyncStatus): void {
|
|
32
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
33
|
+
writeAtomicJson(path.join(asyncDir, "status.json"), status);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runningStatus(runId: string, mode: AsyncStatus["mode"] = "single", count = 1): AsyncStatus {
|
|
37
|
+
return {
|
|
38
|
+
runId,
|
|
39
|
+
sessionId: "session",
|
|
40
|
+
mode,
|
|
41
|
+
state: "running",
|
|
42
|
+
pid: 12345,
|
|
43
|
+
cwd: os.tmpdir(),
|
|
44
|
+
startedAt: Date.now(),
|
|
45
|
+
lastUpdate: Date.now(),
|
|
46
|
+
steps: Array.from({ length: count }, (_, index) => ({ agent: `worker-${index}`, status: "running" as const, startedAt: Date.now() })),
|
|
47
|
+
steering: createSteeringStatus(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function projectRequest(status: AsyncStatus, request: SteerRequest, states: SteeringTargetState[]): void {
|
|
52
|
+
const targets = states.map((state, index) => ({ index, state }));
|
|
53
|
+
status.steering ??= createSteeringStatus();
|
|
54
|
+
recordSteeringRequest(status.steering, { id: request.id, requestedAt: request.ts, source: request.source, message: request.message, targets });
|
|
55
|
+
for (const target of targets) {
|
|
56
|
+
const step = status.steps?.[target.index];
|
|
57
|
+
if (!step) continue;
|
|
58
|
+
step.steering = createSteeringStatus();
|
|
59
|
+
recordSteeringRequest(step.steering, { id: request.id, requestedAt: request.ts, source: request.source, message: request.message, targets: [target] });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitUntil<T>(read: () => T | undefined, timeoutMs = 1_000): Promise<T> {
|
|
64
|
+
const deadline = Date.now() + timeoutMs;
|
|
65
|
+
while (true) {
|
|
66
|
+
const value = read();
|
|
67
|
+
if (value !== undefined) return value;
|
|
68
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for steering test condition.");
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readRequest(asyncDir: string): Promise<SteerRequest> {
|
|
74
|
+
return waitUntil(() => {
|
|
75
|
+
const dir = steerRequestsDir(asyncDir);
|
|
76
|
+
if (!fs.existsSync(dir)) return undefined;
|
|
77
|
+
const file = fs.readdirSync(dir).find((entry) => entry.endsWith(".json"));
|
|
78
|
+
return file ? JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as SteerRequest : undefined;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function recoveryDescriptor(runId: string): SteeringRecoveryDescriptor {
|
|
83
|
+
return {
|
|
84
|
+
version: 1,
|
|
85
|
+
sourceRunId: runId,
|
|
86
|
+
agent: "worker-0",
|
|
87
|
+
cwd: os.tmpdir(),
|
|
88
|
+
systemPromptMode: "replace",
|
|
89
|
+
inheritProjectContext: false,
|
|
90
|
+
inheritSkills: false,
|
|
91
|
+
outputMode: "inline",
|
|
92
|
+
absoluteDeadlineAt: Date.now() + 10_000,
|
|
93
|
+
initialTurnBudget: { maxTurns: 10, graceTurns: 2 },
|
|
94
|
+
initialToolBudget: { soft: 8, hard: 12, block: ["read"] },
|
|
95
|
+
maxSubagentDepth: 2,
|
|
96
|
+
share: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function successResult(asyncId: string): { content: [{ type: "text"; text: string }]; details: Details } {
|
|
101
|
+
return { content: [{ type: "text", text: "started" }], details: { mode: "single", results: [], asyncId } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("acknowledged steering action", () => {
|
|
105
|
+
it("returns delivered only after the runner records child-session acceptance", async () => {
|
|
106
|
+
const runId = `steer-delivered-${Date.now().toString(36)}`;
|
|
107
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
108
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
109
|
+
try {
|
|
110
|
+
const action = steerAsyncRun({ state: createState(), runId, message: "correct course", location: { asyncDir }, ackTimeoutMs: 500, kill: () => true });
|
|
111
|
+
const request = await readRequest(asyncDir);
|
|
112
|
+
assert.deepEqual(request.targetIndexes, [0]);
|
|
113
|
+
const status = runningStatus(runId);
|
|
114
|
+
projectRequest(status, request, ["routed"]);
|
|
115
|
+
updateSteeringTarget(status.steering!, request.id, 0, "delivered", Date.now());
|
|
116
|
+
updateSteeringTarget(status.steps![0]!.steering!, request.id, 0, "delivered", Date.now());
|
|
117
|
+
writeStatus(asyncDir, status);
|
|
118
|
+
const result = await action;
|
|
119
|
+
assert.equal(result.isError, undefined);
|
|
120
|
+
assert.equal(result.details.steering?.state, "delivered");
|
|
121
|
+
assert.match(result.content[0]!.text, /Steering delivered/);
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns a tool error when the runner has closed its steering inbox", async () => {
|
|
128
|
+
const runId = `steer-closed-${Date.now().toString(36)}`;
|
|
129
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
130
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
131
|
+
closeSteerInbox(asyncDir, "complete");
|
|
132
|
+
try {
|
|
133
|
+
const result = await steerAsyncRun({ state: createState(), runId, message: "too late", location: { asyncDir }, kill: () => true });
|
|
134
|
+
assert.equal(result.isError, true);
|
|
135
|
+
assert.match(result.content[0]!.text, /no longer accepts steering requests/);
|
|
136
|
+
} finally {
|
|
137
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not commit recovery when the caller aborts the acknowledgment wait", async () => {
|
|
142
|
+
const runId = `steer-abort-${Date.now().toString(36)}`;
|
|
143
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
144
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
controller.abort();
|
|
147
|
+
let interrupted = false;
|
|
148
|
+
let recovered = false;
|
|
149
|
+
try {
|
|
150
|
+
const result = await steerAsyncRun({
|
|
151
|
+
state: createState(), runId, message: "correct course", location: { asyncDir }, signal: controller.signal,
|
|
152
|
+
kill: (_pid, signal) => { if (signal !== 0) interrupted = true; return true; },
|
|
153
|
+
recover: async () => { recovered = true; return successResult("replacement"); },
|
|
154
|
+
});
|
|
155
|
+
assert.equal(result.details.steering?.state, "pending");
|
|
156
|
+
assert.equal(interrupted, false);
|
|
157
|
+
assert.equal(recovered, false);
|
|
158
|
+
assert.equal(fs.existsSync(path.join(asyncDir, "control", "steer-recovery")), false);
|
|
159
|
+
} finally {
|
|
160
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("honors an acknowledgment persisted before recovery commit without interrupting", async () => {
|
|
165
|
+
const runId = `steer-final-ack-${Date.now().toString(36)}`;
|
|
166
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
167
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
168
|
+
let request: SteerRequest | undefined;
|
|
169
|
+
let interrupted = false;
|
|
170
|
+
let recovered = false;
|
|
171
|
+
try {
|
|
172
|
+
const result = await steerAsyncRun({
|
|
173
|
+
state: createState(), runId, message: "correct course", location: { asyncDir }, ackTimeoutMs: 25,
|
|
174
|
+
kill: (_pid, signal) => { if (signal !== 0) interrupted = true; return true; },
|
|
175
|
+
onRequestQueued: (requestPath) => {
|
|
176
|
+
request = JSON.parse(fs.readFileSync(requestPath, "utf-8")) as SteerRequest;
|
|
177
|
+
const routed = runningStatus(runId);
|
|
178
|
+
projectRequest(routed, request, ["routed"]);
|
|
179
|
+
writeStatus(asyncDir, routed);
|
|
180
|
+
},
|
|
181
|
+
onBeforeRecoveryClaim: (_requestId, committedAt) => {
|
|
182
|
+
assert.ok(request);
|
|
183
|
+
const recoveryDir = path.join(asyncDir, "control", "steer-recovery");
|
|
184
|
+
assert.equal(fs.existsSync(path.join(recoveryDir, "claim.json")), false);
|
|
185
|
+
assert.equal(fs.existsSync(path.join(recoveryDir, `${Buffer.from(request.id).toString("base64url")}.json`)), false);
|
|
186
|
+
const acknowledged = runningStatus(runId);
|
|
187
|
+
projectRequest(acknowledged, request, ["routed"]);
|
|
188
|
+
updateSteeringTarget(acknowledged.steering!, request.id, 0, "delivered", committedAt);
|
|
189
|
+
updateSteeringTarget(acknowledged.steps![0]!.steering!, request.id, 0, "delivered", committedAt);
|
|
190
|
+
writeStatus(asyncDir, acknowledged);
|
|
191
|
+
},
|
|
192
|
+
recover: async () => { recovered = true; return successResult("replacement"); },
|
|
193
|
+
});
|
|
194
|
+
assert.equal(result.details.steering?.state, "delivered");
|
|
195
|
+
assert.equal(interrupted, false);
|
|
196
|
+
assert.equal(recovered, false);
|
|
197
|
+
assert.ok(request);
|
|
198
|
+
assert.equal(fs.existsSync(path.join(asyncDir, "control", "steer-recovery", `${Buffer.from(request.id).toString("base64url")}.json`)), false);
|
|
199
|
+
} finally {
|
|
200
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("releases the recovery claim when interrupt delivery definitively fails", async () => {
|
|
205
|
+
const runId = `steer-interrupt-failed-${Date.now().toString(36)}`;
|
|
206
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
207
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
208
|
+
try {
|
|
209
|
+
const action = steerAsyncRun({
|
|
210
|
+
state: createState(),
|
|
211
|
+
runId,
|
|
212
|
+
message: "correct course",
|
|
213
|
+
location: { asyncDir },
|
|
214
|
+
ackTimeoutMs: 25,
|
|
215
|
+
kill: (_pid, signal) => {
|
|
216
|
+
if (signal === 0) return true;
|
|
217
|
+
const error = new Error("runner disappeared") as NodeJS.ErrnoException;
|
|
218
|
+
error.code = "ESRCH";
|
|
219
|
+
throw error;
|
|
220
|
+
},
|
|
221
|
+
recover: async () => successResult("replacement"),
|
|
222
|
+
});
|
|
223
|
+
const request = await readRequest(asyncDir);
|
|
224
|
+
const routed = runningStatus(runId);
|
|
225
|
+
projectRequest(routed, request, ["routed"]);
|
|
226
|
+
writeStatus(asyncDir, routed);
|
|
227
|
+
const result = await action;
|
|
228
|
+
assert.equal(result.isError, true);
|
|
229
|
+
assert.match(result.content[0]!.text, /Failed to commit steering recovery interrupt/);
|
|
230
|
+
const recoveryDir = path.join(asyncDir, "control", "steer-recovery");
|
|
231
|
+
assert.equal(fs.existsSync(path.join(recoveryDir, "claim.json")), false);
|
|
232
|
+
assert.equal(fs.existsSync(path.join(recoveryDir, `${Buffer.from(request.id).toString("base64url")}.json`)), false);
|
|
233
|
+
} finally {
|
|
234
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("keeps the claim after an unconfirmed pause to prevent delayed duplicate recovery", async () => {
|
|
239
|
+
const runId = `steer-pause-unconfirmed-${Date.now().toString(36)}`;
|
|
240
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
241
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
242
|
+
try {
|
|
243
|
+
const action = steerAsyncRun({
|
|
244
|
+
state: createState(), runId, message: "correct course", location: { asyncDir },
|
|
245
|
+
ackTimeoutMs: 25, recoveryTimeoutMs: 50, kill: () => true,
|
|
246
|
+
recover: async () => successResult("replacement"),
|
|
247
|
+
});
|
|
248
|
+
const request = await readRequest(asyncDir);
|
|
249
|
+
const routed = runningStatus(runId);
|
|
250
|
+
projectRequest(routed, request, ["routed"]);
|
|
251
|
+
writeStatus(asyncDir, routed);
|
|
252
|
+
const result = await action;
|
|
253
|
+
assert.equal(result.isError, true);
|
|
254
|
+
assert.match(result.content[0]!.text, /claim remains committed to prevent a delayed duplicate/);
|
|
255
|
+
assert.equal(fs.existsSync(path.join(asyncDir, "control", "steer-recovery", "claim.json")), true);
|
|
256
|
+
} finally {
|
|
257
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("pauses and revives a single run with only its remaining budgets", async () => {
|
|
262
|
+
const runId = `steer-recover-${Date.now().toString(36)}`;
|
|
263
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
264
|
+
const sessionFile = path.join(asyncDir, "child.jsonl");
|
|
265
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
266
|
+
fs.writeFileSync(sessionFile, "", "utf-8");
|
|
267
|
+
const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
|
|
268
|
+
writePrivateAtomicJson(descriptorPath, recoveryDescriptor(runId));
|
|
269
|
+
if (process.platform !== "win32") assert.equal(fs.statSync(descriptorPath).mode & 0o777, 0o600);
|
|
270
|
+
let receivedLimits: unknown;
|
|
271
|
+
let recoveryStarted = false;
|
|
272
|
+
try {
|
|
273
|
+
const action = steerAsyncRun({
|
|
274
|
+
state: createState(),
|
|
275
|
+
runId,
|
|
276
|
+
message: "correct course",
|
|
277
|
+
location: { asyncDir },
|
|
278
|
+
ackTimeoutMs: 25,
|
|
279
|
+
recoveryTimeoutMs: 500,
|
|
280
|
+
kill: () => true,
|
|
281
|
+
recover: async (limits) => { recoveryStarted = true; receivedLimits = limits; return successResult("replacement"); },
|
|
282
|
+
});
|
|
283
|
+
const request = await readRequest(asyncDir);
|
|
284
|
+
const routed = runningStatus(runId);
|
|
285
|
+
projectRequest(routed, request, ["routed"]);
|
|
286
|
+
writeStatus(asyncDir, routed);
|
|
287
|
+
await waitUntil(() => fs.existsSync(interruptRequestPath(asyncDir)) ? true : undefined);
|
|
288
|
+
writeSteerAck(asyncDir, { requestId: request.id, index: 0, ts: Date.now(), state: "delivered", message: "accepted after runner pause" });
|
|
289
|
+
const paused: AsyncStatus = {
|
|
290
|
+
...routed,
|
|
291
|
+
state: "paused",
|
|
292
|
+
turnBudget: { maxTurns: 10, graceTurns: 2, turnCount: 7, outcome: "within-budget" },
|
|
293
|
+
toolBudget: { soft: 8, hard: 12, block: ["read"], toolCount: 9, outcome: "soft-reached" },
|
|
294
|
+
steps: [{ ...routed.steps![0]!, status: "paused", sessionFile }],
|
|
295
|
+
};
|
|
296
|
+
writeStatus(asyncDir, paused);
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
298
|
+
assert.equal(recoveryStarted, false, "recovery must wait for final paused persistence");
|
|
299
|
+
writeStatus(asyncDir, { ...paused, endedAt: Date.now() });
|
|
300
|
+
const result = await action;
|
|
301
|
+
assert.equal(result.isError, undefined);
|
|
302
|
+
assert.equal(result.details.steering?.state, "recovered");
|
|
303
|
+
assert.equal(result.details.steering?.replacementRunId, "replacement");
|
|
304
|
+
assert.ok(result.details.steering?.targets[0]?.lateDeliveredAt);
|
|
305
|
+
const limits = receivedLimits as { timeoutMs: number; absoluteDeadlineAt: number; turnBudget: unknown; toolBudget: unknown };
|
|
306
|
+
assert.ok(limits.timeoutMs > 0 && limits.timeoutMs <= 10_000);
|
|
307
|
+
assert.ok(limits.absoluteDeadlineAt >= Date.now());
|
|
308
|
+
assert.deepEqual({ turnBudget: limits.turnBudget, toolBudget: limits.toolBudget }, {
|
|
309
|
+
turnBudget: { maxTurns: 3, graceTurns: 2 },
|
|
310
|
+
toolBudget: { hard: 3, block: ["read"] },
|
|
311
|
+
});
|
|
312
|
+
const persisted = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatus;
|
|
313
|
+
assert.equal(persisted.steering?.recent[0]?.targets[0]?.state, "recovered");
|
|
314
|
+
assert.ok(persisted.steering?.recent[0]?.targets[0]?.lateDeliveredAt);
|
|
315
|
+
assert.equal(persisted.steps?.[0]?.steering?.recent[0]?.targets[0]?.state, "recovered");
|
|
316
|
+
} finally {
|
|
317
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("leaves an unacknowledged single run paused when no session can be revived", async () => {
|
|
322
|
+
const runId = `steer-no-session-${Date.now().toString(36)}`;
|
|
323
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
324
|
+
writeStatus(asyncDir, runningStatus(runId));
|
|
325
|
+
writePrivateAtomicJson(path.join(asyncDir, "recovery-descriptor.json"), recoveryDescriptor(runId));
|
|
326
|
+
let recovered = false;
|
|
327
|
+
try {
|
|
328
|
+
const action = steerAsyncRun({
|
|
329
|
+
state: createState(), runId, message: "correct course", location: { asyncDir }, ackTimeoutMs: 25, recoveryTimeoutMs: 500, kill: () => true,
|
|
330
|
+
recover: async () => { recovered = true; return successResult("replacement"); },
|
|
331
|
+
});
|
|
332
|
+
const request = await readRequest(asyncDir);
|
|
333
|
+
const routed = runningStatus(runId);
|
|
334
|
+
projectRequest(routed, request, ["routed"]);
|
|
335
|
+
writeStatus(asyncDir, routed);
|
|
336
|
+
await waitUntil(() => fs.existsSync(interruptRequestPath(asyncDir)) ? true : undefined);
|
|
337
|
+
writeStatus(asyncDir, { ...routed, state: "paused", endedAt: Date.now(), steps: [{ ...routed.steps![0]!, status: "paused" }] });
|
|
338
|
+
const result = await action;
|
|
339
|
+
assert.equal(result.isError, true);
|
|
340
|
+
assert.equal(recovered, false);
|
|
341
|
+
assert.match(result.content[0]!.text, /no persisted child session|does not have a persisted session file/i);
|
|
342
|
+
} finally {
|
|
343
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("never auto-interrupts a nested single run", async () => {
|
|
348
|
+
const runId = `steer-nested-${Date.now().toString(36)}`;
|
|
349
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
350
|
+
const initial = runningStatus(runId);
|
|
351
|
+
initial.isNested = true;
|
|
352
|
+
writeStatus(asyncDir, initial);
|
|
353
|
+
let interrupted = false;
|
|
354
|
+
let recovered = false;
|
|
355
|
+
try {
|
|
356
|
+
const action = steerAsyncRun({
|
|
357
|
+
state: createState(), runId, message: "correct course", location: { asyncDir }, ackTimeoutMs: 25,
|
|
358
|
+
kill: (_pid, signal) => { if (signal !== 0) interrupted = true; return true; },
|
|
359
|
+
recover: async () => { recovered = true; return successResult("replacement"); },
|
|
360
|
+
});
|
|
361
|
+
const request = await readRequest(asyncDir);
|
|
362
|
+
const routed = runningStatus(runId);
|
|
363
|
+
routed.isNested = true;
|
|
364
|
+
projectRequest(routed, request, ["routed"]);
|
|
365
|
+
writeStatus(asyncDir, routed);
|
|
366
|
+
const result = await action;
|
|
367
|
+
assert.equal(result.details.steering?.state, "pending");
|
|
368
|
+
assert.equal(interrupted, false);
|
|
369
|
+
assert.equal(recovered, false);
|
|
370
|
+
assert.equal(fs.existsSync(interruptRequestPath(asyncDir)), false);
|
|
371
|
+
} finally {
|
|
372
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("reports partial multi-child delivery without interrupting the run", async () => {
|
|
377
|
+
const runId = `steer-partial-${Date.now().toString(36)}`;
|
|
378
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
379
|
+
writeStatus(asyncDir, runningStatus(runId, "parallel", 2));
|
|
380
|
+
let killed = false;
|
|
381
|
+
try {
|
|
382
|
+
const action = steerAsyncRun({ state: createState(), runId, message: "correct course", location: { asyncDir }, ackTimeoutMs: 500, kill: (_pid, signal) => { if (signal !== 0) killed = true; return true; } });
|
|
383
|
+
const request = await readRequest(asyncDir);
|
|
384
|
+
assert.deepEqual(request.targetIndexes, [0, 1]);
|
|
385
|
+
const status = runningStatus(runId, "parallel", 2);
|
|
386
|
+
projectRequest(status, request, ["delivered", "failed"]);
|
|
387
|
+
writeStatus(asyncDir, status);
|
|
388
|
+
const result = await action;
|
|
389
|
+
assert.equal(result.isError, true);
|
|
390
|
+
assert.equal(result.details.steering?.state, "partial");
|
|
391
|
+
assert.equal(killed, false);
|
|
392
|
+
assert.equal(fs.existsSync(interruptRequestPath(asyncDir)), false);
|
|
393
|
+
} finally {
|
|
394
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { handleSubagentSteeringNotice } from "../../src/extension/steering-notices.ts";
|
|
4
|
+
import type { SubagentState } from "../../src/shared/types.ts";
|
|
5
|
+
|
|
6
|
+
function state(sessionId: string): SubagentState {
|
|
7
|
+
return {
|
|
8
|
+
baseCwd: "",
|
|
9
|
+
currentSessionId: sessionId,
|
|
10
|
+
asyncJobs: new Map(),
|
|
11
|
+
foregroundRuns: new Map(),
|
|
12
|
+
foregroundControls: new Map(),
|
|
13
|
+
lastForegroundControlId: null,
|
|
14
|
+
pendingForegroundControlNotices: new Map(),
|
|
15
|
+
cleanupTimers: new Map(),
|
|
16
|
+
lastUiContext: null,
|
|
17
|
+
poller: null,
|
|
18
|
+
completionSeen: new Map(),
|
|
19
|
+
watcher: null,
|
|
20
|
+
watcherRestartTimer: null,
|
|
21
|
+
resultFileCoalescer: { schedule: () => false, clear: () => {} },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("steering notices", () => {
|
|
26
|
+
it("delivers a failure notice to the matching parent session", () => {
|
|
27
|
+
const messages: unknown[] = [];
|
|
28
|
+
handleSubagentSteeringNotice({
|
|
29
|
+
pi: { sendMessage: (message: unknown) => messages.push(message) } as any,
|
|
30
|
+
state: state("session-1"),
|
|
31
|
+
details: { runId: "run", requestId: "request", state: "failed", message: "not delivered", currentSessionId: "session-1" },
|
|
32
|
+
});
|
|
33
|
+
assert.equal(messages.length, 1);
|
|
34
|
+
assert.match(JSON.stringify(messages[0]), /not delivered/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("suppresses notices owned by another or missing parent session", () => {
|
|
38
|
+
const messages: unknown[] = [];
|
|
39
|
+
for (const currentSessionId of ["session-2", undefined]) {
|
|
40
|
+
handleSubagentSteeringNotice({
|
|
41
|
+
pi: { sendMessage: (message: unknown) => messages.push(message) } as any,
|
|
42
|
+
state: state("session-1"),
|
|
43
|
+
details: { runId: "run", requestId: "request", state: "partial", message: "partial", currentSessionId },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
assert.deepEqual(messages, []);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("delivers recovered outcomes without changing their state", () => {
|
|
50
|
+
const messages: Array<{ details?: { state?: string } }> = [];
|
|
51
|
+
handleSubagentSteeringNotice({
|
|
52
|
+
pi: { sendMessage: (message: { details?: { state?: string } }) => messages.push(message) } as any,
|
|
53
|
+
state: state("session-1"),
|
|
54
|
+
details: { runId: "run", requestId: "request", state: "recovered", message: "replacement launched", currentSessionId: "session-1" },
|
|
55
|
+
});
|
|
56
|
+
assert.equal(messages[0]?.details?.state, "recovered");
|
|
57
|
+
});
|
|
58
|
+
});
|