@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
|
@@ -15,12 +15,21 @@ import * as os from "node:os";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { createEventBus, createMockPi, createTempDir, events, makeAgent, makeMinimalCtx, removeTempDir, tryImport } from "../support/helpers.ts";
|
|
17
17
|
import type { MockPi } from "../support/helpers.ts";
|
|
18
|
-
import { deliverInterruptRequest } from "../../src/runs/background/control-channel.ts";
|
|
18
|
+
import { deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest } from "../../src/runs/background/control-channel.ts";
|
|
19
|
+
import { waitForSubagents } from "../../src/runs/background/subagent-wait.ts";
|
|
20
|
+
import { writeAtomicJson } from "../../src/shared/atomic-json.ts";
|
|
21
|
+
import { CHILD_WATCHDOG_STATUS_EVENT } from "../../src/watchdog/child-status.ts";
|
|
22
|
+
import { MAX_CHILD_PENDING_LINE_BYTES, MAX_CHILD_STDERR_BYTES } from "../../src/runs/shared/child-protocol.ts";
|
|
23
|
+
import { SUBAGENT_ASYNC_STARTED_EVENT, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION } from "../../src/shared/types.ts";
|
|
24
|
+
import { registerSubagentCapabilityCeiling } from "../../src/api/capability-ceiling.ts";
|
|
25
|
+
import { resolveSubagentLaunchContract } from "../../src/api/preflight.ts";
|
|
26
|
+
import { discoverAgents } from "../../src/agents/agents.ts";
|
|
27
|
+
import { runSync } from "../../src/runs/foreground/execution.ts";
|
|
19
28
|
|
|
20
29
|
interface AsyncExecutionResult {
|
|
21
30
|
content: Array<{ text?: string }>;
|
|
22
31
|
isError?: boolean;
|
|
23
|
-
details: { asyncId?: string };
|
|
32
|
+
details: { asyncId?: string; asyncDir?: string; launchContractDigest?: string };
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
interface AsyncResultPayload {
|
|
@@ -30,38 +39,49 @@ interface AsyncResultPayload {
|
|
|
30
39
|
exitCode?: number;
|
|
31
40
|
sessionId?: string;
|
|
32
41
|
mode?: string;
|
|
42
|
+
launchContractDigest?: string;
|
|
33
43
|
summary?: string;
|
|
34
44
|
error?: string;
|
|
35
45
|
timeoutMs?: number;
|
|
36
46
|
deadlineAt?: number;
|
|
37
47
|
timedOut?: boolean;
|
|
38
|
-
|
|
48
|
+
stopped?: boolean;
|
|
49
|
+
turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number };
|
|
39
50
|
turnBudgetExceeded?: boolean;
|
|
40
51
|
wrapUpRequested?: boolean;
|
|
41
52
|
totalTokens?: { input: number; output: number; total: number };
|
|
42
53
|
totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
|
|
43
|
-
results: Array<{ output?: string; success?: boolean; error?: string; timedOut?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; model?: string; attemptedModels?: string[]; modelAttempts?: Array<{ success?: boolean; error?: string }>; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; structuredOutput?: unknown; intercomTarget?: string; acceptance?: { status?: string; childReport?: unknown } }>;
|
|
54
|
+
results: Array<{ agent?: string; launchContractDigest?: string; output?: string; success?: boolean; error?: string; protocolError?: { code?: string; stream?: string; limitBytes?: number; observedBytes?: number }; timedOut?: boolean; stopped?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; model?: string; attemptedModels?: string[]; modelAttempts?: Array<{ success?: boolean; error?: string }>; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; structuredOutput?: unknown; agentContract?: { version: 1 }; execution?: { status?: string; success?: boolean; exitCode?: number }; effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean } }; intercomTarget?: string; acceptance?: { status?: string; effectiveAcceptance?: { level?: string }; childReport?: unknown; runtimeChecks?: Array<{ id?: string; status?: string; message?: string }> }; artifactPaths?: { outputPath?: string; inputPath?: string; metadataPath?: string }; capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] }; capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean } }>;
|
|
44
55
|
outputs?: Record<string, { text?: string; structured?: unknown }>;
|
|
45
|
-
workflowGraph?: { nodes?: Array<{ kind?: string; label?: string; phase?: string; status?: string; error?: string; outputName?: string; structured?: boolean; children?: Array<{ label?: string; outputName?: string; itemKey?: string; status?: string; error?: string }> }> };
|
|
56
|
+
workflowGraph?: { nodes?: Array<{ kind?: string; label?: string; phase?: string; status?: string; acceptanceStatus?: string; error?: string; outputName?: string; structured?: boolean; children?: Array<{ label?: string; outputName?: string; itemKey?: string; status?: string; acceptanceStatus?: string; error?: string }> }> };
|
|
57
|
+
parallelHandoff?: { version?: number; path?: string; groupCount?: number; childCount?: number; changedPatches?: number; cleanupState?: string };
|
|
58
|
+
capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] };
|
|
59
|
+
capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean };
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
interface AsyncStatusPayload {
|
|
49
63
|
lifecycleArtifactVersion?: number;
|
|
50
64
|
sessionId?: string;
|
|
65
|
+
pid?: number;
|
|
51
66
|
activityState?: string;
|
|
52
67
|
currentTool?: string;
|
|
53
68
|
currentPath?: string;
|
|
54
69
|
state?: string;
|
|
70
|
+
launchContractDigest?: string;
|
|
55
71
|
error?: string;
|
|
56
72
|
timeoutMs?: number;
|
|
57
73
|
deadlineAt?: number;
|
|
58
74
|
timedOut?: boolean;
|
|
59
|
-
|
|
75
|
+
stopped?: boolean;
|
|
76
|
+
turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number };
|
|
60
77
|
turnBudgetExceeded?: boolean;
|
|
61
78
|
wrapUpRequested?: boolean;
|
|
62
79
|
totalTokens?: { total: number };
|
|
63
80
|
totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
|
|
64
81
|
parallelGroups?: Array<{ start: number; count: number; stepIndex: number }>;
|
|
82
|
+
parallelHandoff?: { version?: number; path?: string; groupCount?: number; childCount?: number; changedPatches?: number; cleanupState?: string };
|
|
83
|
+
capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] };
|
|
84
|
+
capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean };
|
|
65
85
|
steps?: Array<{
|
|
66
86
|
label?: string;
|
|
67
87
|
phase?: string;
|
|
@@ -78,10 +98,16 @@ interface AsyncStatusPayload {
|
|
|
78
98
|
thinking?: string;
|
|
79
99
|
tokens?: { total: number };
|
|
80
100
|
totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
|
|
101
|
+
agentContract?: { version: 1 };
|
|
102
|
+
launchContractDigest?: string;
|
|
103
|
+
execution?: { status?: string; success?: boolean; exitCode?: number };
|
|
104
|
+
effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean } };
|
|
81
105
|
acceptance?: { status?: string };
|
|
82
|
-
turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
|
|
106
|
+
turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number };
|
|
83
107
|
turnBudgetExceeded?: boolean;
|
|
84
108
|
wrapUpRequested?: boolean;
|
|
109
|
+
capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] };
|
|
110
|
+
capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean };
|
|
85
111
|
}>;
|
|
86
112
|
}
|
|
87
113
|
|
|
@@ -90,6 +116,56 @@ interface MockPiCallRecord {
|
|
|
90
116
|
systemPrompts?: Array<{ mode?: string; path?: string; text?: string; error?: string }>;
|
|
91
117
|
}
|
|
92
118
|
|
|
119
|
+
function writeWatchdogSettings(projectDir: string, tailMs = 120_000): void {
|
|
120
|
+
const settingsPath = path.join(projectDir, ".selesai", "settings.json");
|
|
121
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
122
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
123
|
+
subagents: {
|
|
124
|
+
watchdog: {
|
|
125
|
+
enabled: true,
|
|
126
|
+
children: {
|
|
127
|
+
enabled: true,
|
|
128
|
+
watchdogTailTimeoutMs: tailMs,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}, null, 2), "utf-8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function withIsolatedWatchdogSettings<T>(projectDir: string, run: () => Promise<T>): Promise<T> {
|
|
136
|
+
const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
137
|
+
const previousHome = process.env.HOME;
|
|
138
|
+
const previousUserProfile = process.env.USERPROFILE;
|
|
139
|
+
const isolatedHome = path.join(projectDir, "isolated-home");
|
|
140
|
+
process.env.SELESAI_CODING_AGENT_DIR = path.join(isolatedHome, ".selesai", "agent");
|
|
141
|
+
process.env.HOME = isolatedHome;
|
|
142
|
+
process.env.USERPROFILE = isolatedHome;
|
|
143
|
+
try {
|
|
144
|
+
return await run();
|
|
145
|
+
} finally {
|
|
146
|
+
if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
147
|
+
else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
|
|
148
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
149
|
+
else process.env.HOME = previousHome;
|
|
150
|
+
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
151
|
+
else process.env.USERPROFILE = previousUserProfile;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function childWatchdogStatus(runId: string, phase: "idle" | "reviewing" | "autofollow" | "settling" | "stale" | "failed", seq: number, followUpPending = false) {
|
|
156
|
+
return {
|
|
157
|
+
type: CHILD_WATCHDOG_STATUS_EVENT,
|
|
158
|
+
runId,
|
|
159
|
+
agent: "worker",
|
|
160
|
+
childIndex: 0,
|
|
161
|
+
stepIndex: 0,
|
|
162
|
+
seq,
|
|
163
|
+
phase,
|
|
164
|
+
ts: Date.now() + seq,
|
|
165
|
+
followUpPending,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
93
169
|
function mockAssistantMessage(text: string, stopReason: "stop" | "tool_use" = "stop") {
|
|
94
170
|
return {
|
|
95
171
|
type: "message_end",
|
|
@@ -186,16 +262,54 @@ function writePackageSkill(packageRoot: string, skillName: string): void {
|
|
|
186
262
|
);
|
|
187
263
|
}
|
|
188
264
|
|
|
265
|
+
function readIfExists(filePath: string): string | undefined {
|
|
266
|
+
try {
|
|
267
|
+
const text = fs.readFileSync(filePath, "utf-8").trim();
|
|
268
|
+
return text || undefined;
|
|
269
|
+
} catch {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
189
274
|
async function waitForAsyncResultFile(id: string, timeoutMs = 15_000): Promise<string> {
|
|
190
275
|
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
191
276
|
const deadline = Date.now() + timeoutMs;
|
|
192
277
|
while (!fs.existsSync(resultPath)) {
|
|
193
|
-
if (Date.now() > deadline)
|
|
278
|
+
if (Date.now() > deadline) {
|
|
279
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
280
|
+
const status = readIfExists(path.join(asyncDir, "status.json"));
|
|
281
|
+
const stdout = readIfExists(path.join(asyncDir, "runner.stdout.log"));
|
|
282
|
+
const stderr = readIfExists(path.join(asyncDir, "runner.stderr.log"));
|
|
283
|
+
assert.fail([
|
|
284
|
+
`Timed out waiting for async result file: ${resultPath}`,
|
|
285
|
+
status ? `status.json: ${status}` : undefined,
|
|
286
|
+
stdout ? `runner stdout: ${stdout}` : undefined,
|
|
287
|
+
stderr ? `runner stderr: ${stderr}` : undefined,
|
|
288
|
+
].filter(Boolean).join("\n"));
|
|
289
|
+
}
|
|
194
290
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
195
291
|
}
|
|
196
292
|
return resultPath;
|
|
197
293
|
}
|
|
198
294
|
|
|
295
|
+
async function waitForDeferredTurnBudget(id: string, timeoutMs = 10_000): Promise<AsyncStatusPayload> {
|
|
296
|
+
const statusPath = path.join(ASYNC_DIR, id, "status.json");
|
|
297
|
+
const deadline = Date.now() + timeoutMs;
|
|
298
|
+
while (Date.now() <= deadline) {
|
|
299
|
+
if (fs.existsSync(statusPath)) {
|
|
300
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
301
|
+
if (status.state === "running"
|
|
302
|
+
&& status.turnBudget?.outcome === "termination-deferred"
|
|
303
|
+
&& status.steps?.[0]?.turnBudget?.outcome === "termination-deferred"
|
|
304
|
+
&& status.steps[0].currentTool === "bash") {
|
|
305
|
+
return status;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
309
|
+
}
|
|
310
|
+
assert.fail(`Timed out waiting for deferred turn-budget status: ${statusPath}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
199
313
|
async function waitForMockPiCall(mockPi: MockPi, index: number, timeoutMs = 30_000): Promise<{ args: string[]; systemPrompts: NonNullable<MockPiCallRecord["systemPrompts"]> }> {
|
|
200
314
|
const deadline = Date.now() + timeoutMs;
|
|
201
315
|
for (;;) {
|
|
@@ -272,11 +386,289 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
272
386
|
afterEach(() => {
|
|
273
387
|
removeTempDir(tempDir);
|
|
274
388
|
});
|
|
389
|
+
|
|
390
|
+
function makeAsyncExecutor(agents: ReturnType<typeof makeAgent>[], config: Record<string, unknown> = {}) {
|
|
391
|
+
return createSubagentExecutor!({
|
|
392
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
393
|
+
state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
|
|
394
|
+
config,
|
|
395
|
+
asyncByDefault: false,
|
|
396
|
+
tempArtifactsDir: tempDir,
|
|
397
|
+
getSubagentSessionRoot: () => tempDir,
|
|
398
|
+
expandTilde: (p: string) => p,
|
|
399
|
+
discoverAgents: () => ({ agents }),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function readAsyncPayload(id: string): Promise<AsyncResultPayload> {
|
|
404
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
405
|
+
return JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function launchProtocolTest(id: string): void {
|
|
409
|
+
executeAsyncSingle(id, {
|
|
410
|
+
agent: "worker",
|
|
411
|
+
task: "Exercise child protocol",
|
|
412
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
413
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
414
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
415
|
+
shareEnabled: false,
|
|
416
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
417
|
+
maxSubagentDepth: 2,
|
|
418
|
+
acceptance: false,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
275
422
|
it("reports jiti availability as boolean", () => {
|
|
276
423
|
const result = isAsyncAvailable();
|
|
277
424
|
assert.equal(typeof result, "boolean");
|
|
278
425
|
});
|
|
279
426
|
|
|
427
|
+
it("background parses split UTF-8 JSON and a final unterminated protocol line", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
428
|
+
const line = Buffer.from(JSON.stringify(events.assistantMessage("你好 from fragmented async JSON")));
|
|
429
|
+
const unicodeStart = line.indexOf(Buffer.from("你"));
|
|
430
|
+
mockPi.onCall({ stdoutBase64Chunks: [line.subarray(0, unicodeStart + 1).toString("base64"), line.subarray(unicodeStart + 1).toString("base64")] });
|
|
431
|
+
const id = `async-protocol-utf8-${Date.now().toString(36)}`;
|
|
432
|
+
launchProtocolTest(id);
|
|
433
|
+
const payload = await readAsyncPayload(id);
|
|
434
|
+
assert.equal(payload.success, true);
|
|
435
|
+
assert.equal(payload.results[0]?.output, "你好 from fragmented async JSON");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("matches preflight launch digest in equivalent foreground and async execution", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
439
|
+
const agentName = `contract-worker-${Date.now().toString(36)}`;
|
|
440
|
+
const task = "Compare the resolved launch inputs.";
|
|
441
|
+
const turnBudget = { maxTurns: 2, graceTurns: 1 } as const;
|
|
442
|
+
const agentPath = path.join(tempDir, ".selesai", "agents", `${agentName}.md`);
|
|
443
|
+
fs.mkdirSync(path.dirname(agentPath), { recursive: true });
|
|
444
|
+
fs.writeFileSync(agentPath, `---\nname: ${agentName}\ndescription: Contract comparison worker\n---\n`, "utf-8");
|
|
445
|
+
const discovered = discoverAgents(tempDir).agents.find((agent) => agent.name === agentName);
|
|
446
|
+
assert.ok(discovered, "expected temporary agent definition to be discovered");
|
|
447
|
+
const preflight = await resolveSubagentLaunchContract({ agent: agentName, cwd: tempDir, task, turnBudget, runId: "contract-preflight" });
|
|
448
|
+
assert.equal(preflight.ok, true);
|
|
449
|
+
|
|
450
|
+
mockPi.onCall({ output: "foreground contract comparison" });
|
|
451
|
+
const foreground = await runSync(tempDir, [discovered], agentName, task, { runId: "contract-foreground", acceptance: false, turnBudget });
|
|
452
|
+
assert.equal(foreground.exitCode, 0);
|
|
453
|
+
assert.equal(foreground.launchContractDigest, preflight.contract.launchContractDigest);
|
|
454
|
+
|
|
455
|
+
mockPi.onCall({ output: "async contract comparison" });
|
|
456
|
+
const asyncId = `async-contract-equivalence-${Date.now().toString(36)}`;
|
|
457
|
+
const launch = executeAsyncSingle(asyncId, {
|
|
458
|
+
agent: agentName,
|
|
459
|
+
task,
|
|
460
|
+
agentConfig: discovered,
|
|
461
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
462
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
463
|
+
shareEnabled: false,
|
|
464
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
465
|
+
maxSubagentDepth: 2,
|
|
466
|
+
acceptance: false,
|
|
467
|
+
turnBudget,
|
|
468
|
+
});
|
|
469
|
+
const payload = await readAsyncPayload(asyncId);
|
|
470
|
+
assert.equal(launch.details.launchContractDigest, preflight.contract.launchContractDigest);
|
|
471
|
+
assert.equal(payload.launchContractDigest, preflight.contract.launchContractDigest);
|
|
472
|
+
assert.equal(payload.results[0]?.launchContractDigest, preflight.contract.launchContractDigest);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("persists the actual launch digest in async status and result metadata", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
476
|
+
mockPi.onCall({ output: "digest-bound async done" });
|
|
477
|
+
const id = `async-launch-digest-${Date.now().toString(36)}`;
|
|
478
|
+
const launch = executeAsyncSingle(id, {
|
|
479
|
+
agent: "worker",
|
|
480
|
+
task: "Exercise launch digest reporting",
|
|
481
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
482
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
483
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
484
|
+
shareEnabled: false,
|
|
485
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
486
|
+
maxSubagentDepth: 2,
|
|
487
|
+
acceptance: false,
|
|
488
|
+
});
|
|
489
|
+
assert.match(launch.details.launchContractDigest ?? "", /^[a-f0-9]{64}$/);
|
|
490
|
+
const payload = await readAsyncPayload(id);
|
|
491
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
492
|
+
assert.equal(payload.launchContractDigest, launch.details.launchContractDigest);
|
|
493
|
+
assert.equal(payload.results[0]?.launchContractDigest, launch.details.launchContractDigest);
|
|
494
|
+
assert.equal(status.launchContractDigest, launch.details.launchContractDigest);
|
|
495
|
+
assert.equal(status.steps?.[0]?.launchContractDigest, launch.details.launchContractDigest);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("background fails with protocol_output_limit for an oversized stdout line", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
499
|
+
mockPi.onCall({ stdoutRaw: "x".repeat(MAX_CHILD_PENDING_LINE_BYTES + 1) });
|
|
500
|
+
const id = `async-protocol-limit-${Date.now().toString(36)}`;
|
|
501
|
+
launchProtocolTest(id);
|
|
502
|
+
const payload = await readAsyncPayload(id);
|
|
503
|
+
assert.equal(payload.success, false);
|
|
504
|
+
assert.equal(payload.results[0]?.protocolError?.code, "protocol_output_limit");
|
|
505
|
+
assert.equal(payload.results[0]?.protocolError?.stream, "stdout");
|
|
506
|
+
assert.match(payload.results[0]?.error ?? "", /protocol_output_limit/);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("routes async artifacts to the configured session directory", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
510
|
+
mockPi.onCall({ output: "async session artifact" });
|
|
511
|
+
const sessionFile = path.join(tempDir, "sessions", "parent-session", "session.jsonl");
|
|
512
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
513
|
+
ctx.sessionManager.getSessionFile = () => sessionFile;
|
|
514
|
+
const executor = makeAsyncExecutor([makeAgent("worker", { completionGuard: false })], { artifactDir: "session" });
|
|
515
|
+
|
|
516
|
+
const launch = await executor.execute(
|
|
517
|
+
"async-session-artifact-dir",
|
|
518
|
+
{ agent: "worker", task: "Write async session artifacts", async: true, runId: "async-session-artifacts", acceptance: false },
|
|
519
|
+
new AbortController().signal,
|
|
520
|
+
undefined,
|
|
521
|
+
ctx,
|
|
522
|
+
) as AsyncExecutionResult;
|
|
523
|
+
|
|
524
|
+
const expectedDir = path.join(path.dirname(sessionFile), "subagent-artifacts");
|
|
525
|
+
assert.equal(launch.isError, undefined);
|
|
526
|
+
assert.ok(launch.details.asyncId);
|
|
527
|
+
const descriptor = JSON.parse(fs.readFileSync(path.join(launch.details.asyncDir!, "recovery-descriptor.json"), "utf-8"));
|
|
528
|
+
assert.equal(descriptor.artifactsDir, expectedDir);
|
|
529
|
+
assert.equal(descriptor.artifactConfig.dir, "session");
|
|
530
|
+
|
|
531
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
532
|
+
const outputPath = payload.results[0]?.artifactPaths?.outputPath;
|
|
533
|
+
assert.ok(outputPath?.startsWith(`${expectedDir}${path.sep}`));
|
|
534
|
+
assert.equal(fs.readFileSync(outputPath, "utf-8"), "async session artifact");
|
|
535
|
+
assert.equal(fs.existsSync(path.join(tempDir, ".pi-subagents", "artifacts")), false);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("persists async capability ceiling audit to status, results, events, and metadata", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
539
|
+
mockPi.onCall({ output: "restricted async done" });
|
|
540
|
+
const sessionId = `session-capability-${Date.now().toString(36)}`;
|
|
541
|
+
const handle = registerSubagentCapabilityCeiling({ sessionId, ceiling: { allowedTools: ["read"], denyExtensions: true }, source: "test" });
|
|
542
|
+
try {
|
|
543
|
+
const executor = makeAsyncExecutor([makeAgent("worker", { tools: ["read", "write"], completionGuard: false })]);
|
|
544
|
+
const id = `async-capability-${Date.now().toString(36)}`;
|
|
545
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
546
|
+
ctx.sessionManager.getSessionId = () => sessionId;
|
|
547
|
+
const launch = await executor.execute(
|
|
548
|
+
id,
|
|
549
|
+
{ agent: "worker", task: "Run with a restricted capability ceiling", async: true, runId: id, acceptance: false, artifacts: true },
|
|
550
|
+
new AbortController().signal,
|
|
551
|
+
undefined,
|
|
552
|
+
ctx,
|
|
553
|
+
) as AsyncExecutionResult;
|
|
554
|
+
assert.equal(launch.isError, undefined);
|
|
555
|
+
const asyncId = launch.details.asyncId;
|
|
556
|
+
assert.ok(asyncId);
|
|
557
|
+
const resultPath = await waitForAsyncResultFile(asyncId, 10_000);
|
|
558
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
559
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, asyncId, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
560
|
+
assert.deepEqual(payload.capabilityCeiling, { version: 1, allowedTools: ["read"], denyExtensions: true, sources: ["test"] });
|
|
561
|
+
assert.deepEqual(payload.results[0]?.capabilityCeiling, payload.capabilityCeiling);
|
|
562
|
+
assert.deepEqual(status.capabilityCeiling, payload.capabilityCeiling);
|
|
563
|
+
assert.deepEqual(status.steps?.[0]?.capabilityCeiling, payload.capabilityCeiling);
|
|
564
|
+
assert.deepEqual(payload.capabilityAudit?.effectiveTools, ["read"]);
|
|
565
|
+
assert.deepEqual(payload.capabilityAudit?.removedTools, ["write", "intercom", "contact_supervisor"]);
|
|
566
|
+
assert.equal(payload.capabilityAudit?.extensionsDenied, true);
|
|
567
|
+
const events = fs.readFileSync(path.join(ASYNC_DIR, asyncId, "events.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line));
|
|
568
|
+
assert.ok(events.some((event) => event.type === "subagent.capability-ceiling.applied" && event.stepIndex === 0 && event.capabilityAudit?.removedTools?.includes("write")));
|
|
569
|
+
const metadataPath = payload.results[0]?.artifactPaths?.metadataPath;
|
|
570
|
+
assert.ok(metadataPath);
|
|
571
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as { launchContractDigest?: string; capabilityCeiling?: unknown; capabilityAudit?: { removedTools?: string[] } };
|
|
572
|
+
assert.equal(metadata.launchContractDigest, payload.results[0]?.launchContractDigest);
|
|
573
|
+
assert.deepEqual(metadata.capabilityCeiling, payload.capabilityCeiling);
|
|
574
|
+
assert.deepEqual(metadata.capabilityAudit?.removedTools, ["write", "intercom", "contact_supervisor"]);
|
|
575
|
+
} finally {
|
|
576
|
+
handle.dispose();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("background writes a failure stub to output artifacts when no output was produced", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
581
|
+
mockPi.onCall({ output: "", stderr: "model unavailable", exitCode: 1 });
|
|
582
|
+
const id = `async-empty-failure-artifact-${Date.now().toString(36)}`;
|
|
583
|
+
executeAsyncSingle(id, {
|
|
584
|
+
agent: "worker",
|
|
585
|
+
task: "Fail before output",
|
|
586
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
587
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
588
|
+
artifactConfig: { enabled: true, includeInput: true, includeOutput: true, includeJsonl: false, includeMetadata: true, cleanupDays: 7 },
|
|
589
|
+
artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
|
|
590
|
+
shareEnabled: false,
|
|
591
|
+
maxSubagentDepth: 2,
|
|
592
|
+
acceptance: false,
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const payload = await readAsyncPayload(id);
|
|
596
|
+
assert.equal(payload.success, false);
|
|
597
|
+
const outputPath = payload.results[0]?.artifactPaths?.outputPath;
|
|
598
|
+
assert.ok(outputPath, "should expose an output artifact path");
|
|
599
|
+
const artifact = fs.readFileSync(outputPath, "utf-8");
|
|
600
|
+
assert.match(artifact, /Subagent run failed before producing output\./);
|
|
601
|
+
assert.match(artifact, /Error:\nmodel unavailable/);
|
|
602
|
+
assert.match(artifact, /Transcript:/);
|
|
603
|
+
assert.match(artifact, /Metadata:/);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("background keeps only a bounded UTF-8 stderr tail", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
607
|
+
mockPi.onCall({ output: "failed", stderr: `${"x".repeat(MAX_CHILD_STDERR_BYTES + 1024)}终`, exitCode: 1 });
|
|
608
|
+
const id = `async-stderr-tail-${Date.now().toString(36)}`;
|
|
609
|
+
launchProtocolTest(id);
|
|
610
|
+
const payload = await readAsyncPayload(id);
|
|
611
|
+
assert.equal(payload.success, false);
|
|
612
|
+
assert.ok(Buffer.byteLength(payload.results[0]?.error ?? "") <= MAX_CHILD_STDERR_BYTES);
|
|
613
|
+
assert.match(payload.results[0]?.error ?? "", /终$/);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("background cancels final drain while agent_end reports a retry and waits for agent_settled", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
617
|
+
mockPi.onCall({ steps: [
|
|
618
|
+
{ jsonl: [events.assistantMessage("retrying async response"), { type: "agent_end", willRetry: true }] },
|
|
619
|
+
{ delay: 1400, jsonl: [events.assistantMessage("settled async response"), { type: "agent_end", willRetry: false }, { type: "agent_settled" }] },
|
|
620
|
+
] });
|
|
621
|
+
const id = `async-lifecycle-retry-${Date.now().toString(36)}`;
|
|
622
|
+
const startedAt = Date.now();
|
|
623
|
+
launchProtocolTest(id);
|
|
624
|
+
const payload = await readAsyncPayload(id);
|
|
625
|
+
assert.equal(payload.success, true);
|
|
626
|
+
assert.equal(payload.results[0]?.output, "settled async response");
|
|
627
|
+
assert.ok(Date.now() - startedAt >= 1200, "background runner must not terminate during the retry delay");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("background treats agent_settled as a clean terminal watermark", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
631
|
+
mockPi.onCall({ jsonl: [mockAssistantMessage("settled async without a terminal assistant stop", "tool_use"), { type: "agent_settled" }], keepAliveAfterFinalMessageMs: 5000 });
|
|
632
|
+
const id = `async-lifecycle-settled-${Date.now().toString(36)}`;
|
|
633
|
+
const startedAt = Date.now();
|
|
634
|
+
launchProtocolTest(id);
|
|
635
|
+
const payload = await readAsyncPayload(id);
|
|
636
|
+
assert.equal(payload.success, true);
|
|
637
|
+
assert.equal(payload.results[0]?.error, undefined);
|
|
638
|
+
assert.equal(payload.results[0]?.output, "settled async without a terminal assistant stop");
|
|
639
|
+
assert.ok(Date.now() - startedAt < 4000, "agent_settled should trigger bounded child cleanup");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("keeps named output references literal in async single tasks", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
643
|
+
const task = "Reply with OK. You may reference {outputs.name} if it helps.";
|
|
644
|
+
mockPi.onCall({ output: "OK" });
|
|
645
|
+
const id = `async-single-literal-output-ref-${Date.now().toString(36)}`;
|
|
646
|
+
const result = executeAsyncSingle(id, {
|
|
647
|
+
agent: "worker",
|
|
648
|
+
task,
|
|
649
|
+
agentConfig: makeAgent("worker"),
|
|
650
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
651
|
+
artifactConfig: {
|
|
652
|
+
enabled: false,
|
|
653
|
+
includeInput: false,
|
|
654
|
+
includeOutput: false,
|
|
655
|
+
includeJsonl: false,
|
|
656
|
+
includeMetadata: false,
|
|
657
|
+
cleanupDays: 7,
|
|
658
|
+
},
|
|
659
|
+
shareEnabled: false,
|
|
660
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
661
|
+
maxSubagentDepth: 2,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
assert.equal(result.isError, undefined);
|
|
665
|
+
const call = await waitForMockPiCall(mockPi, 0, 10_000);
|
|
666
|
+
assert.match(call.args.at(-1) ?? "", /\{outputs\.name\}/);
|
|
667
|
+
const payload = await readAsyncPayload(id);
|
|
668
|
+
assert.equal(payload.success, true);
|
|
669
|
+
assert.equal(payload.results[0]?.output, "OK");
|
|
670
|
+
});
|
|
671
|
+
|
|
280
672
|
it("spawns the async runner with node when process.execPath is not node", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
281
673
|
const originalExecPath = process.execPath;
|
|
282
674
|
process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi");
|
|
@@ -302,7 +694,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
302
694
|
});
|
|
303
695
|
|
|
304
696
|
assert.equal(result.isError, undefined);
|
|
305
|
-
const resultPath = await waitForAsyncResultFile(id,
|
|
697
|
+
const resultPath = await waitForAsyncResultFile(id, 30_000);
|
|
306
698
|
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
307
699
|
assert.equal(payload.success, true);
|
|
308
700
|
assert.equal(payload.results[0]?.output, "non-node exec async done");
|
|
@@ -469,7 +861,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
469
861
|
it("hard-kills async children that ignore timeout SIGTERM", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
470
862
|
mockPi.onCall({ delay: 60_000, ignoreSigterm: true, output: "too late" });
|
|
471
863
|
const id = `async-timeout-hard-kill-${Date.now().toString(36)}`;
|
|
472
|
-
const timeoutMs = 1_500;
|
|
864
|
+
const timeoutMs = process.platform === "win32" ? 5_000 : 1_500;
|
|
473
865
|
const startedAt = Date.now();
|
|
474
866
|
executeAsyncSingle(id, {
|
|
475
867
|
agent: "stubborn",
|
|
@@ -490,7 +882,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
490
882
|
});
|
|
491
883
|
|
|
492
884
|
await waitForMockPiCall(mockPi, 0, 10_000);
|
|
493
|
-
const resultPath = await waitForAsyncResultFile(id,
|
|
885
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
494
886
|
const elapsedMs = Date.now() - startedAt;
|
|
495
887
|
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
496
888
|
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
@@ -500,13 +892,14 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
500
892
|
assert.equal(payload.results[0]?.error, `Subagent timed out after ${timeoutMs}ms.`);
|
|
501
893
|
assert.equal(status.timedOut, true);
|
|
502
894
|
assert.equal(status.steps?.[0]?.timedOut, true);
|
|
503
|
-
assert.ok(elapsedMs <
|
|
895
|
+
assert.ok(elapsedMs < timeoutMs + 4_000, `timeout result should settle after hard kill, elapsed ${elapsedMs}ms`);
|
|
504
896
|
assert.equal(mockPi.callCount(), 1);
|
|
505
897
|
});
|
|
506
898
|
|
|
507
899
|
it("cancels async acceptance verification when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
508
900
|
mockPi.onCall({ output: "implementation complete" });
|
|
509
901
|
const id = `async-timeout-acceptance-${Date.now().toString(36)}`;
|
|
902
|
+
const timeoutMs = 1_000;
|
|
510
903
|
const startedAt = Date.now();
|
|
511
904
|
executeAsyncSingle(id, {
|
|
512
905
|
agent: "worker",
|
|
@@ -514,19 +907,20 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
514
907
|
agentConfig: makeAgent("worker"),
|
|
515
908
|
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
516
909
|
artifactConfig: {
|
|
517
|
-
enabled:
|
|
910
|
+
enabled: true,
|
|
518
911
|
includeInput: false,
|
|
519
912
|
includeOutput: false,
|
|
520
913
|
includeJsonl: false,
|
|
521
|
-
includeMetadata:
|
|
914
|
+
includeMetadata: true,
|
|
522
915
|
cleanupDays: 7,
|
|
523
916
|
},
|
|
917
|
+
artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
|
|
524
918
|
shareEnabled: false,
|
|
525
919
|
maxSubagentDepth: 2,
|
|
526
|
-
timeoutMs
|
|
920
|
+
timeoutMs,
|
|
527
921
|
acceptance: {
|
|
528
922
|
level: "verified",
|
|
529
|
-
verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0),
|
|
923
|
+
verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 30000)"`, timeoutMs: 60_000 }],
|
|
530
924
|
},
|
|
531
925
|
});
|
|
532
926
|
|
|
@@ -537,9 +931,15 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
537
931
|
assert.equal(payload.state, "failed");
|
|
538
932
|
assert.equal(payload.timedOut, true);
|
|
539
933
|
assert.equal(payload.results[0]?.timedOut, true);
|
|
540
|
-
assert.equal(payload.results[0]?.acceptance,
|
|
934
|
+
assert.equal(payload.results[0]?.acceptance?.status, "rejected");
|
|
935
|
+
assert.equal(payload.results[0]?.acceptance?.runtimeChecks?.[0]?.id, "timeout");
|
|
541
936
|
assert.equal(status.steps?.[0]?.timedOut, true);
|
|
542
|
-
|
|
937
|
+
const metadataPath = payload.results[0]?.artifactPaths?.metadataPath;
|
|
938
|
+
assert.ok(metadataPath);
|
|
939
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as { acceptance?: { status?: string; runtimeChecks?: Array<{ id?: string }> } };
|
|
940
|
+
assert.equal(metadata.acceptance?.status, "rejected");
|
|
941
|
+
assert.equal(metadata.acceptance?.runtimeChecks?.[0]?.id, "timeout");
|
|
942
|
+
assert.ok(elapsedMs < timeoutMs + 4_000, `timeout should cancel acceptance verification well before the verify command completes, elapsed ${elapsedMs}ms`);
|
|
543
943
|
});
|
|
544
944
|
|
|
545
945
|
it("async turn budget allows a terminal final grace turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
@@ -580,11 +980,12 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
580
980
|
assert.equal(status.steps?.[0]?.turnBudget?.turnCount, 2);
|
|
581
981
|
});
|
|
582
982
|
|
|
583
|
-
it("async turn budget hard-aborts
|
|
983
|
+
it("async turn budget hard-aborts beyond the final grace turn at a safe assistant boundary", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
584
984
|
mockPi.onCall({
|
|
585
985
|
jsonl: [
|
|
586
986
|
mockAssistantMessage("working before wrap-up", "tool_use"),
|
|
587
|
-
mockAssistantMessage("
|
|
987
|
+
mockAssistantMessage("starting final grace tool work", "tool_use"),
|
|
988
|
+
mockAssistantMessage("safe assistant boundary after tool work", "stop"),
|
|
588
989
|
],
|
|
589
990
|
});
|
|
590
991
|
const id = `async-turn-budget-hard-${Date.now().toString(36)}`;
|
|
@@ -608,17 +1009,153 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
608
1009
|
assert.equal(payload.turnBudgetExceeded, true);
|
|
609
1010
|
assert.equal(payload.wrapUpRequested, true);
|
|
610
1011
|
assert.equal(payload.turnBudget?.outcome, "exceeded");
|
|
611
|
-
assert.equal(payload.turnBudget?.turnCount,
|
|
612
|
-
assert.equal(payload.turnBudget?.exceededAtTurn,
|
|
1012
|
+
assert.equal(payload.turnBudget?.turnCount, 3);
|
|
1013
|
+
assert.equal(payload.turnBudget?.exceededAtTurn, 3);
|
|
613
1014
|
assert.equal(payload.results[0]?.turnBudgetExceeded, true);
|
|
614
1015
|
assert.match(payload.results[0]?.output ?? "", /Partial output before turn-budget abort:/);
|
|
615
|
-
assert.match(payload.results[0]?.output ?? "", /
|
|
1016
|
+
assert.match(payload.results[0]?.output ?? "", /safe assistant boundary after tool work/);
|
|
616
1017
|
assert.equal(status.state, "failed");
|
|
617
1018
|
assert.equal(status.turnBudgetExceeded, true);
|
|
618
1019
|
assert.equal(status.steps?.[0]?.turnBudgetExceeded, true);
|
|
619
1020
|
assert.equal(status.steps?.[0]?.turnBudget?.outcome, "exceeded");
|
|
620
1021
|
});
|
|
621
1022
|
|
|
1023
|
+
it("defers an async hard turn limit through active tool work and aborts at the next assistant boundary", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1024
|
+
mockPi.onCall({
|
|
1025
|
+
steps: [
|
|
1026
|
+
{
|
|
1027
|
+
jsonl: [
|
|
1028
|
+
mockAssistantMessage("starting required tool work", "tool_use"),
|
|
1029
|
+
events.toolStart("bash", { command: "node build.mjs" }),
|
|
1030
|
+
],
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
delay: 750,
|
|
1034
|
+
jsonl: [
|
|
1035
|
+
events.toolResult("bash", "build completed"),
|
|
1036
|
+
events.toolEnd("bash"),
|
|
1037
|
+
mockAssistantMessage("safe assistant boundary reached", "stop"),
|
|
1038
|
+
],
|
|
1039
|
+
},
|
|
1040
|
+
],
|
|
1041
|
+
});
|
|
1042
|
+
const id = `async-turn-budget-deferred-${Date.now().toString(36)}`;
|
|
1043
|
+
executeAsyncSingle(id, {
|
|
1044
|
+
agent: "worker",
|
|
1045
|
+
task: "Finish active tool work before enforcing the hard limit.",
|
|
1046
|
+
agentConfig: makeAgent("worker"),
|
|
1047
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1048
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1049
|
+
shareEnabled: false,
|
|
1050
|
+
maxSubagentDepth: 2,
|
|
1051
|
+
turnBudget: { maxTurns: 1, graceTurns: 0 },
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const pending = await waitForDeferredTurnBudget(id);
|
|
1055
|
+
assert.equal(pending.error, undefined);
|
|
1056
|
+
assert.equal(pending.turnBudgetExceeded, undefined);
|
|
1057
|
+
assert.equal(pending.turnBudget?.terminationDeferredAtTurn, 1);
|
|
1058
|
+
assert.equal(pending.steps?.[0]?.status, "running");
|
|
1059
|
+
assert.equal(pending.steps?.[0]?.turnBudgetExceeded, undefined);
|
|
1060
|
+
|
|
1061
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1062
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1063
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1064
|
+
assert.equal(payload.state, "failed");
|
|
1065
|
+
assert.equal(payload.turnBudgetExceeded, true);
|
|
1066
|
+
assert.equal(payload.turnBudget?.outcome, "exceeded");
|
|
1067
|
+
assert.equal(payload.turnBudget?.turnCount, 2);
|
|
1068
|
+
assert.equal(payload.results[0]?.turnBudgetExceeded, true);
|
|
1069
|
+
assert.match(payload.results[0]?.output ?? "", /safe assistant boundary reached/);
|
|
1070
|
+
assert.equal(status.state, "failed");
|
|
1071
|
+
assert.equal(status.turnBudgetExceeded, true);
|
|
1072
|
+
assert.equal(status.steps?.[0]?.turnBudget?.outcome, "exceeded");
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("lets timeout delivery preempt deferred turn-budget termination", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1076
|
+
mockPi.onCall({
|
|
1077
|
+
steps: [
|
|
1078
|
+
{
|
|
1079
|
+
jsonl: [
|
|
1080
|
+
mockAssistantMessage("starting long tool work", "tool_use"),
|
|
1081
|
+
events.toolStart("bash", { command: "node build.mjs" }),
|
|
1082
|
+
],
|
|
1083
|
+
},
|
|
1084
|
+
{ delay: 5_000, jsonl: [events.toolEnd("bash")] },
|
|
1085
|
+
],
|
|
1086
|
+
});
|
|
1087
|
+
const id = `async-turn-budget-timeout-${Date.now().toString(36)}`;
|
|
1088
|
+
executeAsyncSingle(id, {
|
|
1089
|
+
agent: "worker",
|
|
1090
|
+
task: "Timeout while hard termination is deferred.",
|
|
1091
|
+
agentConfig: makeAgent("worker"),
|
|
1092
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1093
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1094
|
+
shareEnabled: false,
|
|
1095
|
+
maxSubagentDepth: 2,
|
|
1096
|
+
turnBudget: { maxTurns: 1, graceTurns: 0 },
|
|
1097
|
+
timeoutMs: 30_000,
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const pending = await waitForDeferredTurnBudget(id);
|
|
1101
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
1102
|
+
deliverTimeoutRequest({ asyncDir, pid: pending.pid, source: "test" });
|
|
1103
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1104
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1105
|
+
const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1106
|
+
assert.equal(payload.state, "failed");
|
|
1107
|
+
assert.equal(payload.timedOut, true);
|
|
1108
|
+
assert.equal(payload.turnBudgetExceeded, undefined);
|
|
1109
|
+
assert.match(payload.error ?? payload.summary ?? "", /timed out after 30000ms/);
|
|
1110
|
+
assert.equal(payload.results[0]?.timedOut, true);
|
|
1111
|
+
assert.equal(payload.results[0]?.turnBudgetExceeded, undefined);
|
|
1112
|
+
assert.equal(status.timedOut, true);
|
|
1113
|
+
assert.equal(status.turnBudgetExceeded, undefined);
|
|
1114
|
+
assert.equal(status.steps?.[0]?.timedOut, true);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it("lets explicit stop preempt deferred turn-budget termination", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1118
|
+
mockPi.onCall({
|
|
1119
|
+
steps: [
|
|
1120
|
+
{
|
|
1121
|
+
jsonl: [
|
|
1122
|
+
mockAssistantMessage("starting long tool work", "tool_use"),
|
|
1123
|
+
events.toolStart("bash", { command: "node build.mjs" }),
|
|
1124
|
+
],
|
|
1125
|
+
},
|
|
1126
|
+
{ delay: 5_000, jsonl: [events.toolEnd("bash")] },
|
|
1127
|
+
],
|
|
1128
|
+
});
|
|
1129
|
+
const id = `async-turn-budget-stop-${Date.now().toString(36)}`;
|
|
1130
|
+
executeAsyncSingle(id, {
|
|
1131
|
+
agent: "worker",
|
|
1132
|
+
task: "Stop while hard termination is deferred.",
|
|
1133
|
+
agentConfig: makeAgent("worker"),
|
|
1134
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1135
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1136
|
+
shareEnabled: false,
|
|
1137
|
+
maxSubagentDepth: 2,
|
|
1138
|
+
turnBudget: { maxTurns: 1, graceTurns: 0 },
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const pending = await waitForDeferredTurnBudget(id);
|
|
1142
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
1143
|
+
deliverStopRequest({ asyncDir, pid: pending.pid, source: "test" });
|
|
1144
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1145
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1146
|
+
const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1147
|
+
assert.equal(payload.state, "stopped");
|
|
1148
|
+
assert.equal(payload.stopped, true);
|
|
1149
|
+
assert.equal(payload.turnBudgetExceeded, undefined);
|
|
1150
|
+
assert.match(payload.error ?? payload.summary ?? "", /stopped by user/i);
|
|
1151
|
+
assert.equal(payload.results[0]?.stopped, true);
|
|
1152
|
+
assert.equal(payload.results[0]?.turnBudgetExceeded, undefined);
|
|
1153
|
+
assert.equal(status.state, "stopped");
|
|
1154
|
+
assert.equal(status.stopped, true);
|
|
1155
|
+
assert.equal(status.turnBudgetExceeded, undefined);
|
|
1156
|
+
assert.equal(status.steps?.[0]?.status, "stopped");
|
|
1157
|
+
});
|
|
1158
|
+
|
|
622
1159
|
it("async launch messages tell the parent not to sleep-poll", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
623
1160
|
const artifactConfig = {
|
|
624
1161
|
enabled: false,
|
|
@@ -628,26 +1165,56 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
628
1165
|
includeMetadata: false,
|
|
629
1166
|
cleanupDays: 7,
|
|
630
1167
|
};
|
|
1168
|
+
const emitted: Array<{ channel: string; data: unknown }> = [];
|
|
631
1169
|
const commonParams = {
|
|
632
|
-
ctx: {
|
|
1170
|
+
ctx: {
|
|
1171
|
+
pi: { events: { emit(channel: string, data: unknown) { emitted.push({ channel, data }); } } },
|
|
1172
|
+
cwd: tempDir,
|
|
1173
|
+
currentSessionId: "session-1",
|
|
1174
|
+
},
|
|
633
1175
|
artifactConfig,
|
|
634
1176
|
shareEnabled: false,
|
|
635
1177
|
maxSubagentDepth: 2,
|
|
636
1178
|
};
|
|
1179
|
+
const startedEvent = (id: string): { task?: string; goal?: string } => {
|
|
1180
|
+
const event = emitted.find((entry) => entry.channel === SUBAGENT_ASYNC_STARTED_EVENT && (entry.data as { id?: string }).id === id);
|
|
1181
|
+
assert.ok(event, `missing async-started event for ${id}`);
|
|
1182
|
+
return event.data as { task?: string; goal?: string };
|
|
1183
|
+
};
|
|
637
1184
|
mockPi.onCall({ output: "single done" });
|
|
638
1185
|
const singleId = `async-handoff-single-${Date.now().toString(36)}`;
|
|
1186
|
+
const wrappedTask = `Fork preamble: ${"execution ".repeat(20)}`;
|
|
1187
|
+
const rawGoal = `Caller-facing goal: ${"raw ".repeat(40)}`;
|
|
639
1188
|
const singleResult = executeAsyncSingle(singleId, {
|
|
640
1189
|
agent: "worker",
|
|
641
|
-
task:
|
|
1190
|
+
task: wrappedTask,
|
|
1191
|
+
goal: rawGoal,
|
|
642
1192
|
agentConfig: makeAgent("worker"),
|
|
643
1193
|
...commonParams,
|
|
644
1194
|
});
|
|
645
1195
|
assert.match(singleResult.content[0]?.text ?? "", /Async: worker \[/);
|
|
646
1196
|
assert.match(singleResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
|
|
647
|
-
assert.match(singleResult.content[0]?.text ?? "", /call
|
|
648
|
-
assert.match(singleResult.content[0]?.text ?? "", /
|
|
1197
|
+
assert.match(singleResult.content[0]?.text ?? "", /call subagent_wait\(\)/i);
|
|
1198
|
+
assert.match(singleResult.content[0]?.text ?? "", /non-interactive run: Pi auto-drains current-session background work at agent_end/);
|
|
1199
|
+
assert.equal(startedEvent(singleId).task, wrappedTask.slice(0, 50));
|
|
1200
|
+
assert.equal(startedEvent(singleId).goal, rawGoal.slice(0, 120));
|
|
649
1201
|
await waitForAsyncResultFile(singleId, 30_000);
|
|
650
1202
|
|
|
1203
|
+
mockPi.onCall({ output: "interactive done" });
|
|
1204
|
+
const interactiveId = `async-handoff-interactive-${Date.now().toString(36)}`;
|
|
1205
|
+
const interactiveResult = executeAsyncSingle(interactiveId, {
|
|
1206
|
+
agent: "worker",
|
|
1207
|
+
task: "Interactive handoff",
|
|
1208
|
+
agentConfig: makeAgent("worker"),
|
|
1209
|
+
...commonParams,
|
|
1210
|
+
ctx: { ...commonParams.ctx, interactive: true },
|
|
1211
|
+
});
|
|
1212
|
+
assert.match(interactiveResult.content[0]?.text ?? "", /interactive session/);
|
|
1213
|
+
assert.match(interactiveResult.content[0]?.text ?? "", /return control to the user/);
|
|
1214
|
+
assert.match(interactiveResult.content[0]?.text ?? "", /Do NOT call subagent_wait\(\) merely to wait/);
|
|
1215
|
+
assert.doesNotMatch(interactiveResult.content[0]?.text ?? "", /auto-drain/);
|
|
1216
|
+
await waitForAsyncResultFile(interactiveId, 30_000);
|
|
1217
|
+
|
|
651
1218
|
mockPi.onCall({ output: "parallel one done" });
|
|
652
1219
|
mockPi.onCall({ output: "parallel two done" });
|
|
653
1220
|
const parallelId = `async-handoff-parallel-${Date.now().toString(36)}`;
|
|
@@ -659,7 +1226,8 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
659
1226
|
});
|
|
660
1227
|
assert.match(parallelResult.content[0]?.text ?? "", /Async parallel:/);
|
|
661
1228
|
assert.match(parallelResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
|
|
662
|
-
assert.match(parallelResult.content[0]?.text ?? "", /call
|
|
1229
|
+
assert.match(parallelResult.content[0]?.text ?? "", /call subagent_wait\(\)/i);
|
|
1230
|
+
assert.equal(startedEvent(parallelId).goal, "Do one");
|
|
663
1231
|
const parallelResultPath = await waitForAsyncResultFile(parallelId, 10_000);
|
|
664
1232
|
const parallelPayload = JSON.parse(fs.readFileSync(parallelResultPath, "utf-8")) as { agent?: string; mode?: string };
|
|
665
1233
|
assert.equal(parallelPayload.mode, "parallel");
|
|
@@ -667,16 +1235,110 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
667
1235
|
|
|
668
1236
|
mockPi.onCall({ output: "chain done" });
|
|
669
1237
|
const chainId = `async-handoff-chain-${Date.now().toString(36)}`;
|
|
1238
|
+
const chainGoal = `Coordinate the complete workflow ${"goal ".repeat(30)}`;
|
|
1239
|
+
const chainChildTask = `Do chained work ${"child ".repeat(15)}`;
|
|
670
1240
|
const chainResult = executeAsyncChain(chainId, {
|
|
671
|
-
|
|
1241
|
+
task: chainGoal,
|
|
1242
|
+
chain: [{ agent: "worker", task: chainChildTask }],
|
|
672
1243
|
agents: [makeAgent("worker")],
|
|
673
1244
|
...commonParams,
|
|
674
1245
|
});
|
|
675
1246
|
assert.match(chainResult.content[0]?.text ?? "", /Async chain:/);
|
|
676
1247
|
assert.match(chainResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
|
|
1248
|
+
const chainEvent = startedEvent(chainId);
|
|
1249
|
+
assert.equal(chainEvent.task, chainChildTask.slice(0, 50));
|
|
1250
|
+
assert.equal(chainEvent.goal, chainGoal.slice(0, 120));
|
|
677
1251
|
await waitForAsyncResultFile(chainId, 10_000);
|
|
678
1252
|
});
|
|
679
1253
|
|
|
1254
|
+
it("fails background chains when requested extension tools are unavailable", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1255
|
+
mockPi.onCall({ output: "Model incorrectly claimed success", missingTools: ["fixture_search"] });
|
|
1256
|
+
const id = `async-missing-extension-tool-${Date.now().toString(36)}`;
|
|
1257
|
+
|
|
1258
|
+
executeAsyncChain(id, {
|
|
1259
|
+
chain: [{ agent: "extension-worker", task: "Use fixture search" }],
|
|
1260
|
+
agents: [makeAgent("extension-worker", { tools: ["read", "fixture_search"] })],
|
|
1261
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1262
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1263
|
+
shareEnabled: false,
|
|
1264
|
+
maxSubagentDepth: 2,
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1268
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1269
|
+
assert.equal(payload.success, false);
|
|
1270
|
+
assert.equal(payload.state, "failed");
|
|
1271
|
+
assert.match(payload.results[0]?.error ?? "", /requested unavailable child tools: fixture_search/);
|
|
1272
|
+
assert.match(payload.results[0]?.error ?? "", /subagentOnlyExtensions/);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("applies agent acceptance roles to inferred async acceptance", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1276
|
+
mockPi.onCall({ output: "exploration complete" });
|
|
1277
|
+
const executor = makeAsyncExecutor([makeAgent("worker", { acceptanceRole: "read-only" })]);
|
|
1278
|
+
|
|
1279
|
+
const result = await executor.execute(
|
|
1280
|
+
"async-agent-acceptance-role",
|
|
1281
|
+
{ agent: "worker", task: "Explore the authentication flow", async: true, clarify: false },
|
|
1282
|
+
new AbortController().signal,
|
|
1283
|
+
undefined,
|
|
1284
|
+
makeMinimalCtx(tempDir),
|
|
1285
|
+
);
|
|
1286
|
+
|
|
1287
|
+
const asyncId = result.details?.asyncId;
|
|
1288
|
+
assert.ok(asyncId, "expected asyncId");
|
|
1289
|
+
const payload = await readAsyncPayload(asyncId);
|
|
1290
|
+
assert.equal(payload.results[0]?.acceptance?.effectiveAcceptance.level, "attested");
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it("applies agent acceptance roles to inferred async parallel acceptance", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1294
|
+
mockPi.onCall({ output: "parallel exploration complete" });
|
|
1295
|
+
const executor = makeAsyncExecutor([makeAgent("worker", { acceptanceRole: "read-only" })]);
|
|
1296
|
+
|
|
1297
|
+
const result = await executor.execute(
|
|
1298
|
+
"async-parallel-agent-acceptance-role",
|
|
1299
|
+
{ tasks: [{ agent: "worker", task: "Explore the authentication flow" }], async: true, clarify: false },
|
|
1300
|
+
new AbortController().signal,
|
|
1301
|
+
undefined,
|
|
1302
|
+
makeMinimalCtx(tempDir),
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
const asyncId = result.details?.asyncId;
|
|
1306
|
+
assert.ok(asyncId, "expected asyncId");
|
|
1307
|
+
const payload = await readAsyncPayload(asyncId);
|
|
1308
|
+
assert.equal(payload.results[0]?.acceptance?.effectiveAcceptance.level, "attested");
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it("infers async chain acceptance after expanding top-level task templates", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1312
|
+
mockPi.onCall({ output: "patched" });
|
|
1313
|
+
mockPi.onCall({ output: "reviewed" });
|
|
1314
|
+
|
|
1315
|
+
const patchId = `async-role-task-template-patch-${Date.now().toString(36)}`;
|
|
1316
|
+
executeAsyncChain(patchId, {
|
|
1317
|
+
task: "Patch src/auth.ts",
|
|
1318
|
+
chain: [{ agent: "explorer", task: "{task}" }],
|
|
1319
|
+
agents: [makeAgent("explorer", { acceptanceRole: "read-only" })],
|
|
1320
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-role-task-patch" },
|
|
1321
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1322
|
+
shareEnabled: false,
|
|
1323
|
+
maxSubagentDepth: 2,
|
|
1324
|
+
});
|
|
1325
|
+
const patchPayload = await readAsyncPayload(patchId);
|
|
1326
|
+
assert.equal(patchPayload.results[0]?.acceptance?.effectiveAcceptance?.level, "checked");
|
|
1327
|
+
|
|
1328
|
+
const reviewId = `async-role-task-template-review-${Date.now().toString(36)}`;
|
|
1329
|
+
executeAsyncChain(reviewId, {
|
|
1330
|
+
task: "Review only; do not edit files",
|
|
1331
|
+
chain: [{ agent: "implementer", task: "{task}" }],
|
|
1332
|
+
agents: [makeAgent("implementer", { acceptanceRole: "writer" })],
|
|
1333
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-role-task-review" },
|
|
1334
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1335
|
+
shareEnabled: false,
|
|
1336
|
+
maxSubagentDepth: 2,
|
|
1337
|
+
});
|
|
1338
|
+
const reviewPayload = await readAsyncPayload(reviewId);
|
|
1339
|
+
assert.equal(reviewPayload.results[0]?.acceptance?.effectiveAcceptance?.level, "attested");
|
|
1340
|
+
});
|
|
1341
|
+
|
|
680
1342
|
it("top-level async parallel conversion preserves output, reads, and progress", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
681
1343
|
mockPi.onCall({ output: "Async top-level report" });
|
|
682
1344
|
const executor = createSubagentExecutor!({
|
|
@@ -714,11 +1376,13 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
714
1376
|
|
|
715
1377
|
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
716
1378
|
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1379
|
+
assert.equal(payload.mode, "parallel");
|
|
1380
|
+
assert.equal(payload.sessionId, "session-123");
|
|
1381
|
+
assert.equal(payload.results[0]?.acceptance?.status, "review-required");
|
|
1382
|
+
assert.equal(payload.results[0]?.acceptance?.evidenceStatus, "checked");
|
|
1383
|
+
assert.equal(status.sessionId, "session-123");
|
|
1384
|
+
assert.equal(status.steps?.[0]?.acceptance?.status, "review-required");
|
|
1385
|
+
assert.equal(status.steps?.[0]?.acceptance?.evidenceStatus, "checked");
|
|
722
1386
|
const outputPath = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", asyncId, "async-top-output.md");
|
|
723
1387
|
const outputDeadline = Date.now() + 5_000;
|
|
724
1388
|
while (!fs.existsSync(outputPath)) {
|
|
@@ -740,7 +1404,141 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
740
1404
|
assert.equal(fs.existsSync(path.join(tempDir, "progress.md")), false);
|
|
741
1405
|
});
|
|
742
1406
|
|
|
743
|
-
|
|
1407
|
+
for (const outputOverride of [undefined, true] as const) {
|
|
1408
|
+
it(`async top-level parallel isolates inherited output (${outputOverride === true ? "output:true" : "omitted output"})`, { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1409
|
+
mockPi.onCall({ matchArgIncludes: "Write the first report", output: "first async report" });
|
|
1410
|
+
mockPi.onCall({ matchArgIncludes: "Write the second report", output: "second async report" });
|
|
1411
|
+
const agent = makeAgent("worker", { output: "context.md", tools: ["read", "grep", "find", "ls"] });
|
|
1412
|
+
const executor = makeAsyncExecutor([agent]);
|
|
1413
|
+
const tasks = [
|
|
1414
|
+
{ agent: "worker", task: "Write the first report", ...(outputOverride !== undefined ? { output: outputOverride } : {}) },
|
|
1415
|
+
{ agent: "worker", task: "Write the second report", ...(outputOverride !== undefined ? { output: outputOverride } : {}) },
|
|
1416
|
+
];
|
|
1417
|
+
const launch = await executor.execute(
|
|
1418
|
+
`async-inherited-output-${outputOverride === true ? "true" : "omitted"}`,
|
|
1419
|
+
{ tasks, async: true, clarify: false },
|
|
1420
|
+
new AbortController().signal,
|
|
1421
|
+
undefined,
|
|
1422
|
+
makeMinimalCtx(tempDir),
|
|
1423
|
+
);
|
|
1424
|
+
|
|
1425
|
+
assert.equal(launch.isError, undefined);
|
|
1426
|
+
const payload = await readAsyncPayload(launch.details?.asyncId as string);
|
|
1427
|
+
assert.equal(payload.success, true);
|
|
1428
|
+
assert.equal(payload.results[0]?.output?.split("\n\nOutput saved to:")[0], "first async report");
|
|
1429
|
+
assert.equal(payload.results[1]?.output?.split("\n\nOutput saved to:")[0], "second async report");
|
|
1430
|
+
const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", launch.details?.asyncId as string);
|
|
1431
|
+
const authoritativePaths = [
|
|
1432
|
+
path.join(outputDir, "parallel-0", "0-worker", "context.md"),
|
|
1433
|
+
path.join(outputDir, "parallel-0", "1-worker", "context.md"),
|
|
1434
|
+
];
|
|
1435
|
+
assert.equal(fs.readFileSync(authoritativePaths[0]!, "utf-8"), "first async report");
|
|
1436
|
+
assert.equal(fs.readFileSync(authoritativePaths[1]!, "utf-8"), "second async report");
|
|
1437
|
+
const artifactPaths = payload.results.map((result) => result.artifactPaths?.outputPath);
|
|
1438
|
+
assert.ok(artifactPaths[0] && artifactPaths[1]);
|
|
1439
|
+
assert.notEqual(artifactPaths[0], artifactPaths[1]);
|
|
1440
|
+
assert.equal(fs.readFileSync(artifactPaths[0], "utf-8"), "first async report");
|
|
1441
|
+
assert.equal(fs.readFileSync(artifactPaths[1], "utf-8"), "second async report");
|
|
1442
|
+
const calls = fs.readdirSync(mockPi.dir).filter((name) => name.startsWith("call-")).sort();
|
|
1443
|
+
const taskArgs = calls.map((name) => (JSON.parse(fs.readFileSync(path.join(mockPi.dir, name), "utf-8")) as MockPiCallRecord).args?.at(-1) ?? "");
|
|
1444
|
+
const firstTask = taskArgs.find((task) => task.includes("Write the first report")) ?? "";
|
|
1445
|
+
const secondTask = taskArgs.find((task) => task.includes("Write the second report")) ?? "";
|
|
1446
|
+
assert.ok(firstTask.includes(path.join("parallel-0", "0-worker", "context.md")));
|
|
1447
|
+
assert.ok(secondTask.includes(path.join("parallel-0", "1-worker", "context.md")));
|
|
1448
|
+
for (const taskArg of [firstTask, secondTask]) {
|
|
1449
|
+
assert.match(taskArg, /Return the complete artifact in your final response\./);
|
|
1450
|
+
assert.match(taskArg, /Do not call contact_supervisor merely because no write-capable tool is available\./);
|
|
1451
|
+
assert.doesNotMatch(taskArg, /Write your findings to exactly this path/);
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
it("async top-level parallel rejects duplicate explicit output paths before spawning", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1457
|
+
const executor = makeAsyncExecutor([makeAgent("worker")]);
|
|
1458
|
+
const result = await executor.execute(
|
|
1459
|
+
"async-duplicate-output",
|
|
1460
|
+
{
|
|
1461
|
+
tasks: [
|
|
1462
|
+
{ agent: "worker", task: "Write A", output: "same.md" },
|
|
1463
|
+
{ agent: "worker", task: "Write B", output: "same.md" },
|
|
1464
|
+
],
|
|
1465
|
+
async: true,
|
|
1466
|
+
clarify: false,
|
|
1467
|
+
},
|
|
1468
|
+
new AbortController().signal,
|
|
1469
|
+
undefined,
|
|
1470
|
+
makeMinimalCtx(tempDir),
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
assert.equal(result.isError, true);
|
|
1474
|
+
assert.match(result.content[0]?.text ?? "", /Parallel tasks 1 \(worker\) and 2 \(worker\).*same\.md/);
|
|
1475
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it("async top-level parallel preserves distinct explicit output destinations", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1479
|
+
mockPi.onCall({ matchArgIncludes: "first.md", output: "first explicit report" });
|
|
1480
|
+
mockPi.onCall({ matchArgIncludes: "second.md", output: "second explicit report" });
|
|
1481
|
+
const executor = makeAsyncExecutor([makeAgent("worker", { output: "context.md" })]);
|
|
1482
|
+
const launch = await executor.execute(
|
|
1483
|
+
"async-distinct-explicit-output",
|
|
1484
|
+
{
|
|
1485
|
+
tasks: [
|
|
1486
|
+
{ agent: "worker", task: "Write A", output: "first.md" },
|
|
1487
|
+
{ agent: "worker", task: "Write B", output: "second.md" },
|
|
1488
|
+
],
|
|
1489
|
+
async: true,
|
|
1490
|
+
clarify: false,
|
|
1491
|
+
},
|
|
1492
|
+
new AbortController().signal,
|
|
1493
|
+
undefined,
|
|
1494
|
+
makeMinimalCtx(tempDir),
|
|
1495
|
+
);
|
|
1496
|
+
|
|
1497
|
+
const payload = await readAsyncPayload(launch.details?.asyncId as string);
|
|
1498
|
+
assert.equal(payload.success, true);
|
|
1499
|
+
const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", launch.details?.asyncId as string);
|
|
1500
|
+
assert.equal(fs.readFileSync(path.join(outputDir, "first.md"), "utf-8"), "first explicit report");
|
|
1501
|
+
assert.equal(fs.readFileSync(path.join(outputDir, "second.md"), "utf-8"), "second explicit report");
|
|
1502
|
+
assert.ok(payload.results[0]?.artifactPaths?.outputPath?.endsWith("_worker_0_output.md"));
|
|
1503
|
+
assert.ok(payload.results[1]?.artifactPaths?.outputPath?.endsWith("_worker_1_output.md"));
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
it("async chain static parallel namespaces inherited default outputs", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1507
|
+
mockPi.onCall({ matchArgIncludes: "Write first", output: "chain first report" });
|
|
1508
|
+
mockPi.onCall({ matchArgIncludes: "Write second", output: "chain second report" });
|
|
1509
|
+
const id = `async-chain-parallel-output-${Date.now().toString(36)}`;
|
|
1510
|
+
const launch = executeAsyncChain(id, {
|
|
1511
|
+
chain: [{ parallel: [{ agent: "worker", task: "Write first" }, { agent: "worker", task: "Write second" }] }],
|
|
1512
|
+
agents: [makeAgent("worker", { output: "context.md" })],
|
|
1513
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-chain-output" },
|
|
1514
|
+
artifactConfig: { enabled: true, includeInput: false, includeOutput: true, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1515
|
+
artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
|
|
1516
|
+
shareEnabled: false,
|
|
1517
|
+
maxSubagentDepth: 2,
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
assert.equal(launch.isError, undefined);
|
|
1521
|
+
const payload = await readAsyncPayload(id);
|
|
1522
|
+
assert.equal(payload.success, true);
|
|
1523
|
+
const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", id);
|
|
1524
|
+
const authoritativePaths = [
|
|
1525
|
+
path.join(outputDir, "parallel-0", "0-worker", "context.md"),
|
|
1526
|
+
path.join(outputDir, "parallel-0", "1-worker", "context.md"),
|
|
1527
|
+
];
|
|
1528
|
+
assert.equal(fs.readFileSync(authoritativePaths[0]!, "utf-8"), "chain first report");
|
|
1529
|
+
assert.equal(fs.readFileSync(authoritativePaths[1]!, "utf-8"), "chain second report");
|
|
1530
|
+
const artifactPaths = payload.results.map((result) => result.artifactPaths?.outputPath);
|
|
1531
|
+
assert.ok(artifactPaths[0] && artifactPaths[1]);
|
|
1532
|
+
assert.notEqual(artifactPaths[0], artifactPaths[1]);
|
|
1533
|
+
assert.equal(fs.readFileSync(artifactPaths[0], "utf-8"), "chain first report");
|
|
1534
|
+
assert.equal(fs.readFileSync(artifactPaths[1], "utf-8"), "chain second report");
|
|
1535
|
+
const calls = fs.readdirSync(mockPi.dir).filter((name) => name.startsWith("call-")).sort();
|
|
1536
|
+
const taskArgs = calls.map((name) => (JSON.parse(fs.readFileSync(path.join(mockPi.dir, name), "utf-8")) as MockPiCallRecord).args?.at(-1) ?? "");
|
|
1537
|
+
assert.ok(taskArgs.find((task) => task.includes("Write first"))?.includes(path.join("parallel-0", "0-worker", "context.md")));
|
|
1538
|
+
assert.ok(taskArgs.find((task) => task.includes("Write second"))?.includes(path.join("parallel-0", "1-worker", "context.md")));
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
it("async single preserves checked evidence while independent review is pending", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
744
1542
|
mockPi.onCall({
|
|
745
1543
|
output: [
|
|
746
1544
|
"implemented",
|
|
@@ -775,17 +1573,18 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
775
1573
|
artifactConfig,
|
|
776
1574
|
shareEnabled: false,
|
|
777
1575
|
maxSubagentDepth: 2,
|
|
778
|
-
acceptance: { level: "
|
|
1576
|
+
acceptance: { level: "checked", criteria: ["Patch bug"], review: { agent: "reviewer", required: true } },
|
|
779
1577
|
});
|
|
780
1578
|
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
781
1579
|
const result = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
782
1580
|
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
783
1581
|
|
|
784
|
-
assert.equal(result.success,
|
|
785
|
-
assert.equal(result.results[0]?.acceptance?.status, "
|
|
1582
|
+
assert.equal(result.success, true);
|
|
1583
|
+
assert.equal(result.results[0]?.acceptance?.status, "review-required");
|
|
1584
|
+
assert.equal(result.results[0]?.acceptance?.evidenceStatus, "checked");
|
|
786
1585
|
assert.ok(result.results[0]?.acceptance?.childReport);
|
|
787
|
-
assert.equal(result.results[0]?.acceptance?.reviewResult?.status, "
|
|
788
|
-
assert.equal(status.steps?.[0]?.acceptance?.status, "
|
|
1586
|
+
assert.equal(result.results[0]?.acceptance?.reviewResult?.status, "review-required");
|
|
1587
|
+
assert.equal(status.steps?.[0]?.acceptance?.status, "review-required");
|
|
789
1588
|
});
|
|
790
1589
|
|
|
791
1590
|
it("top-level async chain suppresses progress for {task} review-only tasks", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
@@ -1002,8 +1801,8 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1002
1801
|
|
|
1003
1802
|
it("async chains expand dynamic fanout and persist collected output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1004
1803
|
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1005
|
-
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
1006
|
-
mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
|
|
1804
|
+
mockPi.onCall({ matchArgIncludes: "Review src/a.ts", output: "review-a", structuredOutput: { ok: "a" } });
|
|
1805
|
+
mockPi.onCall({ matchArgIncludes: "Review src/b.ts", output: "review-b", structuredOutput: { ok: "b" } });
|
|
1007
1806
|
mockPi.onCall({ output: "used reviews" });
|
|
1008
1807
|
const id = `async-dynamic-chain-${Date.now().toString(36)}`;
|
|
1009
1808
|
const result = executeAsyncChain(id, {
|
|
@@ -1022,9 +1821,10 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1022
1821
|
},
|
|
1023
1822
|
{ agent: "consumer", task: "Use {outputs.reviews}" },
|
|
1024
1823
|
],
|
|
1025
|
-
agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")],
|
|
1824
|
+
agents: [makeAgent("producer"), makeAgent("reviewer", { output: "context.md" }), makeAgent("consumer")],
|
|
1026
1825
|
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" },
|
|
1027
|
-
artifactConfig: { enabled:
|
|
1826
|
+
artifactConfig: { enabled: true, includeInput: false, includeOutput: true, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1827
|
+
artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
|
|
1028
1828
|
shareEnabled: false,
|
|
1029
1829
|
maxSubagentDepth: 2,
|
|
1030
1830
|
});
|
|
@@ -1041,6 +1841,20 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1041
1841
|
const collected = payload.outputs?.reviews?.structured as Array<{ key: string; structured: unknown }>;
|
|
1042
1842
|
assert.deepEqual(collected.map((item) => item.key), ["src/a.ts", "src/b.ts"]);
|
|
1043
1843
|
assert.deepEqual(collected.map((item) => item.structured), [{ ok: "a" }, { ok: "b" }]);
|
|
1844
|
+
const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", id);
|
|
1845
|
+
const dynamicOutputPaths = [
|
|
1846
|
+
path.join(outputDir, "dynamic-1", "0-reviewer", "context.md"),
|
|
1847
|
+
path.join(outputDir, "dynamic-1", "1-reviewer", "context.md"),
|
|
1848
|
+
];
|
|
1849
|
+
assert.equal(fs.readFileSync(dynamicOutputPaths[0]!, "utf-8"), "review-a");
|
|
1850
|
+
assert.equal(fs.readFileSync(dynamicOutputPaths[1]!, "utf-8"), "review-b");
|
|
1851
|
+
const reviewerArtifacts = payload.results.slice(1, 3).map((result) => result.artifactPaths?.outputPath);
|
|
1852
|
+
assert.ok(reviewerArtifacts[0] && reviewerArtifacts[1]);
|
|
1853
|
+
assert.notEqual(reviewerArtifacts[0], reviewerArtifacts[1]);
|
|
1854
|
+
assert.equal(fs.readFileSync(reviewerArtifacts[0], "utf-8"), "review-a");
|
|
1855
|
+
assert.equal(fs.readFileSync(reviewerArtifacts[1], "utf-8"), "review-b");
|
|
1856
|
+
assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", new RegExp(dynamicOutputPaths[0]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1857
|
+
assert.match(readMockPiArgs(mockPi, 2).at(-1) ?? "", new RegExp(dynamicOutputPaths[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1044
1858
|
assert.equal(status.steps?.length, 4);
|
|
1045
1859
|
assert.deepEqual(status.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]);
|
|
1046
1860
|
assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "dynamic-parallel-group");
|
|
@@ -1048,6 +1862,35 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1048
1862
|
assert.equal(payload.workflowGraph?.nodes?.[2]?.flatIndex, 3);
|
|
1049
1863
|
});
|
|
1050
1864
|
|
|
1865
|
+
it("rejects a shared explicit output before dynamic fanout children start", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1866
|
+
mockPi.onCall({ matchArgIncludes: "Produce targets", output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1867
|
+
const id = `async-dynamic-explicit-output-${Date.now().toString(36)}`;
|
|
1868
|
+
const launch = executeAsyncChain(id, {
|
|
1869
|
+
chain: [
|
|
1870
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1871
|
+
{
|
|
1872
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", maxItems: 2 },
|
|
1873
|
+
parallel: { agent: "reviewer", task: "Review {target.path}", output: "shared.md" },
|
|
1874
|
+
collect: { as: "reviews" },
|
|
1875
|
+
concurrency: 2,
|
|
1876
|
+
},
|
|
1877
|
+
],
|
|
1878
|
+
agents: [makeAgent("producer"), makeAgent("reviewer")],
|
|
1879
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-explicit-output" },
|
|
1880
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1881
|
+
shareEnabled: false,
|
|
1882
|
+
maxSubagentDepth: 2,
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
assert.equal(launch.isError, undefined);
|
|
1886
|
+
const payload = await readAsyncPayload(id);
|
|
1887
|
+
assert.equal(payload.success, false);
|
|
1888
|
+
const error = payload.results.find((result) => result.error)?.error ?? "";
|
|
1889
|
+
assert.match(error, /materialized 2 items that resolve output to the same path/);
|
|
1890
|
+
assert.match(error, /shared\.md/);
|
|
1891
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1051
1894
|
it("async dynamic fanout applies fork session files and thinking overrides to materialized children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1052
1895
|
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1053
1896
|
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
@@ -1094,6 +1937,97 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1094
1937
|
assert.deepEqual(status.steps?.slice(1).map((step) => step.thinking), ["off", "off"]);
|
|
1095
1938
|
});
|
|
1096
1939
|
|
|
1940
|
+
it("applies read-only acceptance roles to async dynamic children and their aggregate group", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1941
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1942
|
+
const readOnlyReport = [
|
|
1943
|
+
"done",
|
|
1944
|
+
"```acceptance-report",
|
|
1945
|
+
JSON.stringify({
|
|
1946
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "inspection complete" }],
|
|
1947
|
+
changedFiles: [],
|
|
1948
|
+
testsAddedOrUpdated: [],
|
|
1949
|
+
commandsRun: [],
|
|
1950
|
+
validationOutput: [],
|
|
1951
|
+
reviewFindings: ["No blocking findings"],
|
|
1952
|
+
residualRisks: [],
|
|
1953
|
+
noStagedFiles: true,
|
|
1954
|
+
}),
|
|
1955
|
+
"```",
|
|
1956
|
+
].join("\n");
|
|
1957
|
+
mockPi.onCall({ output: readOnlyReport, structuredOutput: { ok: "a" } });
|
|
1958
|
+
mockPi.onCall({ output: readOnlyReport, structuredOutput: { ok: "b" } });
|
|
1959
|
+
const id = `async-dynamic-acceptance-role-${Date.now().toString(36)}`;
|
|
1960
|
+
const result = executeAsyncChain(id, {
|
|
1961
|
+
chain: [
|
|
1962
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1963
|
+
{
|
|
1964
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
|
|
1965
|
+
parallel: { agent: "explorer", task: "Explore {target.path}", outputSchema: { type: "object" } },
|
|
1966
|
+
collect: { as: "reviews" },
|
|
1967
|
+
concurrency: 1,
|
|
1968
|
+
},
|
|
1969
|
+
],
|
|
1970
|
+
agents: [makeAgent("producer"), makeAgent("explorer", { acceptanceRole: "read-only" })],
|
|
1971
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-role" },
|
|
1972
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1973
|
+
shareEnabled: false,
|
|
1974
|
+
maxSubagentDepth: 2,
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
assert.ok(!result.isError);
|
|
1978
|
+
const payload = await readAsyncPayload(id);
|
|
1979
|
+
const explorerResults = payload.results.filter((child) => child.agent === "explorer");
|
|
1980
|
+
assert.deepEqual(explorerResults.map((child) => child.acceptance?.effectiveAcceptance?.level), ["attested", "attested"]);
|
|
1981
|
+
const dynamicNode = payload.workflowGraph?.nodes?.[1];
|
|
1982
|
+
assert.equal(dynamicNode?.acceptanceStatus, "attested");
|
|
1983
|
+
assert.deepEqual(dynamicNode?.children?.map((child) => child.acceptanceStatus), ["attested", "attested"]);
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
it("infers async dynamic acceptance after materializing item templates", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1987
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1988
|
+
const writerReport = [
|
|
1989
|
+
"done",
|
|
1990
|
+
"```acceptance-report",
|
|
1991
|
+
JSON.stringify({
|
|
1992
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patch complete" }],
|
|
1993
|
+
changedFiles: ["src/a.ts"],
|
|
1994
|
+
testsAddedOrUpdated: ["test/a.test.ts"],
|
|
1995
|
+
commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
|
|
1996
|
+
validationOutput: ["tests passed"],
|
|
1997
|
+
residualRisks: [],
|
|
1998
|
+
noStagedFiles: true,
|
|
1999
|
+
}),
|
|
2000
|
+
"```",
|
|
2001
|
+
].join("\n");
|
|
2002
|
+
mockPi.onCall({ output: writerReport, structuredOutput: { ok: "a" } });
|
|
2003
|
+
mockPi.onCall({ output: writerReport, structuredOutput: { ok: "b" } });
|
|
2004
|
+
const id = `async-dynamic-role-item-template-${Date.now().toString(36)}`;
|
|
2005
|
+
executeAsyncChain(id, {
|
|
2006
|
+
chain: [
|
|
2007
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
2008
|
+
{
|
|
2009
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
|
|
2010
|
+
parallel: { agent: "explorer", task: "Patch {target.path}", outputSchema: { type: "object" } },
|
|
2011
|
+
collect: { as: "reviews" },
|
|
2012
|
+
concurrency: 1,
|
|
2013
|
+
},
|
|
2014
|
+
],
|
|
2015
|
+
agents: [makeAgent("producer"), makeAgent("explorer", { acceptanceRole: "read-only" })],
|
|
2016
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-role-item" },
|
|
2017
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
2018
|
+
shareEnabled: false,
|
|
2019
|
+
maxSubagentDepth: 2,
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
const payload = await readAsyncPayload(id);
|
|
2023
|
+
const explorerResults = payload.results.filter((child) => child.agent === "explorer");
|
|
2024
|
+
assert.deepEqual(explorerResults.map((child) => child.acceptance?.effectiveAcceptance?.level), ["checked", "checked"]);
|
|
2025
|
+
const dynamicNode = payload.workflowGraph?.nodes?.[1];
|
|
2026
|
+
assert.equal(payload.success, true);
|
|
2027
|
+
assert.equal(dynamicNode?.acceptanceStatus, "rejected");
|
|
2028
|
+
assert.deepEqual(dynamicNode?.children?.map((child) => child.acceptanceStatus), ["rejected", "rejected"]);
|
|
2029
|
+
});
|
|
2030
|
+
|
|
1097
2031
|
it("cancels dynamic fanout aggregate acceptance when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
1098
2032
|
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
|
|
1099
2033
|
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
@@ -1269,28 +2203,54 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1269
2203
|
const taskArg = args.at(-1) ?? "";
|
|
1270
2204
|
assert.ok(taskArg.includes(`[Read from: ${path.join(worktreeCwd, "input.md")}]`));
|
|
1271
2205
|
assert.ok(taskArg.includes(`Write your findings to exactly this path: ${path.join(repoDir, ".pi-subagents", "artifacts", "outputs", asyncId, "report.md")}`));
|
|
1272
|
-
await waitForAsyncResultFile(asyncId, 90_000);
|
|
2206
|
+
const resultPath = await waitForAsyncResultFile(asyncId, 90_000);
|
|
2207
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
2208
|
+
const status = JSON.parse(fs.readFileSync(path.join(result.details!.asyncDir!, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
2209
|
+
assert.equal(payload.parallelHandoff?.version, 1);
|
|
2210
|
+
assert.equal(payload.parallelHandoff?.path, path.join(result.details!.asyncDir!, "handoff.json"));
|
|
2211
|
+
assert.deepEqual(status.parallelHandoff, payload.parallelHandoff);
|
|
2212
|
+
assert.equal(payload.parallelHandoff?.childCount, 1);
|
|
2213
|
+
assert.equal(payload.parallelHandoff?.cleanupState, "complete");
|
|
2214
|
+
assert.equal(fs.existsSync(worktreeCwd), false, "temporary worktree should be removed before handoff publication");
|
|
2215
|
+
const handoff = JSON.parse(fs.readFileSync(payload.parallelHandoff!.path!, "utf-8")) as {
|
|
2216
|
+
version: number;
|
|
2217
|
+
groups: Array<{ children: Array<{ agent: string; status: string; patch: { path: string } }>; cleanup: { state: string } }>;
|
|
2218
|
+
};
|
|
2219
|
+
assert.equal(handoff.version, 1);
|
|
2220
|
+
assert.equal(handoff.groups[0]!.children[0]!.agent, "worker");
|
|
2221
|
+
assert.equal(handoff.groups[0]!.children[0]!.status, "completed");
|
|
2222
|
+
assert.equal(handoff.groups[0]!.cleanup.state, "complete");
|
|
2223
|
+
assert.equal(fs.existsSync(handoff.groups[0]!.children[0]!.patch.path), true, "patch artifact should outlive cleanup");
|
|
1273
2224
|
} finally {
|
|
1274
2225
|
removeTempDir(repoDir);
|
|
1275
2226
|
}
|
|
1276
2227
|
});
|
|
1277
2228
|
|
|
1278
|
-
it("readStatus caches
|
|
2229
|
+
it("readStatus caches unchanged files and invalidates same-mtime replacements", () => {
|
|
1279
2230
|
const dir = createTempDir();
|
|
1280
2231
|
try {
|
|
2232
|
+
const statusPath = path.join(dir, "status.json");
|
|
2233
|
+
const fixedTimestamp = new Date(1_700_000_000_000);
|
|
1281
2234
|
const statusData = {
|
|
1282
2235
|
runId: "cache-test",
|
|
1283
2236
|
state: "running",
|
|
1284
2237
|
mode: "single",
|
|
1285
|
-
startedAt:
|
|
2238
|
+
startedAt: fixedTimestamp.getTime(),
|
|
1286
2239
|
};
|
|
1287
|
-
fs.writeFileSync(
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
const
|
|
1291
|
-
assert.ok(
|
|
1292
|
-
assert.
|
|
1293
|
-
|
|
2240
|
+
fs.writeFileSync(statusPath, JSON.stringify(statusData));
|
|
2241
|
+
fs.utimesSync(statusPath, fixedTimestamp, fixedTimestamp);
|
|
2242
|
+
|
|
2243
|
+
const cached = readStatus(dir);
|
|
2244
|
+
assert.ok(cached);
|
|
2245
|
+
assert.strictEqual(readStatus(dir), cached);
|
|
2246
|
+
|
|
2247
|
+
writeAtomicJson(statusPath, { ...statusData, state: "stopped" });
|
|
2248
|
+
fs.utimesSync(statusPath, fixedTimestamp, fixedTimestamp);
|
|
2249
|
+
assert.equal(fs.statSync(statusPath).mtimeMs, fixedTimestamp.getTime());
|
|
2250
|
+
const replaced = readStatus(dir);
|
|
2251
|
+
assert.ok(replaced);
|
|
2252
|
+
assert.equal(replaced.state, "stopped");
|
|
2253
|
+
assert.notStrictEqual(replaced, cached);
|
|
1294
2254
|
} finally {
|
|
1295
2255
|
removeTempDir(dir);
|
|
1296
2256
|
}
|
|
@@ -1328,6 +2288,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1328
2288
|
const run = executeAsyncSingle(id, {
|
|
1329
2289
|
agent: "worker",
|
|
1330
2290
|
task: "Do work",
|
|
2291
|
+
acceptance: { level: "none", reason: "descriptor persistence coverage" },
|
|
1331
2292
|
agentConfig: makeAgent("worker", {
|
|
1332
2293
|
model: "openai/gpt-5-mini:high",
|
|
1333
2294
|
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
@@ -1360,8 +2321,22 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1360
2321
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1361
2322
|
}
|
|
1362
2323
|
|
|
2324
|
+
const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
|
|
2325
|
+
const descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf-8"));
|
|
2326
|
+
assert.equal(descriptor.sourceRunId, id);
|
|
2327
|
+
assert.equal(descriptor.agent, "worker");
|
|
2328
|
+
assert.equal(descriptor.model, "openai/gpt-5-mini:high");
|
|
2329
|
+
assert.deepEqual(descriptor.fallbackModels, ["anthropic/claude-sonnet-4:low"]);
|
|
2330
|
+
assert.equal(descriptor.cwd, tempDir);
|
|
2331
|
+
assert.equal(descriptor.sessionDir, path.join(sessionRoot, `async-${id}`));
|
|
2332
|
+
assert.deepEqual(descriptor.acceptance, { level: "none", reason: "descriptor persistence coverage" });
|
|
2333
|
+
assert.equal(Object.hasOwn(descriptor.acceptance, "explicit"), false);
|
|
2334
|
+
assert.equal(Object.hasOwn(descriptor.acceptance, "inferredReason"), false);
|
|
2335
|
+
assert.equal(Object.hasOwn(descriptor, "task"), false);
|
|
2336
|
+
if (process.platform !== "win32") assert.equal(fs.statSync(descriptorPath).mode & 0o777, 0o600);
|
|
2337
|
+
|
|
1363
2338
|
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1364
|
-
assert.equal(payload.lifecycleArtifactVersion,
|
|
2339
|
+
assert.equal(payload.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1365
2340
|
assert.equal(payload.success, true);
|
|
1366
2341
|
assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:low");
|
|
1367
2342
|
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
|
|
@@ -1369,7 +2344,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1369
2344
|
assert.deepEqual(payload.results[0].totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1370
2345
|
assert.deepEqual(payload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1371
2346
|
const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1372
|
-
assert.equal(statusPayload.lifecycleArtifactVersion,
|
|
2347
|
+
assert.equal(statusPayload.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1373
2348
|
assert.equal(statusPayload.steps[0]?.model, "anthropic/claude-sonnet-4:low");
|
|
1374
2349
|
assert.equal(statusPayload.steps[0]?.thinking, "low");
|
|
1375
2350
|
assert.ok(statusPayload.totalTokens!.total > 0);
|
|
@@ -1377,14 +2352,103 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1377
2352
|
assert.deepEqual(statusPayload.steps[0]?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1378
2353
|
assert.deepEqual(statusPayload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1379
2354
|
const events = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line));
|
|
1380
|
-
assert.equal(events.find((event) => event.type === "subagent.run.started")?.lifecycleArtifactVersion,
|
|
2355
|
+
assert.equal(events.find((event) => event.type === "subagent.run.started")?.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1381
2356
|
const completed = events.find((event) => event.type === "subagent.run.completed");
|
|
1382
|
-
assert.equal(completed?.lifecycleArtifactVersion,
|
|
2357
|
+
assert.equal(completed?.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1383
2358
|
assert.deepEqual(completed?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1384
2359
|
assert.match(fs.readFileSync(path.join(asyncDir, "output-0.log"), "utf-8"), /Recovered asynchronously/);
|
|
1385
2360
|
assert.equal(mockPi.callCount(), 2);
|
|
1386
2361
|
});
|
|
1387
2362
|
|
|
2363
|
+
it("background runs retry the fallback model when the provider stream ends without finish_reason", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2364
|
+
mockPi.onCall({
|
|
2365
|
+
jsonl: [{
|
|
2366
|
+
type: "message_end",
|
|
2367
|
+
message: {
|
|
2368
|
+
role: "assistant",
|
|
2369
|
+
content: [{ type: "text", text: "stream broke mid-response" }],
|
|
2370
|
+
model: "openai/gpt-5-mini",
|
|
2371
|
+
errorMessage: "Stream ended without finish_reason",
|
|
2372
|
+
usage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
|
|
2373
|
+
},
|
|
2374
|
+
}],
|
|
2375
|
+
exitCode: 1,
|
|
2376
|
+
});
|
|
2377
|
+
mockPi.onCall({ output: "Recovered after stream failure" });
|
|
2378
|
+
const id = `async-fallback-stream-${Date.now().toString(36)}`;
|
|
2379
|
+
executeAsyncSingle(id, {
|
|
2380
|
+
agent: "worker",
|
|
2381
|
+
task: "Do work",
|
|
2382
|
+
agentConfig: makeAgent("worker", {
|
|
2383
|
+
model: "openai/gpt-5-mini:high",
|
|
2384
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
2385
|
+
}),
|
|
2386
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2387
|
+
availableModels: [
|
|
2388
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
2389
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
2390
|
+
],
|
|
2391
|
+
artifactConfig: {
|
|
2392
|
+
enabled: false,
|
|
2393
|
+
includeInput: false,
|
|
2394
|
+
includeOutput: false,
|
|
2395
|
+
includeJsonl: false,
|
|
2396
|
+
includeMetadata: false,
|
|
2397
|
+
cleanupDays: 7,
|
|
2398
|
+
},
|
|
2399
|
+
shareEnabled: false,
|
|
2400
|
+
maxSubagentDepth: 2,
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
2404
|
+
assert.equal(payload.success, true);
|
|
2405
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
|
|
2406
|
+
assert.match(payload.results[0].output ?? "", /Recovered after stream failure/);
|
|
2407
|
+
assert.equal(mockPi.callCount(), 2);
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
it("background runs do not retry the fallback model for a trailing tool failure", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2411
|
+
mockPi.onCall({
|
|
2412
|
+
jsonl: [
|
|
2413
|
+
mockAssistantMessage("checking connectivity", "tool_use"),
|
|
2414
|
+
events.toolResult("bash", "curl: (28) Connection timed out after 5000 ms\nCommand exited with code 1", true),
|
|
2415
|
+
],
|
|
2416
|
+
exitCode: 0,
|
|
2417
|
+
});
|
|
2418
|
+
mockPi.onCall({ output: "fallback must not run" });
|
|
2419
|
+
const id = `async-fallback-toolfail-${Date.now().toString(36)}`;
|
|
2420
|
+
executeAsyncSingle(id, {
|
|
2421
|
+
agent: "worker",
|
|
2422
|
+
task: "Do work",
|
|
2423
|
+
agentConfig: makeAgent("worker", {
|
|
2424
|
+
model: "openai/gpt-5-mini:high",
|
|
2425
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
2426
|
+
}),
|
|
2427
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2428
|
+
availableModels: [
|
|
2429
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
2430
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
2431
|
+
],
|
|
2432
|
+
artifactConfig: {
|
|
2433
|
+
enabled: false,
|
|
2434
|
+
includeInput: false,
|
|
2435
|
+
includeOutput: false,
|
|
2436
|
+
includeJsonl: false,
|
|
2437
|
+
includeMetadata: false,
|
|
2438
|
+
cleanupDays: 7,
|
|
2439
|
+
},
|
|
2440
|
+
shareEnabled: false,
|
|
2441
|
+
maxSubagentDepth: 2,
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
2445
|
+
assert.equal(payload.success, false);
|
|
2446
|
+
assert.equal(payload.results[0].modelAttempts.length, 1);
|
|
2447
|
+
assert.match(payload.results[0].error ?? "", /^bash failed \(exit 1\)/);
|
|
2448
|
+
assert.match(payload.results[0].error ?? "", /timed out/i);
|
|
2449
|
+
assert.equal(mockPi.callCount(), 1);
|
|
2450
|
+
});
|
|
2451
|
+
|
|
1388
2452
|
it("background single thinking override replaces primary and fallback suffixes", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1389
2453
|
mockPi.onCall({
|
|
1390
2454
|
jsonl: [{
|
|
@@ -1486,6 +2550,54 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1486
2550
|
assert.equal(mockPi.callCount(), 2);
|
|
1487
2551
|
});
|
|
1488
2552
|
|
|
2553
|
+
it("background runs prefer empty-output fallback over an earlier tool error", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2554
|
+
mockPi.onCall({
|
|
2555
|
+
jsonl: [
|
|
2556
|
+
events.toolResult("read", "ENOENT: no such file or directory", true),
|
|
2557
|
+
events.toolResult("read", "recovered file contents"),
|
|
2558
|
+
{
|
|
2559
|
+
type: "message_end",
|
|
2560
|
+
message: {
|
|
2561
|
+
role: "assistant",
|
|
2562
|
+
content: [],
|
|
2563
|
+
model: "openai/gpt-5-mini",
|
|
2564
|
+
stopReason: "stop",
|
|
2565
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
2566
|
+
},
|
|
2567
|
+
},
|
|
2568
|
+
],
|
|
2569
|
+
exitCode: 0,
|
|
2570
|
+
});
|
|
2571
|
+
mockPi.onCall({ output: "Recovered asynchronously on fallback" });
|
|
2572
|
+
const id = `async-empty-output-after-tool-error-${Date.now().toString(36)}`;
|
|
2573
|
+
executeAsyncSingle(id, {
|
|
2574
|
+
agent: "worker",
|
|
2575
|
+
task: "Do work",
|
|
2576
|
+
agentConfig: makeAgent("worker", {
|
|
2577
|
+
model: "openai/gpt-5-mini",
|
|
2578
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
2579
|
+
}),
|
|
2580
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2581
|
+
artifactConfig: {
|
|
2582
|
+
enabled: false,
|
|
2583
|
+
includeInput: false,
|
|
2584
|
+
includeOutput: false,
|
|
2585
|
+
includeJsonl: false,
|
|
2586
|
+
includeMetadata: false,
|
|
2587
|
+
cleanupDays: 7,
|
|
2588
|
+
},
|
|
2589
|
+
shareEnabled: false,
|
|
2590
|
+
maxSubagentDepth: 2,
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2593
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
2594
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
2595
|
+
assert.equal(payload.success, true);
|
|
2596
|
+
assert.equal(payload.results[0]?.model, "anthropic/claude-sonnet-4");
|
|
2597
|
+
assert.match(payload.results[0]?.modelAttempts?.[0]?.error ?? "", /no output/i);
|
|
2598
|
+
assert.equal(mockPi.callCount(), 2);
|
|
2599
|
+
});
|
|
2600
|
+
|
|
1489
2601
|
it("background runs fail zero-exit provider errors when no fallback succeeds", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1490
2602
|
mockPi.onCall({
|
|
1491
2603
|
jsonl: [{
|
|
@@ -1635,9 +2747,9 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1635
2747
|
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
1636
2748
|
const outputPath = path.join(tempDir, "async-file-only.md");
|
|
1637
2749
|
const run = executeAsyncSingle(id, {
|
|
1638
|
-
agent: "
|
|
1639
|
-
task: "
|
|
1640
|
-
agentConfig: makeAgent("
|
|
2750
|
+
agent: "analyst",
|
|
2751
|
+
task: "Analyze without modifying files",
|
|
2752
|
+
agentConfig: makeAgent("analyst", { tools: ["read", "grep", "find", "ls"] }),
|
|
1641
2753
|
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1642
2754
|
artifactConfig: {
|
|
1643
2755
|
enabled: false,
|
|
@@ -1655,6 +2767,15 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1655
2767
|
});
|
|
1656
2768
|
|
|
1657
2769
|
assert.equal(run.details.asyncId, id);
|
|
2770
|
+
const call = await waitForMockPiCall(mockPi, 0);
|
|
2771
|
+
const taskArg = call.args.at(-1) ?? "";
|
|
2772
|
+
const systemPrompt = call.systemPrompts[0]?.text ?? "";
|
|
2773
|
+
for (const instruction of [taskArg, systemPrompt]) {
|
|
2774
|
+
assert.match(instruction, /Return the complete artifact in your final response\./);
|
|
2775
|
+
assert.match(instruction, /runtime will persist it to exactly this path:/);
|
|
2776
|
+
assert.match(instruction, /Do not call contact_supervisor merely because no write-capable tool is available\./);
|
|
2777
|
+
assert.doesNotMatch(instruction, /Write your findings to exactly this path/);
|
|
2778
|
+
}
|
|
1658
2779
|
const deadline = Date.now() + 10_000;
|
|
1659
2780
|
while (!fs.existsSync(resultPath)) {
|
|
1660
2781
|
if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
@@ -1867,6 +2988,77 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
1867
2988
|
assert.doesNotMatch(eventsText, /Interrupt:/);
|
|
1868
2989
|
});
|
|
1869
2990
|
|
|
2991
|
+
it("agent contract v1 keeps async acceptance and file-mutation effects separate from execution", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2992
|
+
mockPi.onCall({ output: "I’ll do that now and report back after implementing.\n```acceptance-report\n{\"criteriaSatisfied\":[{\"id\":\"criterion-1\",\"status\":\"not-satisfied\",\"evidence\":\"no proof\"}]}\n```" });
|
|
2993
|
+
const id = `async-v1-separate-${Date.now().toString(36)}`;
|
|
2994
|
+
|
|
2995
|
+
executeAsyncSingle(id, {
|
|
2996
|
+
agent: "worker",
|
|
2997
|
+
task: "Implement the approved fixes",
|
|
2998
|
+
agentConfig: makeAgent("worker", { completionGuard: true }),
|
|
2999
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
3000
|
+
artifactConfig: {
|
|
3001
|
+
enabled: false,
|
|
3002
|
+
includeInput: false,
|
|
3003
|
+
includeOutput: false,
|
|
3004
|
+
includeJsonl: false,
|
|
3005
|
+
includeMetadata: false,
|
|
3006
|
+
cleanupDays: 7,
|
|
3007
|
+
},
|
|
3008
|
+
shareEnabled: false,
|
|
3009
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
3010
|
+
maxSubagentDepth: 2,
|
|
3011
|
+
agentContract: { version: 1 },
|
|
3012
|
+
acceptance: { level: "checked", criteria: ["Return required proof"] },
|
|
3013
|
+
});
|
|
3014
|
+
|
|
3015
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
3016
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
3017
|
+
const statusPayload = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
3018
|
+
|
|
3019
|
+
assert.equal(payload.success, true);
|
|
3020
|
+
assert.equal(payload.state, "complete");
|
|
3021
|
+
assert.equal(payload.exitCode, 0);
|
|
3022
|
+
assert.equal(payload.results[0]?.agentContract?.version, 1);
|
|
3023
|
+
assert.equal(payload.results[0]?.execution?.status, "completed");
|
|
3024
|
+
assert.equal(payload.results[0]?.execution?.success, true);
|
|
3025
|
+
assert.equal(payload.results[0]?.acceptance?.status, "rejected");
|
|
3026
|
+
assert.equal(payload.results[0]?.effects?.fileMutation?.status, "missing");
|
|
3027
|
+
assert.equal(statusPayload.state, "complete");
|
|
3028
|
+
assert.equal(statusPayload.steps?.[0]?.agentContract?.version, 1);
|
|
3029
|
+
assert.equal(statusPayload.steps?.[0]?.execution?.status, "completed");
|
|
3030
|
+
assert.equal(statusPayload.steps?.[0]?.effects?.fileMutation?.status, "missing");
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
it("background single runs support outputSchema", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3034
|
+
mockPi.onCall({ output: "structured", structuredOutput: { ok: true, note: "async" } });
|
|
3035
|
+
const id = `async-single-schema-${Date.now().toString(36)}`;
|
|
3036
|
+
|
|
3037
|
+
executeAsyncSingle(id, {
|
|
3038
|
+
agent: "worker",
|
|
3039
|
+
task: "Return structured data",
|
|
3040
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
3041
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
3042
|
+
artifactConfig: {
|
|
3043
|
+
enabled: false,
|
|
3044
|
+
includeInput: false,
|
|
3045
|
+
includeOutput: false,
|
|
3046
|
+
includeJsonl: false,
|
|
3047
|
+
includeMetadata: false,
|
|
3048
|
+
cleanupDays: 7,
|
|
3049
|
+
},
|
|
3050
|
+
shareEnabled: false,
|
|
3051
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
3052
|
+
maxSubagentDepth: 2,
|
|
3053
|
+
acceptance: false,
|
|
3054
|
+
structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } },
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id, 10_000), "utf-8")) as AsyncResultPayload;
|
|
3058
|
+
assert.equal(payload.success, true);
|
|
3059
|
+
assert.deepEqual(payload.results[0]?.structuredOutput, { ok: true, note: "async" });
|
|
3060
|
+
});
|
|
3061
|
+
|
|
1870
3062
|
it("background bash-enabled non-implementation agents can opt out of the completion guard", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1871
3063
|
mockPi.onCall({ output: "cold start test after patch" });
|
|
1872
3064
|
|
|
@@ -2024,6 +3216,94 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
2024
3216
|
assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
|
|
2025
3217
|
});
|
|
2026
3218
|
|
|
3219
|
+
it("background chains treat empty step models as parent inheritance", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3220
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
3221
|
+
|
|
3222
|
+
const id = `async-chain-empty-model-${Date.now().toString(36)}`;
|
|
3223
|
+
executeAsyncChain(id, {
|
|
3224
|
+
chain: [{ agent: "worker", task: "Do work", model: "" }],
|
|
3225
|
+
agents: [makeAgent("worker", { model: "anthropic/claude-sonnet-4-5", thinking: "high" })],
|
|
3226
|
+
ctx: {
|
|
3227
|
+
pi: { events: { emit() {} } },
|
|
3228
|
+
cwd: tempDir,
|
|
3229
|
+
currentSessionId: "session-1",
|
|
3230
|
+
currentModelProvider: "openai",
|
|
3231
|
+
currentModel: { provider: "openai", id: "gpt-5-mini" },
|
|
3232
|
+
},
|
|
3233
|
+
availableModels: [
|
|
3234
|
+
{ provider: "anthropic", id: "claude-sonnet-4-5", fullId: "anthropic/claude-sonnet-4-5", api: "anthropic-messages" },
|
|
3235
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini", api: "openai-responses" },
|
|
3236
|
+
],
|
|
3237
|
+
artifactConfig: {
|
|
3238
|
+
enabled: false,
|
|
3239
|
+
includeInput: false,
|
|
3240
|
+
includeOutput: false,
|
|
3241
|
+
includeJsonl: false,
|
|
3242
|
+
includeMetadata: false,
|
|
3243
|
+
cleanupDays: 7,
|
|
3244
|
+
},
|
|
3245
|
+
shareEnabled: false,
|
|
3246
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
3247
|
+
maxSubagentDepth: 2,
|
|
3248
|
+
});
|
|
3249
|
+
|
|
3250
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
3251
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
3252
|
+
assert.equal(payload.success, true);
|
|
3253
|
+
assert.equal(payload.results[0].model, "openai/gpt-5-mini:high");
|
|
3254
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high"]);
|
|
3255
|
+
const args = readMockPiArgs(mockPi, 0);
|
|
3256
|
+
assert.equal(args[args.indexOf("--model") + 1], "openai/gpt-5-mini:high");
|
|
3257
|
+
});
|
|
3258
|
+
|
|
3259
|
+
it("background chains keep agent fallback models inherited for scope warnings", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3260
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
3261
|
+
const warnings: string[] = [];
|
|
3262
|
+
const originalWarn = console.warn;
|
|
3263
|
+
console.warn = (message?: unknown) => warnings.push(String(message));
|
|
3264
|
+
try {
|
|
3265
|
+
for (const [index, requestedModel] of [undefined, "", "inherit"].entries()) {
|
|
3266
|
+
const id = `async-chain-no-parent-${index}-${Date.now().toString(36)}`;
|
|
3267
|
+
executeAsyncChain(id, {
|
|
3268
|
+
chain: [{ agent: "worker", task: "Do work", ...(requestedModel !== undefined ? { model: requestedModel } : {}) }],
|
|
3269
|
+
agents: [makeAgent("worker", { model: "openai/gpt-5-mini", thinking: "high" })],
|
|
3270
|
+
ctx: {
|
|
3271
|
+
pi: { events: { emit() {} } },
|
|
3272
|
+
cwd: tempDir,
|
|
3273
|
+
currentSessionId: "session-1",
|
|
3274
|
+
modelScope: { enforce: true, allow: ["anthropic/*"] },
|
|
3275
|
+
},
|
|
3276
|
+
availableModels: [
|
|
3277
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini", api: "openai-responses" },
|
|
3278
|
+
],
|
|
3279
|
+
artifactConfig: {
|
|
3280
|
+
enabled: false,
|
|
3281
|
+
includeInput: false,
|
|
3282
|
+
includeOutput: false,
|
|
3283
|
+
includeJsonl: false,
|
|
3284
|
+
includeMetadata: false,
|
|
3285
|
+
cleanupDays: 7,
|
|
3286
|
+
},
|
|
3287
|
+
shareEnabled: false,
|
|
3288
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
3289
|
+
maxSubagentDepth: 2,
|
|
3290
|
+
});
|
|
3291
|
+
|
|
3292
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
3293
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
3294
|
+
assert.equal(payload.success, true);
|
|
3295
|
+
assert.equal(payload.results[0].model, "openai/gpt-5-mini:high");
|
|
3296
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high"]);
|
|
3297
|
+
const args = readMockPiArgs(mockPi, index);
|
|
3298
|
+
assert.equal(args[args.indexOf("--model") + 1], "openai/gpt-5-mini:high");
|
|
3299
|
+
}
|
|
3300
|
+
assert.equal(warnings.length, 3);
|
|
3301
|
+
assert.equal(warnings.every((warning) => warning.includes("outside the configured subagent model scope")), true);
|
|
3302
|
+
} finally {
|
|
3303
|
+
console.warn = originalWarn;
|
|
3304
|
+
}
|
|
3305
|
+
});
|
|
3306
|
+
|
|
2027
3307
|
it("background runs resolve skills from the effective task cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2028
3308
|
mockPi.onCall({ output: "Done asynchronously" });
|
|
2029
3309
|
const taskCwd = createTempDir("pi-subagent-async-task-cwd-");
|
|
@@ -2070,6 +3350,60 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
2070
3350
|
}
|
|
2071
3351
|
});
|
|
2072
3352
|
|
|
3353
|
+
it("injects agent-file-relative local skills into background single child prompts", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3354
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
3355
|
+
const id = `async-local-skill-${Date.now().toString(36)}`;
|
|
3356
|
+
const agentFile = path.join(tempDir, "agents", "worker", "worker.md");
|
|
3357
|
+
const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
|
|
3358
|
+
fs.mkdirSync(path.dirname(skillFile), { recursive: true });
|
|
3359
|
+
fs.writeFileSync(skillFile, "---\ndescription: async local skill\n---\nLocal skill body\n", "utf-8");
|
|
3360
|
+
|
|
3361
|
+
executeAsyncSingle(id, {
|
|
3362
|
+
agent: "worker",
|
|
3363
|
+
task: "Do work",
|
|
3364
|
+
agentConfig: makeAgent("worker", { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] }),
|
|
3365
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
3366
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
3367
|
+
shareEnabled: false,
|
|
3368
|
+
maxSubagentDepth: 2,
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
await waitForAsyncResultFile(id);
|
|
3372
|
+
const call = await waitForMockPiCall(mockPi, 0);
|
|
3373
|
+
assert.match(call.systemPrompts.map((record) => record.text ?? "").join("\n"), /async local skill/);
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
it("isolates agent-local skills between background parallel chain children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3377
|
+
mockPi.onCall({ output: "one" });
|
|
3378
|
+
mockPi.onCall({ output: "two" });
|
|
3379
|
+
const id = `async-parallel-local-skills-${Date.now().toString(36)}`;
|
|
3380
|
+
const agents = ["one", "two"].map((name) => {
|
|
3381
|
+
const agentFile = path.join(tempDir, "agents", name, `${name}.md`);
|
|
3382
|
+
const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
|
|
3383
|
+
fs.mkdirSync(path.dirname(skillFile), { recursive: true });
|
|
3384
|
+
fs.writeFileSync(skillFile, `---\ndescription: ${name} async local skill\n---\nbody\n`, "utf-8");
|
|
3385
|
+
return makeAgent(name, { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] });
|
|
3386
|
+
});
|
|
3387
|
+
|
|
3388
|
+
executeAsyncChain(id, {
|
|
3389
|
+
chain: [{ parallel: [{ agent: "one", task: "One" }, { agent: "two", task: "Two" }], concurrency: 2 }],
|
|
3390
|
+
resultMode: "parallel",
|
|
3391
|
+
agents,
|
|
3392
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
3393
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
3394
|
+
shareEnabled: false,
|
|
3395
|
+
maxSubagentDepth: 2,
|
|
3396
|
+
});
|
|
3397
|
+
|
|
3398
|
+
await waitForAsyncResultFile(id);
|
|
3399
|
+
const prompts = await Promise.all([0, 1].map(async (index) => {
|
|
3400
|
+
const call = await waitForMockPiCall(mockPi, index);
|
|
3401
|
+
return call.systemPrompts.map((record) => record.text ?? "").join("\n");
|
|
3402
|
+
}));
|
|
3403
|
+
assert.equal(prompts.filter((prompt) => /one async local skill/.test(prompt) && !/two async local skill/.test(prompt)).length, 1);
|
|
3404
|
+
assert.equal(prompts.filter((prompt) => /two async local skill/.test(prompt) && !/one async local skill/.test(prompt)).length, 1);
|
|
3405
|
+
});
|
|
3406
|
+
|
|
2073
3407
|
it("background single runs report unavailable pi-subagents skill requests", () => {
|
|
2074
3408
|
const id = `async-pi-subagents-skill-${Date.now().toString(36)}`;
|
|
2075
3409
|
const result = executeAsyncSingle(id, {
|
|
@@ -2370,6 +3704,104 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
2370
3704
|
assert.match(result.content[0]?.text ?? "", /async-cfg-/);
|
|
2371
3705
|
});
|
|
2372
3706
|
|
|
3707
|
+
it("background ignores child watchdog status when child watchdogs are not configured", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3708
|
+
await withIsolatedWatchdogSettings(tempDir, async () => {
|
|
3709
|
+
const id = `async-watchdog-unconfigured-${Date.now().toString(36)}`;
|
|
3710
|
+
mockPi.onCall({
|
|
3711
|
+
jsonl: [events.assistantMessage("async-done-without-watchdog-config"), childWatchdogStatus(id, "reviewing", 1)],
|
|
3712
|
+
keepAliveAfterFinalMessageMs: 10000,
|
|
3713
|
+
});
|
|
3714
|
+
|
|
3715
|
+
const start = Date.now();
|
|
3716
|
+
executeAsyncSingle(id, {
|
|
3717
|
+
agent: "worker",
|
|
3718
|
+
task: "Do work",
|
|
3719
|
+
agentConfig: makeAgent("worker"),
|
|
3720
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
3721
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
3722
|
+
shareEnabled: false,
|
|
3723
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
3724
|
+
maxSubagentDepth: 2,
|
|
3725
|
+
});
|
|
3726
|
+
|
|
3727
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
3728
|
+
const elapsed = Date.now() - start;
|
|
3729
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
3730
|
+
assert.ok(elapsed < 6000, `unconfigured watchdog status should not delay async final drain, took ${elapsed}ms`);
|
|
3731
|
+
assert.equal(payload.success, true);
|
|
3732
|
+
assert.equal(payload.results[0]?.output, "async-done-without-watchdog-config");
|
|
3733
|
+
assert.equal((payload.results[0] as { watchdog?: unknown }).watchdog, undefined);
|
|
3734
|
+
});
|
|
3735
|
+
});
|
|
3736
|
+
|
|
3737
|
+
it("background final-drain waits for child watchdog settlement", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3738
|
+
await withIsolatedWatchdogSettings(tempDir, async () => {
|
|
3739
|
+
writeWatchdogSettings(tempDir);
|
|
3740
|
+
const id = `async-watchdog-drain-${Date.now().toString(36)}`;
|
|
3741
|
+
mockPi.onCall({
|
|
3742
|
+
steps: [
|
|
3743
|
+
{ jsonl: [events.assistantMessage("async-done-before-watchdog"), childWatchdogStatus(id, "reviewing", 1)] },
|
|
3744
|
+
{ delay: 1400, jsonl: [childWatchdogStatus(id, "idle", 2)] },
|
|
3745
|
+
],
|
|
3746
|
+
keepAliveAfterFinalMessageMs: 10000,
|
|
3747
|
+
});
|
|
3748
|
+
|
|
3749
|
+
const start = Date.now();
|
|
3750
|
+
executeAsyncSingle(id, {
|
|
3751
|
+
agent: "worker",
|
|
3752
|
+
task: "Do work",
|
|
3753
|
+
agentConfig: makeAgent("worker"),
|
|
3754
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
3755
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
3756
|
+
shareEnabled: false,
|
|
3757
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
3758
|
+
maxSubagentDepth: 2,
|
|
3759
|
+
});
|
|
3760
|
+
|
|
3761
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
3762
|
+
const elapsed = Date.now() - start;
|
|
3763
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
3764
|
+
assert.ok(elapsed >= 1200, `watchdog settlement should delay async final drain, took ${elapsed}ms`);
|
|
3765
|
+
assert.ok(elapsed < 9000, `settled watchdog should still allow async cleanup, took ${elapsed}ms`);
|
|
3766
|
+
assert.equal(payload.success, true);
|
|
3767
|
+
assert.equal(payload.results[0]?.output, "async-done-before-watchdog");
|
|
3768
|
+
assert.equal((payload.results[0] as { watchdog?: { phase?: string } }).watchdog?.phase, "idle");
|
|
3769
|
+
});
|
|
3770
|
+
});
|
|
3771
|
+
|
|
3772
|
+
it("background child watchdog tail timeout still finalizes successful output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
3773
|
+
await withIsolatedWatchdogSettings(tempDir, async () => {
|
|
3774
|
+
writeWatchdogSettings(tempDir, 150);
|
|
3775
|
+
const id = `async-watchdog-timeout-${Date.now().toString(36)}`;
|
|
3776
|
+
mockPi.onCall({
|
|
3777
|
+
jsonl: [events.assistantMessage("async-done-before-watchdog-timeout"), childWatchdogStatus(id, "reviewing", 1)],
|
|
3778
|
+
keepAliveAfterFinalMessageMs: 10000,
|
|
3779
|
+
});
|
|
3780
|
+
|
|
3781
|
+
const start = Date.now();
|
|
3782
|
+
executeAsyncSingle(id, {
|
|
3783
|
+
agent: "worker",
|
|
3784
|
+
task: "Do work",
|
|
3785
|
+
agentConfig: makeAgent("worker"),
|
|
3786
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
3787
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
3788
|
+
shareEnabled: false,
|
|
3789
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
3790
|
+
maxSubagentDepth: 2,
|
|
3791
|
+
});
|
|
3792
|
+
|
|
3793
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
3794
|
+
const elapsed = Date.now() - start;
|
|
3795
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
3796
|
+
assert.ok(elapsed < 6000, `watchdog tail fallback should not hang async final drain, took ${elapsed}ms`);
|
|
3797
|
+
assert.equal(payload.success, true);
|
|
3798
|
+
assert.equal(payload.results[0]?.output, "async-done-before-watchdog-timeout");
|
|
3799
|
+
const watchdog = (payload.results[0] as { watchdog?: { phase?: string; timedOut?: boolean } }).watchdog;
|
|
3800
|
+
assert.equal(watchdog?.phase, "stale");
|
|
3801
|
+
assert.equal(watchdog?.timedOut, true);
|
|
3802
|
+
});
|
|
3803
|
+
});
|
|
3804
|
+
|
|
2373
3805
|
it("background forced drain after final assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2374
3806
|
mockPi.onCall({
|
|
2375
3807
|
jsonl: [events.assistantMessage("async-done-before-drain")],
|
|
@@ -2564,6 +3996,145 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
|
|
|
2564
3996
|
}
|
|
2565
3997
|
});
|
|
2566
3998
|
|
|
3999
|
+
it("does not flag a delayed active tool as idle attention", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
4000
|
+
mockPi.onCall({
|
|
4001
|
+
steps: [
|
|
4002
|
+
{ jsonl: [events.toolStart("bash", { command: "sleep 2" })] },
|
|
4003
|
+
{ delay: 2_500, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "done")] },
|
|
4004
|
+
{ jsonl: [events.assistantMessage("Done")] },
|
|
4005
|
+
],
|
|
4006
|
+
});
|
|
4007
|
+
|
|
4008
|
+
const id = `async-delayed-tool-attention-${Date.now().toString(36)}`;
|
|
4009
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
4010
|
+
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
4011
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
4012
|
+
executeAsyncSingle(id, {
|
|
4013
|
+
agent: "worker",
|
|
4014
|
+
task: "Run the command",
|
|
4015
|
+
agentConfig: makeAgent("worker"),
|
|
4016
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
4017
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
4018
|
+
shareEnabled: false,
|
|
4019
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
4020
|
+
maxSubagentDepth: 2,
|
|
4021
|
+
controlConfig: {
|
|
4022
|
+
enabled: true,
|
|
4023
|
+
needsAttentionAfterMs: 200,
|
|
4024
|
+
activeNoticeAfterMs: 999_999,
|
|
4025
|
+
failedToolAttemptsBeforeAttention: 3,
|
|
4026
|
+
notifyOn: ["active_long_running", "needs_attention"],
|
|
4027
|
+
notifyChannels: ["event", "async", "intercom"],
|
|
4028
|
+
},
|
|
4029
|
+
});
|
|
4030
|
+
|
|
4031
|
+
const deadline = Date.now() + 10_000;
|
|
4032
|
+
let statusDuringTool: AsyncStatusPayload | undefined;
|
|
4033
|
+
while (Date.now() < deadline && !fs.existsSync(resultPath)) {
|
|
4034
|
+
if (fs.existsSync(asyncDir) && fs.existsSync(path.join(asyncDir, "status.json"))) {
|
|
4035
|
+
const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
4036
|
+
const toolStartedAt = status.steps?.[0]?.currentToolStartedAt;
|
|
4037
|
+
if (status.currentTool === "bash" && status.steps?.[0]?.currentTool === "bash" && toolStartedAt && Date.now() - toolStartedAt >= 1_500) {
|
|
4038
|
+
statusDuringTool = status;
|
|
4039
|
+
break;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
4043
|
+
}
|
|
4044
|
+
assert.ok(statusDuringTool, "expected status.json to expose the active tool");
|
|
4045
|
+
assert.equal(statusDuringTool?.activityState, undefined);
|
|
4046
|
+
assert.equal(statusDuringTool?.steps?.[0]?.activityState, undefined);
|
|
4047
|
+
const eventText = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, "utf-8") : "";
|
|
4048
|
+
assert.doesNotMatch(eventText, /"type":"needs_attention"/);
|
|
4049
|
+
await waitForAsyncResultFile(id);
|
|
4050
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
4051
|
+
assert.equal(payload.success, true);
|
|
4052
|
+
});
|
|
4053
|
+
|
|
4054
|
+
it("subagent_wait wakes when an async child is waiting on contact_supervisor", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
4055
|
+
mockPi.onCall({
|
|
4056
|
+
steps: [
|
|
4057
|
+
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
4058
|
+
{ delay: 5_000, jsonl: [events.toolEnd("contact_supervisor"), events.toolResult("contact_supervisor", "**Reply from supervisor:**\nProceed")] },
|
|
4059
|
+
{ delay: 2_500, jsonl: [events.assistantMessage("Done")] },
|
|
4060
|
+
],
|
|
4061
|
+
});
|
|
4062
|
+
|
|
4063
|
+
const id = `async-supervisor-attention-${Date.now().toString(36)}`;
|
|
4064
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
4065
|
+
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
4066
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
4067
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
4068
|
+
executeAsyncSingle(id, {
|
|
4069
|
+
agent: "worker",
|
|
4070
|
+
task: "Ask the supervisor for a blocking decision",
|
|
4071
|
+
agentConfig: makeAgent("worker"),
|
|
4072
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
4073
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
4074
|
+
shareEnabled: false,
|
|
4075
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
4076
|
+
maxSubagentDepth: 2,
|
|
4077
|
+
controlConfig: {
|
|
4078
|
+
enabled: true,
|
|
4079
|
+
needsAttentionAfterMs: 999_999,
|
|
4080
|
+
activeNoticeAfterMs: 999_999,
|
|
4081
|
+
failedToolAttemptsBeforeAttention: 3,
|
|
4082
|
+
notifyOn: ["active_long_running", "needs_attention"],
|
|
4083
|
+
notifyChannels: ["event", "async", "intercom"],
|
|
4084
|
+
},
|
|
4085
|
+
});
|
|
4086
|
+
|
|
4087
|
+
const attentionDeadline = Date.now() + 10_000;
|
|
4088
|
+
let statusDuringAttention: AsyncStatusPayload | undefined;
|
|
4089
|
+
while (Date.now() < attentionDeadline && !fs.existsSync(resultPath)) {
|
|
4090
|
+
if (fs.existsSync(statusPath)) {
|
|
4091
|
+
const nextStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
4092
|
+
if (nextStatus.currentTool === "contact_supervisor" && nextStatus.activityState === "needs_attention") {
|
|
4093
|
+
statusDuringAttention = nextStatus;
|
|
4094
|
+
break;
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
4098
|
+
}
|
|
4099
|
+
assert.ok(statusDuringAttention, "expected status.json to expose the blocking supervisor request");
|
|
4100
|
+
|
|
4101
|
+
const waitResult = await waitForSubagents({ id, timeoutMs: 3_500 }, undefined, {
|
|
4102
|
+
state: { currentSessionId: "session-1", foregroundRuns: new Map(), asyncJobs: new Map(), cleanupTimers: new Map(), resultFileCoalescer: new Map() },
|
|
4103
|
+
pollIntervalMs: 100,
|
|
4104
|
+
events: createEventBus(),
|
|
4105
|
+
});
|
|
4106
|
+
const waitText = waitResult.content[0]?.text ?? "";
|
|
4107
|
+
assert.equal(waitResult.isError, undefined);
|
|
4108
|
+
assert.match(waitText, /attention required/i);
|
|
4109
|
+
assert.match(waitText, new RegExp(id));
|
|
4110
|
+
assert.match(waitText, /intercom\(\{ action: "pending" \}\)/);
|
|
4111
|
+
assert.equal(fs.existsSync(resultPath), false, "wait should return before the child completes");
|
|
4112
|
+
|
|
4113
|
+
const eventText = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, "utf-8") : "";
|
|
4114
|
+
assert.match(eventText, /"type":"needs_attention"/);
|
|
4115
|
+
assert.match(eventText, /"reason":"supervisor_request"/);
|
|
4116
|
+
assert.equal(statusDuringAttention.activityState, "needs_attention");
|
|
4117
|
+
assert.equal(statusDuringAttention.steps?.[0]?.activityState, "needs_attention");
|
|
4118
|
+
assert.equal(statusDuringAttention.currentTool, "contact_supervisor");
|
|
4119
|
+
assert.equal(statusDuringAttention.steps?.[0]?.currentTool, "contact_supervisor");
|
|
4120
|
+
|
|
4121
|
+
const clearDeadline = Date.now() + 10_000;
|
|
4122
|
+
let statusAfterReply: AsyncStatusPayload | undefined;
|
|
4123
|
+
while (Date.now() < clearDeadline && !fs.existsSync(resultPath)) {
|
|
4124
|
+
const nextStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
4125
|
+
if (nextStatus.state === "running" && !nextStatus.currentTool && !nextStatus.steps?.[0]?.currentTool) {
|
|
4126
|
+
statusAfterReply = nextStatus;
|
|
4127
|
+
break;
|
|
4128
|
+
}
|
|
4129
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
4130
|
+
}
|
|
4131
|
+
assert.ok(statusAfterReply, "expected the child to keep running after the supervisor reply");
|
|
4132
|
+
assert.equal(statusAfterReply.activityState, undefined);
|
|
4133
|
+
assert.equal(statusAfterReply.steps?.[0]?.activityState, undefined);
|
|
4134
|
+
|
|
4135
|
+
await waitForAsyncResultFile(id);
|
|
4136
|
+
});
|
|
4137
|
+
|
|
2567
4138
|
it("background runs escalate repeated mutating tool failures", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2568
4139
|
mockPi.onCall({
|
|
2569
4140
|
steps: [
|