@selesai/code 0.13.15 → 0.13.18
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/CHANGELOG.md +37 -0
- package/README.md +2 -14
- package/dist/bun/cli.d.ts +3 -1
- package/dist/bun/cli.js +3 -9
- package/dist/bun/{register-bedrock.js → runtime-setup.js} +5 -0
- package/dist/bun/sandbox-env-setup.d.ts +1 -0
- package/dist/bun/sandbox-env-setup.js +3 -0
- package/dist/cli/auth-command.d.ts +23 -0
- package/dist/cli/auth-command.js +102 -0
- package/dist/cli/config-selector.js +2 -1
- package/dist/cli/setup.d.ts +1 -0
- package/dist/cli/setup.js +11 -0
- package/dist/cli.js +2 -14
- package/dist/config.d.ts +3 -2
- package/dist/config.js +9 -4
- package/dist/core/agent-session-runtime.js +15 -7
- package/dist/core/agent-session.d.ts +2 -6
- package/dist/core/agent-session.js +15 -4
- package/dist/core/compaction/branch-summarization.d.ts +1 -1
- package/dist/core/compaction/branch-summarization.js +2 -1
- package/dist/core/extensions/loader.js +3 -0
- package/dist/core/extensions/types.d.ts +16 -2
- package/dist/core/http-dispatcher.js +2 -0
- package/dist/core/keybindings.d.ts +5 -0
- package/dist/core/keybindings.js +4 -0
- package/dist/core/messages.d.ts +1 -1
- package/dist/core/model-config.d.ts +15 -0
- package/dist/core/model-config.js +3 -0
- package/dist/core/model-runtime.d.ts +1 -0
- package/dist/core/model-runtime.js +5 -2
- package/dist/core/provider-composer.js +8 -2
- package/dist/core/session-export.d.ts +3 -0
- package/dist/core/session-export.js +31 -0
- package/dist/core/session-manager.d.ts +3 -2
- package/dist/core/session-manager.js +40 -13
- package/dist/core/skills.d.ts +1 -1
- package/dist/core/skills.js +4 -2
- package/dist/core/system-prompt.js +10 -10
- package/dist/core/tools/bash.js +3 -117
- package/dist/core/tools/edit.d.ts +1 -12
- package/dist/core/tools/edit.js +5 -163
- package/dist/core/tools/find.js +4 -55
- package/dist/core/tools/grep.js +4 -60
- package/dist/core/tools/ls.js +4 -49
- package/dist/core/tools/read.d.ts +4 -0
- package/dist/core/tools/read.js +10 -122
- package/dist/core/tools/renderers/bash.d.ts +11 -0
- package/dist/core/tools/renderers/bash.js +129 -0
- package/dist/core/tools/renderers/edit.d.ts +22 -0
- package/dist/core/tools/renderers/edit.js +166 -0
- package/dist/core/tools/renderers/find.d.ts +9 -0
- package/dist/core/tools/renderers/find.js +63 -0
- package/dist/core/tools/renderers/grep.d.ts +9 -0
- package/dist/core/tools/renderers/grep.js +68 -0
- package/dist/core/tools/renderers/index.d.ts +27 -0
- package/dist/core/tools/renderers/index.js +46 -0
- package/dist/core/tools/renderers/ls.d.ts +9 -0
- package/dist/core/tools/renderers/ls.js +57 -0
- package/dist/core/tools/renderers/read.d.ts +10 -0
- package/dist/core/tools/renderers/read.js +128 -0
- package/dist/core/tools/renderers/write.d.ts +9 -0
- package/dist/core/tools/renderers/write.js +152 -0
- package/dist/core/tools/write.js +5 -146
- package/dist/extensions/capability-gateway/index.ts +12 -1
- package/dist/extensions/capability-gateway/integration.test.ts +23 -0
- package/dist/extensions/pi-hermes-memory/CHANGELOG.md +18 -0
- package/dist/extensions/pi-hermes-memory/README.md +16 -12
- package/dist/extensions/pi-hermes-memory/docs/ROADMAP.md +1 -1
- package/dist/extensions/pi-hermes-memory/package-lock.json +587 -62
- package/dist/extensions/pi-hermes-memory/package.json +4 -4
- package/dist/extensions/pi-hermes-memory/src/config.ts +32 -0
- package/dist/extensions/pi-hermes-memory/src/constants.ts +12 -2
- package/dist/extensions/pi-hermes-memory/src/handlers/background-review.ts +109 -51
- package/dist/extensions/pi-hermes-memory/src/handlers/index-sessions.ts +9 -3
- package/dist/extensions/pi-hermes-memory/src/handlers/review-memory-ops.ts +257 -98
- package/dist/extensions/pi-hermes-memory/src/handlers/session-backfill.ts +9 -2
- package/dist/extensions/pi-hermes-memory/src/handlers/sync-markdown-memories.ts +5 -0
- package/dist/extensions/pi-hermes-memory/src/index.ts +22 -2
- package/dist/extensions/pi-hermes-memory/src/paths.ts +1 -1
- package/dist/extensions/pi-hermes-memory/src/store/db.ts +6 -0
- package/dist/extensions/pi-hermes-memory/src/store/fts-query.ts +58 -1
- package/dist/extensions/pi-hermes-memory/src/store/memory-store.ts +60 -8
- package/dist/extensions/pi-hermes-memory/src/store/session-indexer.ts +169 -7
- package/dist/extensions/pi-hermes-memory/src/store/session-search.ts +7 -21
- package/dist/extensions/pi-hermes-memory/src/store/sqlite-memory-store.ts +134 -15
- package/dist/extensions/pi-hermes-memory/src/tools/memory-search-tool.ts +4 -2
- package/dist/extensions/pi-hermes-memory/src/tools/memory-tool.ts +24 -9
- package/dist/extensions/pi-hermes-memory/src/types.ts +11 -0
- package/dist/extensions/pi-hermes-memory/tests/config.test.ts +42 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/background-review.test.ts +159 -3
- package/dist/extensions/pi-hermes-memory/tests/handlers/review-memory-ops.test.ts +452 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-backfill.test.ts +39 -0
- package/dist/extensions/pi-hermes-memory/tests/integration/corruption-recovery-integration.test.ts +91 -0
- package/dist/extensions/pi-hermes-memory/tests/store/db.test.ts +29 -0
- package/dist/extensions/pi-hermes-memory/tests/store/fts-query.test.ts +116 -0
- package/dist/extensions/pi-hermes-memory/tests/store/memory-store.test.ts +163 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-indexer.test.ts +233 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-search.test.ts +12 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-memory-store.test.ts +175 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-native.test.ts +2 -2
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-search-tool.test.ts +22 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-tool.test.ts +37 -1
- package/dist/extensions/pi-intercom/CHANGELOG.md +13 -0
- package/dist/extensions/pi-intercom/README.md +16 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +19 -2
- package/dist/extensions/pi-intercom/broker/spawn.ts +4 -4
- package/dist/extensions/pi-intercom/index.ts +1 -1
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +121 -2
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/project-agent.test.ts +1 -1
- package/dist/extensions/pi-intercom/project-agent.ts +1 -6
- package/dist/extensions/pi-intercom/skills/pi-intercom/SKILL.md +7 -6
- package/dist/extensions/pi-rewind-hook/index.test.ts +7 -0
- package/dist/extensions/pi-subagents/.github/workflows/test.yml +36 -4
- package/dist/extensions/pi-subagents/CHANGELOG.md +120 -0
- package/dist/extensions/pi-subagents/README.md +3 -3
- package/dist/extensions/pi-subagents/agents/researcher.md +23 -13
- package/dist/extensions/pi-subagents/agents/reviewer.md +1 -1
- package/dist/extensions/pi-subagents/agents/scout.md +1 -1
- package/dist/extensions/pi-subagents/docs/agents.md +32 -16
- package/dist/extensions/pi-subagents/docs/configuration.md +41 -15
- package/dist/extensions/pi-subagents/docs/extension-api.md +109 -6
- package/dist/extensions/pi-subagents/docs/missions.md +2 -0
- package/dist/extensions/pi-subagents/docs/models.md +58 -1
- package/dist/extensions/pi-subagents/docs/observability.md +50 -12
- package/dist/extensions/pi-subagents/docs/tool-reference.md +17 -10
- package/dist/extensions/pi-subagents/docs/watchdog.md +1 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +26 -9
- package/dist/extensions/pi-subagents/package-lock.json +747 -177
- package/dist/extensions/pi-subagents/package.json +6 -4
- package/dist/extensions/pi-subagents/runner-server-preload.mjs +13 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +2 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +21 -4
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/dist/extensions/pi-subagents/src/agents/advertised-agent-prompt.ts +63 -0
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +61 -16
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +2 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +8 -0
- package/dist/extensions/pi-subagents/src/api/capability-ceiling.ts +0 -1
- package/dist/extensions/pi-subagents/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
- package/dist/extensions/pi-subagents/src/api/preflight.ts +7 -4
- package/dist/extensions/pi-subagents/src/api/shared-types.ts +2 -2
- package/dist/extensions/pi-subagents/src/api/workflow-resources.ts +6 -0
- package/dist/extensions/pi-subagents/src/extension/config.ts +4 -2
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +2 -10
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +9 -11
- package/dist/extensions/pi-subagents/src/extension/index.ts +99 -10
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +13 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +7 -23
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +11 -8
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +25 -7
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +73 -0
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +228 -136
- package/dist/extensions/pi-subagents/src/intercom/supervisor-ui.ts +244 -0
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +37 -16
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +18 -18
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +51 -25
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +46 -3
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +14 -3
- package/dist/extensions/pi-subagents/src/runs/background/async-retention.ts +9 -0
- package/dist/extensions/pi-subagents/src/runs/background/async-status-snapshot.ts +10 -12
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -15
- package/dist/extensions/pi-subagents/src/runs/background/auto-drain.ts +40 -29
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +8 -0
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +79 -247
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +88 -12
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +6 -6
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +24 -24
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +642 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +75 -6
- package/dist/extensions/pi-subagents/src/runs/background/runner-aliases.ts +163 -0
- package/dist/extensions/pi-subagents/src/runs/background/runner-child-launch.ts +86 -0
- package/dist/extensions/pi-subagents/src/runs/background/runner-child-sessions.ts +31 -0
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +18 -4
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +597 -1092
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +3 -0
- package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +20 -17
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +296 -394
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +630 -283
- package/dist/extensions/pi-subagents/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
- package/dist/extensions/pi-subagents/src/runs/foreground/workflow-foreground-steering.ts +78 -98
- package/dist/extensions/pi-subagents/src/runs/shared/abort-recovery.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +16 -3
- package/dist/extensions/pi-subagents/src/runs/shared/agent-contract.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +47 -47
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +1 -2
- package/dist/extensions/pi-subagents/src/runs/shared/child-hooks.ts +174 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-identity.ts +14 -3
- package/dist/extensions/pi-subagents/src/runs/shared/child-launch.ts +319 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-lifecycle.ts +25 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-runtime-config.ts +126 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-session.ts +373 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +530 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-evidence.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-preflight.ts +16 -0
- package/dist/extensions/pi-subagents/src/runs/shared/host-step-status.ts +11 -11
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +30 -20
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
- package/dist/extensions/pi-subagents/src/runs/shared/model-exclusions.ts +84 -15
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +88 -12
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +37 -53
- package/dist/extensions/pi-subagents/src/runs/shared/nested-path.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/orca-progress-tabs.ts +18 -7
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +57 -12
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +3 -4
- package/dist/extensions/pi-subagents/src/runs/shared/permissions.ts +0 -11
- package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +4 -1
- package/dist/extensions/pi-subagents/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/dist/extensions/pi-subagents/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/dist/extensions/pi-subagents/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +8 -21
- package/dist/extensions/pi-subagents/src/runs/shared/runtime-acknowledged-extensions.ts +3 -30
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +17 -4
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +99 -386
- package/dist/extensions/pi-subagents/src/runs/shared/tool-availability.ts +18 -62
- package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +0 -14
- package/dist/extensions/pi-subagents/src/runs/shared/worktree-cleanup-plan.ts +31 -9
- package/dist/extensions/pi-subagents/src/runs/shared/worktree-setup-command.ts +190 -0
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +506 -226
- package/dist/extensions/pi-subagents/src/shared/child-session-name.ts +1 -1
- package/dist/extensions/pi-subagents/src/shared/jsonl-writer.ts +11 -0
- package/dist/extensions/pi-subagents/src/shared/model-response-aliases.ts +13 -0
- package/dist/extensions/pi-subagents/src/shared/thinking-ceiling.ts +0 -6
- package/dist/extensions/pi-subagents/src/shared/types.ts +145 -89
- package/dist/extensions/pi-subagents/src/shared/utils.ts +13 -6
- package/dist/extensions/pi-subagents/src/shared/watch-strategy.ts +2 -0
- package/dist/extensions/pi-subagents/src/shared/workflow-child-permit.ts +18 -13
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +0 -6
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +1 -1
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +11 -6
- package/dist/extensions/pi-subagents/src/tui/render.ts +277 -46
- package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +0 -1
- package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +12 -12
- package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +3 -3
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +116 -15
- package/dist/extensions/pi-subagents/src/workflows/workflow-checklist.ts +15 -18
- package/dist/extensions/pi-subagents/src/workflows/workflow-child-summary.ts +57 -8
- package/dist/extensions/pi-subagents/src/workflows/workflow-preflight.ts +19 -19
- package/dist/extensions/pi-subagents/src/workflows/workflow-receipt.ts +3 -3
- package/dist/extensions/pi-subagents/src/workflows/workflow-resources.ts +97 -22
- package/dist/extensions/pi-subagents/src/workflows/workflow-settlement.ts +3 -0
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +336 -21
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-1.test.ts +1181 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-2.test.ts +2240 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +2268 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-4.test.ts +1512 -0
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +231 -16
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +4 -18
- package/dist/extensions/pi-subagents/test/integration/foreign-workflow-steering.test.ts +95 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +9 -15
- package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +374 -0
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +20 -10
- package/dist/extensions/pi-subagents/test/integration/orca-progress-tabs.test.ts +18 -6
- package/dist/extensions/pi-subagents/test/integration/recovered-assistant-error.test.ts +151 -0
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +42 -1
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +150 -25
- package/dist/extensions/pi-subagents/test/integration/result-publication.test.ts +354 -0
- package/dist/extensions/pi-subagents/test/integration/shared-cwd-terminal-evidence.test.ts +117 -0
- package/dist/extensions/pi-subagents/test/integration/single-execution.part-1.test.ts +3678 -0
- package/dist/extensions/pi-subagents/test/integration/{single-execution.test.ts → single-execution.part-2.test.ts} +500 -4207
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -2
- package/dist/extensions/pi-subagents/test/integration/workflow-result-publication.test.ts +127 -0
- package/dist/extensions/pi-subagents/test/integration/workflow-steer-inbox.test.ts +365 -0
- package/dist/extensions/pi-subagents/test/smoke/pi085-child.ts +31 -0
- package/dist/extensions/pi-subagents/test/smoke/pi085-clean-install.mjs +74 -0
- package/dist/extensions/pi-subagents/test/smoke/pi085-extension.ts +16 -0
- package/dist/extensions/pi-subagents/test/support/async-execution-fixture.ts +699 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +418 -0
- package/dist/extensions/pi-subagents/test/support/foreign-workflow-steer-host.mjs +13 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +30 -76
- package/dist/extensions/pi-subagents/test/support/notify-diagnostics-fixture.ts +84 -0
- package/dist/extensions/pi-subagents/test/support/result-publication-capacity-preload.mjs +70 -0
- package/dist/extensions/pi-subagents/test/support/runner-child-session-factory.ts +17 -0
- package/dist/extensions/pi-subagents/test/support/single-execution-fixture.ts +390 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance-compaction.test.ts +120 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +24 -3
- package/dist/extensions/pi-subagents/test/unit/advertised-agent-prompt.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/unit/advertised-agent-refresh.test.ts +163 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +70 -31
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +78 -3
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +2 -8
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +73 -66
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +51 -1
- package/dist/extensions/pi-subagents/test/unit/async-retention.test.ts +49 -1
- package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +9 -8
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +10 -7
- package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +48 -6
- package/dist/extensions/pi-subagents/test/unit/child-runtime-config.test.ts +90 -0
- package/dist/extensions/pi-subagents/test/unit/{pi-args-permission-system.test.ts → child-tool-plan-permission-system.test.ts} +48 -86
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +33 -0
- package/dist/extensions/pi-subagents/test/unit/compaction-resume.test.ts +18 -18
- package/dist/extensions/pi-subagents/test/unit/completion-evidence.test.ts +9 -9
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +8 -7
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +225 -65
- package/dist/extensions/pi-subagents/test/unit/extension-bindings.test.ts +17 -22
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +11 -1
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +47 -3
- package/dist/extensions/pi-subagents/test/unit/foreign-workflow-steering.test.ts +74 -0
- package/dist/extensions/pi-subagents/test/unit/fork-cache-key.test.ts +23 -29
- package/dist/extensions/pi-subagents/test/unit/host-peer-runtime-imports.test.ts +96 -4
- package/dist/extensions/pi-subagents/test/unit/host-step-status.test.ts +4 -4
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +116 -55
- package/dist/extensions/pi-subagents/test/unit/index-segment.test.ts +58 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +104 -3
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/mission-store.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/model-exclusions.test.ts +102 -51
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +196 -6
- package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +181 -38
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +62 -70
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +58 -53
- package/dist/extensions/pi-subagents/test/unit/notify-diagnostics.test.ts +42 -0
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +84 -0
- package/dist/extensions/pi-subagents/test/unit/orca-progress-tabs.test.ts +13 -5
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +21 -5
- package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +5 -4
- package/dist/extensions/pi-subagents/test/unit/permissions.test.ts +2 -6
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +28 -2
- package/dist/extensions/pi-subagents/test/unit/pi-web-session-liveness.test.ts +209 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +38 -1
- package/dist/extensions/pi-subagents/test/unit/prompt-audit-optional-peers.test.ts +43 -0
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +24 -2
- package/dist/extensions/pi-subagents/test/unit/readonly-drain-observation.test.ts +174 -0
- package/dist/extensions/pi-subagents/test/unit/readonly-model-continuation.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/unit/readonly-session-evidence.test.ts +1329 -0
- package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +29 -80
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +11 -8
- package/dist/extensions/pi-subagents/test/unit/result-files.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/retained-nested-route-tracker.test.ts +175 -0
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +38 -12
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +2 -5
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +89 -1
- package/dist/extensions/pi-subagents/test/unit/runtime-acknowledged-extensions.test.ts +2 -16
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +27 -3
- package/dist/extensions/pi-subagents/test/unit/scheduled-supervisor-demand.test.ts +215 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +15 -11
- package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +237 -1
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +2 -5
- package/dist/extensions/pi-subagents/test/unit/streamed-progress-bounds.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/subagent-guide.test.ts +14 -1
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +130 -739
- package/dist/extensions/pi-subagents/test/unit/supervisor-ask-registration.test.ts +682 -0
- package/dist/extensions/pi-subagents/test/unit/supervisor-ui.test.ts +137 -0
- package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +1 -11
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +23 -3
- package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +8 -1
- package/dist/extensions/pi-subagents/test/unit/watchdog-tool-actions.test.ts +19 -1
- package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +72 -4
- package/dist/extensions/pi-subagents/test/unit/workflow-checklist.test.ts +17 -3
- package/dist/extensions/pi-subagents/test/unit/workflow-child-summary.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-detach-reconcile.test.ts +12 -2
- package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +46 -6
- package/dist/extensions/pi-subagents/test/unit/workflow-receipt.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/unit/workflow-resources.test.ts +133 -1
- package/dist/extensions/pi-subagents/test/unit/workflow-resume-hint.test.ts +53 -2
- package/dist/extensions/pi-subagents/test/unit/worktree-cleanup-plan.test.ts +222 -14
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +526 -85
- package/dist/extensions/pi-web-agent/src/backends/config.ts +1 -0
- package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +5 -3
- package/dist/extensions/pi-web-agent/src/search/tokenin.ts +7 -3
- package/dist/extensions/pi-web-agent/src/types.ts +1 -1
- package/dist/extensions/pi-zentui/README.md +17 -0
- package/dist/extensions/pi-zentui/assets/screenshots/footer-status-hyperlink.png +0 -0
- package/dist/extensions/pi-zentui/docs/configuration.md +71 -2
- package/dist/extensions/pi-zentui/extensions/zentui/config.ts +11 -0
- package/dist/extensions/pi-zentui/extensions/zentui/editor-transfer.ts +14 -0
- package/dist/extensions/pi-zentui/extensions/zentui/extension-status.ts +29 -13
- package/dist/extensions/pi-zentui/extensions/zentui/footer-layout.ts +5 -4
- package/dist/extensions/pi-zentui/extensions/zentui/footer-text.ts +11 -0
- package/dist/extensions/pi-zentui/extensions/zentui/footer.ts +11 -10
- package/dist/extensions/pi-zentui/extensions/zentui/index.ts +77 -3
- package/dist/extensions/pi-zentui/extensions/zentui/presets.ts +77 -0
- package/dist/extensions/pi-zentui/extensions/zentui/settings-command.ts +103 -2
- package/dist/extensions/pi-zentui/extensions/zentui/working-line-extension-segments.ts +115 -0
- package/dist/extensions/pi-zentui/extensions/zentui/working-line.ts +87 -5
- package/dist/extensions/pi-zentui/package-lock.json +277 -517
- package/dist/extensions/pi-zentui/package.json +7 -4
- package/dist/extensions/pi-zentui/test/config-presets.test.ts +238 -0
- package/dist/extensions/pi-zentui/test/editor-transfer.test.ts +24 -1
- package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +570 -2
- package/dist/extensions/pi-zentui/test/extension-status.test.ts +90 -0
- package/dist/extensions/pi-zentui/test/footer-hyperlink.test.ts +243 -0
- package/dist/extensions/pi-zentui/test/package-contents.mjs +1 -0
- package/dist/extensions/pi-zentui/test/presets.test.ts +121 -0
- package/dist/extensions/pi-zentui/test/settings-command.test.ts +205 -1
- package/dist/extensions/pi-zentui/test/working-line-extension-segments.test.ts +139 -0
- package/dist/extensions/pi-zentui/test/working-line-lifecycle.integration.test.ts +237 -3
- package/dist/extensions/pi-zentui/test/working-line.test.ts +94 -2
- package/dist/index.d.ts +1 -1
- package/dist/main.js +4 -1
- package/dist/modes/interactive/chat-viewport.d.ts +19 -0
- package/dist/modes/interactive/chat-viewport.js +27 -0
- package/dist/modes/interactive/components/assistant-message.d.ts +6 -2
- package/dist/modes/interactive/components/assistant-message.js +30 -13
- package/dist/modes/interactive/components/custom-editor.d.ts +10 -1
- package/dist/modes/interactive/components/custom-editor.js +39 -1
- package/dist/modes/interactive/components/first-time-setup.js +1 -1
- package/dist/modes/interactive/components/index.d.ts +1 -1
- package/dist/modes/interactive/components/markdown-transform.d.ts +2 -0
- package/dist/modes/interactive/components/markdown-transform.js +18 -0
- package/dist/modes/interactive/components/model-selector.js +19 -17
- package/dist/modes/interactive/components/scoped-models-selector.js +21 -23
- package/dist/modes/interactive/components/settings-selector.d.ts +7 -1
- package/dist/modes/interactive/components/settings-selector.js +128 -20
- package/dist/modes/interactive/components/settings-submenu.d.ts +71 -0
- package/dist/modes/interactive/components/settings-submenu.js +164 -0
- package/dist/modes/interactive/components/status-indicator.d.ts +3 -1
- package/dist/modes/interactive/components/status-indicator.js +10 -3
- package/dist/modes/interactive/components/thinking-selector.d.ts +15 -3
- package/dist/modes/interactive/components/thinking-selector.js +75 -18
- package/dist/modes/interactive/components/tool-execution.d.ts +21 -4
- package/dist/modes/interactive/components/tool-execution.js +35 -33
- package/dist/modes/interactive/components/trust-selector.js +2 -2
- package/dist/modes/interactive/interactive-mode.d.ts +13 -15
- package/dist/modes/interactive/interactive-mode.js +144 -80
- package/dist/modes/interactive/session-share.d.ts +15 -0
- package/dist/modes/interactive/session-share.js +183 -0
- package/dist/modes/interactive/theme/dark.json +2 -1
- package/dist/modes/interactive/theme/light.json +2 -1
- package/dist/modes/interactive/theme/theme-controller.d.ts +1 -0
- package/dist/modes/interactive/theme/theme-controller.js +5 -0
- package/dist/modes/interactive/theme/theme-json.d.ts +83 -0
- package/dist/modes/interactive/theme/theme-json.js +129 -0
- package/dist/modes/interactive/theme/theme-schema.json +9 -1
- package/dist/modes/interactive/theme/theme.d.ts +14 -5
- package/dist/modes/interactive/theme/theme.js +18 -119
- package/dist/modes/interactive/tui-renderer.d.ts +20 -0
- package/dist/modes/interactive/tui-renderer.js +65 -0
- package/dist/utils/exif-orientation.js +2 -3
- package/dist/utils/management-http.d.ts +24 -0
- package/dist/utils/management-http.js +53 -0
- package/dist/utils/text.d.ts +7 -0
- package/dist/utils/text.js +8 -0
- package/dist/utils/tools-manager.d.ts +1 -0
- package/dist/utils/tools-manager.js +43 -21
- package/docs/docs.json +0 -4
- package/package.json +4 -4
- package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +0 -415
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +0 -1059
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +0 -116
- package/dist/extensions/pi-subagents/src/shared/post-exit-stdio-guard.ts +0 -85
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +0 -224
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +0 -6986
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +0 -447
- package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +0 -185
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +0 -311
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-pi-args.test.ts +0 -84
- package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +0 -202
- package/dist/extensions/pi-subagents/test/unit/close-grace-timer.test.ts +0 -101
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +0 -2347
- package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +0 -104
- package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +0 -49
- package/dist/skills/imagegen-frontend-mobile/SKILL.md +0 -1465
- package/dist/skills/imagegen-frontend-web/SKILL.md +0 -987
- package/dist/skills/implanger/SKILL.md +0 -70
- package/dist/skills/output/SKILL.md +0 -49
- package/dist/skills/planger/SKILL.md +0 -167
- package/dist/skills/workflow/SKILL.md +0 -95
- package/docs/workflows.md +0 -18
- /package/dist/bun/{register-bedrock.d.ts → runtime-setup.d.ts} +0 -0
|
@@ -0,0 +1,3678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for single (sync) agent execution.
|
|
3
|
+
*
|
|
4
|
+
* Uses the local createMockPi() helper to simulate the pi CLI.
|
|
5
|
+
* Tests the full spawn→parse→result pipeline in runSync without a real LLM.
|
|
6
|
+
*
|
|
7
|
+
* These tests require pi packages to be importable (they run inside a pi
|
|
8
|
+
* environment or with pi packages installed). If unavailable, tests skip
|
|
9
|
+
* gracefully.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import {
|
|
14
|
+
tempDir, agentDir, mockPi, available, runSync, getFinalOutput, utils,
|
|
15
|
+
createSubagentExecutor, escapeRegExp, pathContainsSegments, waitForFileContent,
|
|
16
|
+
mockAssistantMessage, readCall, readCallArgs, readAllCallArgs, makeExecutor,
|
|
17
|
+
installSingleExecutionHooks,
|
|
18
|
+
} from "../support/single-execution-fixture.ts";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import {
|
|
24
|
+
createEventBus,
|
|
25
|
+
makeAgentConfigs,
|
|
26
|
+
makeAgent,
|
|
27
|
+
makeMinimalCtx,
|
|
28
|
+
events,
|
|
29
|
+
} from "../support/helpers.ts";
|
|
30
|
+
import registerSubagentExtension from "../../src/extension/index.ts";
|
|
31
|
+
import { handleSubagentControlNotice } from "../../src/extension/control-notices.ts";
|
|
32
|
+
import { discoverAgents } from "../../src/agents/agents.ts";
|
|
33
|
+
import {
|
|
34
|
+
SUBAGENT_DELEGATION_REQUEST_EVENT,
|
|
35
|
+
SUBAGENT_DELEGATION_RESPONSE_EVENT,
|
|
36
|
+
SUBAGENT_DELEGATION_STARTED_EVENT,
|
|
37
|
+
type SubagentDelegationRequest,
|
|
38
|
+
type SubagentDelegationResponse,
|
|
39
|
+
type SubagentDelegationStarted,
|
|
40
|
+
} from "../../src/api/delegation.ts";
|
|
41
|
+
import { CHAIN_RUNS_DIR, DIRS, INTERCOM_DETACH_REQUEST_EVENT, INTERCOM_DETACH_RESPONSE_EVENT, SUBAGENT_CONTROL_EVENT, TEMP_ARTIFACTS_DIR, type AsyncStatus, type ChildWatchdogProgress, type ControlEvent, type SubagentState } from "../../src/shared/types.ts";
|
|
42
|
+
import { ACTIVE_RUN_INDEX_DIR } from "../../src/runs/background/active-run-index.ts";
|
|
43
|
+
import { encodeIndexSegment } from "../../src/runs/background/index-segment.ts";
|
|
44
|
+
import { listAsyncRuns } from "../../src/runs/background/async-status.ts";
|
|
45
|
+
import { CHILD_WATCHDOG_STATUS_EVENT } from "../../src/watchdog/child-status.ts";
|
|
46
|
+
import { createRunFanoutBudget } from "../../src/runs/shared/run-fanout-budget.ts";
|
|
47
|
+
import { MainWatchdogRuntime } from "../../src/watchdog/runtime.ts";
|
|
48
|
+
import { SUBAGENT_CHILD_ENV, type ChildRuntimeConfig } from "../../src/runs/shared/child-runtime-config.ts";
|
|
49
|
+
import { createNestedRoute, parseNestedEventRecords } from "../../src/runs/shared/nested-events.ts";
|
|
50
|
+
import { resolveMissionStoreLocation } from "../../src/missions/store.ts";
|
|
51
|
+
import { missionStatePath } from "../../src/missions/workflow-state.ts";
|
|
52
|
+
import { discardPreservedWorktrees } from "../../src/runs/shared/parallel-handoff.ts";
|
|
53
|
+
import { createWorktrees } from "../../src/runs/shared/worktree.ts";
|
|
54
|
+
import { resolveAsyncResumeTarget } from "../../src/runs/background/async-resume.ts";
|
|
55
|
+
import { createResultWatcher } from "../../src/runs/background/result-watcher.ts";
|
|
56
|
+
import { clearExclusions, recordModelFailure } from "../../src/runs/shared/model-exclusions.ts";
|
|
57
|
+
import { createWorkflowChildPermit, workflowChildPermitConsumed } from "../../src/shared/workflow-child-permit.ts";
|
|
58
|
+
import { toSubagentDelegationExecutionParams } from "../../src/slash/delegation-adapters.ts";
|
|
59
|
+
import { registerWorkflowResource } from "../../src/api/workflow-resources.ts";
|
|
60
|
+
import { registerSubagentCapabilityCeiling } from "../../src/api/capability-ceiling.ts";
|
|
61
|
+
|
|
62
|
+
describe("single sync execution", { skip: !available ? "pi packages not available" : undefined }, () => {
|
|
63
|
+
installSingleExecutionHooks();
|
|
64
|
+
|
|
65
|
+
it("streams bounded concurrent synchronous workflow activity with chatProgress off", async () => {
|
|
66
|
+
for (const [task, tool] of [["Child A", "read"], ["Child B", "bash"]]) {
|
|
67
|
+
mockPi.onCall({ matchArgIncludes: task, steps: [
|
|
68
|
+
{ jsonl: Array.from({ length: 100 }, () => ({ type: "tool_execution_start", toolCallId: tool, toolName: tool, args: { secret: "not-forwarded" } })) },
|
|
69
|
+
{ delay: 450, jsonl: [events.toolEnd(tool)] },
|
|
70
|
+
{ delay: 250, jsonl: [events.assistantMessage("done")] },
|
|
71
|
+
] });
|
|
72
|
+
}
|
|
73
|
+
const updates: any[] = [];
|
|
74
|
+
const result = await makeExecutor([makeAgent("worker")]).execute("wf-activity", {
|
|
75
|
+
workflowScript: `return await runs.all([{ key: "a", agent: "worker", task: "Child A", async: false }, { key: "b", agent: "worker", task: "Child B", async: false }]);`,
|
|
76
|
+
async: false, chatProgress: "off",
|
|
77
|
+
}, undefined, (update) => updates.push(structuredClone(update.details)), makeMinimalCtx(tempDir));
|
|
78
|
+
assert.equal(result.isError, undefined, JSON.stringify(result.content));
|
|
79
|
+
for (const [key, tool] of [["a", "read"], ["b", "bash"]]) {
|
|
80
|
+
const row = updates.flatMap((update) => update.workflowChildren?.children ?? []).find((row) => row.childId === key && row.activity?.currentTool === tool);
|
|
81
|
+
assert.ok(row, `missing mid-run activity for ${key}`);
|
|
82
|
+
assert.equal(row.agent, "worker");
|
|
83
|
+
assert.ok(row.activity.durationMs >= 0);
|
|
84
|
+
assert.ok(row.activity.toolCount >= 1);
|
|
85
|
+
assert.equal(JSON.stringify(row).includes("not-forwarded"), false);
|
|
86
|
+
}
|
|
87
|
+
assert.ok(result.details.workflowChildren.children.every((row) => row.activity === undefined));
|
|
88
|
+
assert.ok(updates.length < 25, `burst was not coalesced: ${updates.length} updates`);
|
|
89
|
+
assert.ok(updates.some((update) => update.workflowChildren?.children.some((row) => row.activity && !row.activity.currentTool && row.activity.toolCount > 0)), "tool completion clears currentTool while running");
|
|
90
|
+
const count = updates.length;
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
92
|
+
assert.equal(updates.length, count, "no trailing timer after settlement");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("spawns agent and captures output", async () => {
|
|
96
|
+
mockPi.onCall({ output: "Hello from mock agent" });
|
|
97
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
98
|
+
|
|
99
|
+
const sessionFile = path.join(tempDir, "child-session.jsonl");
|
|
100
|
+
const result = await runSync(tempDir, agents, "echo", "Say hello", { sessionFile });
|
|
101
|
+
|
|
102
|
+
assert.equal(result.exitCode, 0);
|
|
103
|
+
assert.equal(result.agent, "echo");
|
|
104
|
+
assert.equal(result.sessionFile, sessionFile);
|
|
105
|
+
assert.ok(result.messages.length > 0, "should have messages");
|
|
106
|
+
|
|
107
|
+
const output = getFinalOutput(result.messages);
|
|
108
|
+
assert.equal(output, "Hello from mock agent");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("derives a child session name and passes it to the child runtime config", async () => {
|
|
112
|
+
mockPi.onCall({ output: "hello" });
|
|
113
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
114
|
+
|
|
115
|
+
const result = await runSync(tempDir, agents, "echo", "Say hello to the world", {});
|
|
116
|
+
|
|
117
|
+
assert.equal(result.exitCode, 0);
|
|
118
|
+
assert.equal(result.sessionName, "echo: Say hello to the world");
|
|
119
|
+
assert.equal(result.progressSummary?.sessionName, "echo: Say hello to the world");
|
|
120
|
+
assert.equal(readCall().runtime?.sessionName, "echo: Say hello to the world");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects invalid foreground cwd before spawning Pi", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
124
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
125
|
+
const requestedCwd = "missing-local-cwd";
|
|
126
|
+
const effectiveCwd = path.resolve(tempDir, requestedCwd);
|
|
127
|
+
|
|
128
|
+
const missing = await executor.executePublic(
|
|
129
|
+
"invalid-foreground-cwd",
|
|
130
|
+
{ agent: "echo", task: "Do not spawn", async: false, cwd: requestedCwd },
|
|
131
|
+
new AbortController().signal,
|
|
132
|
+
undefined,
|
|
133
|
+
makeMinimalCtx(tempDir),
|
|
134
|
+
);
|
|
135
|
+
assert.equal(missing.isError, true);
|
|
136
|
+
assert.match(missing.content[0]?.text ?? "", new RegExp(`cwd does not exist: ${effectiveCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
137
|
+
assert.match(missing.content[0]?.text ?? "", /resolved from "missing-local-cwd"/);
|
|
138
|
+
|
|
139
|
+
const fileCwd = path.join(tempDir, "not-a-directory");
|
|
140
|
+
fs.writeFileSync(fileCwd, "file");
|
|
141
|
+
const notDirectory = await executor.executePublic(
|
|
142
|
+
"invalid-foreground-file-cwd",
|
|
143
|
+
{ agent: "echo", task: "Do not spawn", async: false, cwd: fileCwd },
|
|
144
|
+
new AbortController().signal,
|
|
145
|
+
undefined,
|
|
146
|
+
makeMinimalCtx(tempDir),
|
|
147
|
+
);
|
|
148
|
+
assert.equal(notDirectory.isError, true);
|
|
149
|
+
assert.match(notDirectory.content[0]?.text ?? "", /cwd is not a directory/);
|
|
150
|
+
assert.match(notDirectory.content[0]?.text ?? "", new RegExp(fileCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
151
|
+
assert.equal(mockPi.callCount(), 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rejects invalid async cwd before spawning the native runner", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
155
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
156
|
+
const requestedCwd = "missing-async-cwd";
|
|
157
|
+
const effectiveCwd = path.resolve(tempDir, requestedCwd);
|
|
158
|
+
|
|
159
|
+
const result = await executor.executePublic(
|
|
160
|
+
"invalid-async-cwd",
|
|
161
|
+
{ agent: "echo", task: "Do not spawn", async: true, cwd: requestedCwd },
|
|
162
|
+
new AbortController().signal,
|
|
163
|
+
undefined,
|
|
164
|
+
makeMinimalCtx(tempDir),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
assert.equal(result.isError, true);
|
|
168
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(`cwd does not exist: ${effectiveCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
169
|
+
assert.match(result.content[0]?.text ?? "", /resolved from "missing-async-cwd"/);
|
|
170
|
+
assert.equal(mockPi.callCount(), 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("runs public structured single-child requests directly", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
174
|
+
mockPi.onCall({ output: "Structured child completed" });
|
|
175
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
176
|
+
|
|
177
|
+
const result = await executor.executePublic(
|
|
178
|
+
"structured-single",
|
|
179
|
+
{ agent: "echo", task: "Run through workflow", async: false, context: "fresh" },
|
|
180
|
+
new AbortController().signal,
|
|
181
|
+
undefined,
|
|
182
|
+
makeMinimalCtx(tempDir),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "");
|
|
186
|
+
assert.equal(result.details.mode, "single");
|
|
187
|
+
assert.equal(mockPi.callCount(), 1);
|
|
188
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /Console:/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("keeps public structured children alive when tool results backfill without execution_end", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
192
|
+
mockPi.onCall({
|
|
193
|
+
steps: [
|
|
194
|
+
{ jsonl: [{ type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: { command: "echo PROBE_OK" } }] },
|
|
195
|
+
{ delay: 25, jsonl: [
|
|
196
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolCallId: "bash-1", toolName: "bash", isError: false, content: [{ type: "text", text: "PROBE_OK" }] } },
|
|
197
|
+
events.assistantMessage("PROBE_OK"),
|
|
198
|
+
] },
|
|
199
|
+
],
|
|
200
|
+
keepAliveAfterFinalMessageMs: 400,
|
|
201
|
+
});
|
|
202
|
+
const executor = makeExecutor([makeAgent("bash-worker")]);
|
|
203
|
+
|
|
204
|
+
const result = await executor.executePublic(
|
|
205
|
+
"structured-single-tool-backfill",
|
|
206
|
+
{ agent: "bash-worker", task: "Run exactly one tool: bash with command echo PROBE_OK.", async: false, toolTimeoutMs: 100, timeoutMs: 5_000 },
|
|
207
|
+
new AbortController().signal,
|
|
208
|
+
undefined,
|
|
209
|
+
makeMinimalCtx(tempDir),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
213
|
+
assert.match(result.content[0]?.text ?? "", /PROBE_OK/);
|
|
214
|
+
assert.equal(result.details.results[0]?.timedOut, undefined);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("keeps public structured single-child calls foreground when async is disabled by default", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
218
|
+
mockPi.onCall({ output: "Structured child used the foreground default" });
|
|
219
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false);
|
|
220
|
+
|
|
221
|
+
const result = await executor.executePublic(
|
|
222
|
+
"structured-single-foreground-default",
|
|
223
|
+
{ agent: "echo", task: "Run through workflow" },
|
|
224
|
+
new AbortController().signal,
|
|
225
|
+
undefined,
|
|
226
|
+
makeMinimalCtx(tempDir),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "");
|
|
230
|
+
assert.match(result.content[0]?.text ?? "", /Structured child used the foreground default/);
|
|
231
|
+
assert.equal(result.details.asyncId, undefined);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("does not override structured single output unless configured by the agent", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
235
|
+
for (const params of [
|
|
236
|
+
{ agent: "echo", task: "Use the task output path", async: false },
|
|
237
|
+
{ agent: "echo", task: "Disable file output", output: false, async: false },
|
|
238
|
+
] as const) {
|
|
239
|
+
mockPi.onCall({ output: "Structured child completed" });
|
|
240
|
+
const result = await makeExecutor([makeAgent("echo")]).executePublic(
|
|
241
|
+
"structured-single-output",
|
|
242
|
+
params,
|
|
243
|
+
new AbortController().signal,
|
|
244
|
+
undefined,
|
|
245
|
+
makeMinimalCtx(tempDir),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
249
|
+
assert.doesNotMatch(readCallArgs().join("\n"), /This path is authoritative for this run/);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
mockPi.onCall({ output: "Agent report" });
|
|
253
|
+
const configuredPath = path.join(tempDir, "agent-report.md");
|
|
254
|
+
const configured = await makeExecutor([makeAgent("echo", { output: configuredPath })]).executePublic(
|
|
255
|
+
"structured-single-agent-output",
|
|
256
|
+
{ agent: "echo", task: "Use agent output", async: false },
|
|
257
|
+
new AbortController().signal,
|
|
258
|
+
undefined,
|
|
259
|
+
makeMinimalCtx(tempDir),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
assert.equal(configured.isError, undefined, configured.content[0]?.text ?? "workflow failed");
|
|
263
|
+
const configuredTask = readCallArgs().join("\n");
|
|
264
|
+
assert.match(configuredTask, new RegExp(escapeRegExp(configuredPath)));
|
|
265
|
+
assert.match(configuredTask, /This path is authoritative for this run/);
|
|
266
|
+
assert.equal(fs.readFileSync(configuredPath, "utf-8"), "Agent report");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("preserves agent output defaults for structured prompt-template delegation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
270
|
+
const configuredPath = path.join(tempDir, "delegated-agent-report.md");
|
|
271
|
+
mockPi.onCall({
|
|
272
|
+
stdoutRaw: [
|
|
273
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
|
|
274
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
275
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
276
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
277
|
+
structuredOutputCapture: { ok: true },
|
|
278
|
+
});
|
|
279
|
+
const executor = makeExecutor([makeAgent("echo", { output: configuredPath, outputMode: "file-only" })]);
|
|
280
|
+
const request: SubagentDelegationRequest = {
|
|
281
|
+
requestId: "delegated-output-default",
|
|
282
|
+
ownerRunId: "owner-1",
|
|
283
|
+
nodeId: "node-1",
|
|
284
|
+
agent: "echo",
|
|
285
|
+
task: "Return structured data",
|
|
286
|
+
context: "fresh",
|
|
287
|
+
cwd: tempDir,
|
|
288
|
+
model: "mock/model",
|
|
289
|
+
result: { kind: "structured", schema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } } },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const result = await executor.executeDelegated(
|
|
293
|
+
request.requestId,
|
|
294
|
+
toSubagentDelegationExecutionParams(request),
|
|
295
|
+
new AbortController().signal,
|
|
296
|
+
undefined,
|
|
297
|
+
makeMinimalCtx(tempDir),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "delegated execution failed");
|
|
301
|
+
const child = result.details?.results?.[0];
|
|
302
|
+
assert.equal(child?.savedOutputPath, configuredPath);
|
|
303
|
+
assert.equal(child?.outputMode, "file-only");
|
|
304
|
+
assert.deepEqual(child?.structuredOutput, { ok: true });
|
|
305
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(configuredPath, "utf-8")), { ok: true });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("does not inject a workflow child output without an aggregate or explicit output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
309
|
+
mockPi.onCall({ output: "Workflow child completed" });
|
|
310
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
311
|
+
"workflow-omitted-output",
|
|
312
|
+
{ async: false, workflowScript: `return runs.run("main", { agent: "echo", task: "Use the task output path" });` },
|
|
313
|
+
new AbortController().signal,
|
|
314
|
+
undefined,
|
|
315
|
+
makeMinimalCtx(tempDir),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
319
|
+
assert.doesNotMatch(readCallArgs().join("\n"), /This path is authoritative for this run/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("keeps escaped read-only delegate tasks from triggering the completion guard", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
323
|
+
mockPi.onCall({ output: "The exact user-facing response" });
|
|
324
|
+
const task = [
|
|
325
|
+
"This is a read-only skill compliance scenario, not an implementation assignment.",
|
|
326
|
+
"Read the supplied skill and write the exact user-facing response.",
|
|
327
|
+
"Do not edit files.",
|
|
328
|
+
"Use a scenario that discusses selection for an implementation task or closeout of an implementation assignment.",
|
|
329
|
+
].join("\\n");
|
|
330
|
+
const result = await makeExecutor([makeAgent("delegate", {
|
|
331
|
+
tools: ["read", "grep", "find", "ls", "bash", "edit", "write", "contact_supervisor"],
|
|
332
|
+
inheritProjectContext: true,
|
|
333
|
+
systemPromptMode: "append",
|
|
334
|
+
})]).execute(
|
|
335
|
+
"workflow-read-only-delegate",
|
|
336
|
+
{
|
|
337
|
+
async: false,
|
|
338
|
+
acceptance: false,
|
|
339
|
+
preflight: { version: 1, coverage: "complete", lanes: [{ key: "main", mode: "review" }] },
|
|
340
|
+
workflowScript: `return runs.all([{ key: "main", agent: "delegate", task: ${JSON.stringify(task)} }]);`,
|
|
341
|
+
},
|
|
342
|
+
new AbortController().signal,
|
|
343
|
+
undefined,
|
|
344
|
+
makeMinimalCtx(tempDir),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
348
|
+
const child = (result.details as { results?: Array<{ exitCode?: number; error?: string; output?: string }> } | undefined)?.results?.[0];
|
|
349
|
+
assert.equal(child?.exitCode, 0);
|
|
350
|
+
assert.equal(child?.error, undefined);
|
|
351
|
+
assert.match(result.content[0]?.text ?? "", /The exact user-facing response/);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("consumes one exact host-only workflow child permit before spawn", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
355
|
+
const executor = makeExecutor([makeAgent("echo"), makeAgent("other"), makeAgent("external", { runner: { type: "external-cli", command: "external" } })]);
|
|
356
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
357
|
+
const script = `return runs.run("main", { agent: "echo", task: "Exact task", acceptance: false });`;
|
|
358
|
+
mockPi.onCall({ output: "projection probe" });
|
|
359
|
+
const probe = await executor.execute("probe", { async: false, workflowScript: script }, new AbortController().signal, undefined, ctx);
|
|
360
|
+
const launchContractDigest = (probe.details as { results?: Array<{ launchContractDigest?: string }> }).results?.[0]?.launchContractDigest;
|
|
361
|
+
assert.ok(launchContractDigest);
|
|
362
|
+
|
|
363
|
+
const permitFor = (workflowRunId: string, overrides: Partial<Parameters<typeof createWorkflowChildPermit>[0]> = {}) => createWorkflowChildPermit({
|
|
364
|
+
issuerPackage: "permit-secret-package",
|
|
365
|
+
workflowRunId,
|
|
366
|
+
childKey: "main",
|
|
367
|
+
agent: "echo",
|
|
368
|
+
launchContractDigest,
|
|
369
|
+
context: "fresh",
|
|
370
|
+
...overrides,
|
|
371
|
+
});
|
|
372
|
+
const run = (id: string, workflowScript: string, permit: ReturnType<typeof permitFor>, async = false) => executor.executeDelegated(
|
|
373
|
+
id,
|
|
374
|
+
{ async, workflowScript, delegatedWorkflowPermit: permit },
|
|
375
|
+
new AbortController().signal,
|
|
376
|
+
undefined,
|
|
377
|
+
ctx,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
mockPi.onCall({ output: "permitted child" });
|
|
381
|
+
const longRunId = `permitted-${"x".repeat(300)}`;
|
|
382
|
+
const boundedLongRunId = encodeIndexSegment(longRunId);
|
|
383
|
+
const permit = permitFor(boundedLongRunId);
|
|
384
|
+
const allowed = await run(longRunId, script, permit);
|
|
385
|
+
assert.equal(allowed.isError, undefined, allowed.content[0]?.text ?? "long permitted workflow failed");
|
|
386
|
+
assert.equal(allowed.details.runId, boundedLongRunId);
|
|
387
|
+
assert.equal(workflowChildPermitConsumed(permit), true);
|
|
388
|
+
assert.equal(mockPi.callCount(), 2);
|
|
389
|
+
assert.doesNotMatch(JSON.stringify(allowed), /permit-secret-package|__workflowChildPermit/);
|
|
390
|
+
|
|
391
|
+
const reused = await run(longRunId, script, permit);
|
|
392
|
+
assert.equal(reused.isError, true);
|
|
393
|
+
assert.match(reused.content[0]?.text ?? "", /already consumed/);
|
|
394
|
+
assert.equal(mockPi.callCount(), 2);
|
|
395
|
+
|
|
396
|
+
mockPi.onCall({ stderr: "child failed", exitCode: 1 });
|
|
397
|
+
const failedPermit = permitFor("spawn-failure");
|
|
398
|
+
const spawnFailure = await run("spawn-failure", script, failedPermit);
|
|
399
|
+
assert.equal(spawnFailure.isError, true);
|
|
400
|
+
assert.equal(workflowChildPermitConsumed(failedPermit), true);
|
|
401
|
+
assert.equal(mockPi.callCount(), 3, "a consumed permit must not start a retry");
|
|
402
|
+
|
|
403
|
+
const denied = [
|
|
404
|
+
await run("wrong-key", `return runs.run("other", { agent: "echo", task: "Exact task", acceptance: false });`, permitFor("wrong-key")),
|
|
405
|
+
await run("wrong-agent", `return runs.run("main", { agent: "other", task: "Exact task", acceptance: false });`, permitFor("wrong-agent")),
|
|
406
|
+
await run("wrong-task", `return runs.run("main", { agent: "echo", task: "Changed task", acceptance: false });`, permitFor("wrong-task")),
|
|
407
|
+
await run("runs-all", `return runs.all([{ key: "main", agent: "echo", task: "Exact task", acceptance: false }]);`, permitFor("runs-all")),
|
|
408
|
+
await run("resume", `return runs.run("main", { resume: "retained-run", task: "Continue" });`, permitFor("resume")),
|
|
409
|
+
await run("external", `return runs.run("main", { agent: "external", task: "Exact task", async: false });`, permitFor("external")),
|
|
410
|
+
await run("async-root", script, permitFor("async-root"), true),
|
|
411
|
+
];
|
|
412
|
+
const denialText = denied.map((result) => result.content[0]?.text ?? "").join("\n");
|
|
413
|
+
assert.match(denialText, /child key mismatch.*agent mismatch.*final launch projection.*runs\.all.*retained resume.*native Pi children.*foreground workflow roots/s);
|
|
414
|
+
const wrongThenRightPermit = permitFor("wrong-then-right");
|
|
415
|
+
const wrongThenRight = await run("wrong-then-right", `
|
|
416
|
+
try { await runs.run("other", { agent: "echo", task: "Exact task", acceptance: false }); } catch {}
|
|
417
|
+
return runs.run("main", { agent: "echo", task: "Exact task", acceptance: false });
|
|
418
|
+
`, wrongThenRightPermit);
|
|
419
|
+
assert.equal(wrongThenRight.isError, true);
|
|
420
|
+
assert.match(wrongThenRight.content[0]?.text ?? "", /already consumed/);
|
|
421
|
+
assert.equal(workflowChildPermitConsumed(wrongThenRightPermit), true);
|
|
422
|
+
assert.equal(mockPi.callCount(), 3, "wrong-then-right must not spawn");
|
|
423
|
+
const fallback = await makeExecutor([makeAgent("echo", { model: "mock/primary", fallbackModels: ["mock/backup"] })]).executeDelegated(
|
|
424
|
+
"fallback",
|
|
425
|
+
{ async: false, workflowScript: script, delegatedWorkflowPermit: permitFor("fallback") },
|
|
426
|
+
new AbortController().signal,
|
|
427
|
+
undefined,
|
|
428
|
+
ctx,
|
|
429
|
+
);
|
|
430
|
+
assert.match(fallback.content[0]?.text ?? "", /does not support model fallback/);
|
|
431
|
+
assert.equal(mockPi.callCount(), 3);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("resolves workflow child profile context from its agent default", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
435
|
+
mockPi.onCall({ output: "Workflow child completed" });
|
|
436
|
+
const result = await makeExecutor([makeAgent("echo", { defaultContext: "fresh" })], { defaultSubagentContext: "fork" }).execute(
|
|
437
|
+
"workflow-profile-context",
|
|
438
|
+
{ async: false, workflowScript: `return runs.run("main", { agent: "echo", task: "Use profile context", context: "profile" });` },
|
|
439
|
+
new AbortController().signal,
|
|
440
|
+
undefined,
|
|
441
|
+
makeMinimalCtx(tempDir),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
445
|
+
assert.equal(result.details?.results?.[0]?.context, "fresh");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("reports a user-requested foreground detach without supervisor guidance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
449
|
+
mockPi.onCall({ steps: [{ delay: 500, jsonl: [events.assistantMessage("completed after user detach")] }] });
|
|
450
|
+
const state: SubagentState = {
|
|
451
|
+
baseCwd: tempDir,
|
|
452
|
+
currentSessionId: null,
|
|
453
|
+
asyncJobs: new Map(),
|
|
454
|
+
foregroundControls: new Map(),
|
|
455
|
+
lastForegroundControlId: null,
|
|
456
|
+
};
|
|
457
|
+
const executor = createSubagentExecutor!({
|
|
458
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
459
|
+
state,
|
|
460
|
+
config: {},
|
|
461
|
+
asyncByDefault: false,
|
|
462
|
+
tempArtifactsDir: tempDir,
|
|
463
|
+
getSubagentSessionRoot: () => path.join(tempDir, ".pi-subagents", "sessions"),
|
|
464
|
+
expandTilde: (value: string) => value,
|
|
465
|
+
discoverAgents: () => ({ agents: [makeAgent("echo")] }),
|
|
466
|
+
allowMutatingManagementActions: true,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const pending = executor.execute(
|
|
470
|
+
"user-detach-guidance",
|
|
471
|
+
{ agent: "echo", task: "Keep working", async: false },
|
|
472
|
+
new AbortController().signal,
|
|
473
|
+
undefined,
|
|
474
|
+
makeMinimalCtx(tempDir),
|
|
475
|
+
);
|
|
476
|
+
let control = state.lastForegroundControlId ? state.foregroundControls.get(state.lastForegroundControlId) : undefined;
|
|
477
|
+
for (let attempt = 0; attempt < 100 && !control?.detach; attempt++) {
|
|
478
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
479
|
+
control = state.lastForegroundControlId ? state.foregroundControls.get(state.lastForegroundControlId) : undefined;
|
|
480
|
+
}
|
|
481
|
+
assert.ok(control?.detach, "foreground detach control should become available");
|
|
482
|
+
assert.equal(control.detach(), true);
|
|
483
|
+
|
|
484
|
+
const result = await pending;
|
|
485
|
+
const text = result.content.map((part) => part.type === "text" ? part.text : "").join("\n");
|
|
486
|
+
assert.equal(result.details.results[0]?.detachedReason, "user request");
|
|
487
|
+
assert.match(text, /Detached at user request/);
|
|
488
|
+
assert.match(text, /bg_wait\(\{ id: "[^"]+", nonBlocking: true \}\)/);
|
|
489
|
+
assert.doesNotMatch(text, /intercom coordination|supervisor request|Wait with bg_wait/);
|
|
490
|
+
assert.doesNotMatch(text, /bg_wait\(\{ id: "[^"]+" \}\)/);
|
|
491
|
+
|
|
492
|
+
let terminalChild = state.foregroundRuns?.get(control.runId)?.children[0];
|
|
493
|
+
for (let attempt = 0; attempt < 250 && terminalChild?.status !== "completed"; attempt++) {
|
|
494
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
495
|
+
terminalChild = state.foregroundRuns?.get(control.runId)?.children[0];
|
|
496
|
+
}
|
|
497
|
+
assert.equal(terminalChild?.status, "completed", "detached child should reach its terminal callback before teardown");
|
|
498
|
+
assert.equal(terminalChild.finalOutput, "completed after user detach");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("rejects action='single' with execution fields", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
502
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
503
|
+
const result = await executor.executePublic("single-alias", { action: "single", agent: "echo", task: "work" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
504
|
+
assert.equal(result.isError, true);
|
|
505
|
+
assert.match(result.content[0]?.text ?? "", /action='single' is not supported/);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("rejects internal fan-out fields from public workflows", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
509
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
510
|
+
for (const params of [
|
|
511
|
+
{ workflowScript: `return runs.run("main", { agent: "echo", task: "work" })`, runFanoutBudget: { version: 1 } },
|
|
512
|
+
{ workflowScript: `return runs.run("main", { agent: "echo", task: "work" })`, runFanoutAdmitted: true },
|
|
513
|
+
] as const) {
|
|
514
|
+
const result = await executor.executePublic("private-fanout", params, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
515
|
+
assert.equal(result.isError, true);
|
|
516
|
+
assert.match(result.content[0]?.text ?? "", /does not accept internal run fan-out fields/);
|
|
517
|
+
}
|
|
518
|
+
assert.equal(mockPi.callCount(), 0);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("runs isolation none outside Git and keeps worktree isolation strict", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
522
|
+
assert.equal(fs.existsSync(path.join(tempDir, ".git")), false);
|
|
523
|
+
mockPi.onCall({ output: "shared cwd" });
|
|
524
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
525
|
+
const script = `return runs.run("main", { agent: "echo", task: "work" })`;
|
|
526
|
+
|
|
527
|
+
const shared = await executor.executePublic(
|
|
528
|
+
"isolation-none",
|
|
529
|
+
{ async: false, isolation: "none", workflowScript: script },
|
|
530
|
+
new AbortController().signal,
|
|
531
|
+
undefined,
|
|
532
|
+
makeMinimalCtx(tempDir),
|
|
533
|
+
);
|
|
534
|
+
assert.equal(shared.isError, undefined, shared.content[0]?.text ?? "shared workflow failed");
|
|
535
|
+
|
|
536
|
+
const isolated = await executor.executePublic(
|
|
537
|
+
"isolation-worktree",
|
|
538
|
+
{ async: false, isolation: "worktree", workflowScript: script },
|
|
539
|
+
new AbortController().signal,
|
|
540
|
+
undefined,
|
|
541
|
+
makeMinimalCtx(tempDir),
|
|
542
|
+
);
|
|
543
|
+
assert.equal(isolated.isError, true);
|
|
544
|
+
assert.match(isolated.content[0]?.text ?? "", /worktree isolation requires a git repository/i);
|
|
545
|
+
assert.equal(mockPi.callCount(), 1);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("allows schedule.create to load its workflowScript target from a path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
549
|
+
let forwarded;
|
|
550
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, async (params) => {
|
|
551
|
+
forwarded = params;
|
|
552
|
+
return { content: [{ type: "text", text: "created" }], details: { mode: "management", results: [] } };
|
|
553
|
+
});
|
|
554
|
+
fs.writeFileSync(path.join(tempDir, "scheduled.js"), "return runs.run('main', { agent: 'echo' })");
|
|
555
|
+
|
|
556
|
+
const result = await executor.executePublic(
|
|
557
|
+
"schedule-create",
|
|
558
|
+
{ action: "schedule.create", id: "nightly", every: "1h", workflowScriptPath: "scheduled.js" },
|
|
559
|
+
new AbortController().signal,
|
|
560
|
+
undefined,
|
|
561
|
+
makeMinimalCtx(tempDir),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
assert.equal(result.isError, undefined);
|
|
565
|
+
assert.equal(result.content[0]?.text, "created");
|
|
566
|
+
assert.equal(forwarded?.workflowScript, "return runs.run('main', { agent: 'echo' })");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("validates workflow scripts without launching children or creating artifacts", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
570
|
+
const before = fs.readdirSync(tempDir).sort();
|
|
571
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
|
|
572
|
+
throw new Error("validate must not discover or launch agents");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const result = await executor.executePublic(
|
|
576
|
+
"offline-validation",
|
|
577
|
+
{ action: "validate", workflowScript: `return runs.run("bad key", { agent: "echo" });` },
|
|
578
|
+
new AbortController().signal,
|
|
579
|
+
undefined,
|
|
580
|
+
makeMinimalCtx(tempDir),
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
assert.equal(result.isError, true);
|
|
584
|
+
assert.deepEqual(JSON.parse(result.content[0]?.text ?? "null"), {
|
|
585
|
+
ok: false,
|
|
586
|
+
errors: [{ message: "runs.run key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.", line: 1, column: 17 }],
|
|
587
|
+
});
|
|
588
|
+
const invalidPreflight = await executor.executePublic(
|
|
589
|
+
"invalid-preflight",
|
|
590
|
+
{ workflowScript: `return runs.run("child", { agent: "echo" });`, preflight: { version: 1, lanes: [{ key: "bad key" }] } },
|
|
591
|
+
new AbortController().signal,
|
|
592
|
+
undefined,
|
|
593
|
+
makeMinimalCtx(tempDir),
|
|
594
|
+
);
|
|
595
|
+
assert.equal(invalidPreflight.isError, true);
|
|
596
|
+
assert.match(invalidPreflight.content[0]?.text ?? "", /preflight\.lanes\[0\]\.key/);
|
|
597
|
+
const offlinePreflight = await executor.executePublic(
|
|
598
|
+
"offline-preflight",
|
|
599
|
+
{ action: "validate", workflowScript: "return 1;", preflight: { version: 1, lanes: [{ key: "bad key" }] } },
|
|
600
|
+
new AbortController().signal,
|
|
601
|
+
undefined,
|
|
602
|
+
makeMinimalCtx(tempDir),
|
|
603
|
+
);
|
|
604
|
+
assert.equal(offlinePreflight.isError, true);
|
|
605
|
+
const offlinePreflightValidation = JSON.parse(offlinePreflight.content[0]?.text ?? "null") as { ok?: boolean; errors?: unknown[] };
|
|
606
|
+
assert.equal(offlinePreflightValidation.ok, false);
|
|
607
|
+
assert.match(JSON.stringify(offlinePreflightValidation.errors ?? []), /preflight\.lanes\[0\]\.key/);
|
|
608
|
+
assert.equal(mockPi.callCount(), 0);
|
|
609
|
+
assert.deepEqual(fs.readdirSync(tempDir).sort(), before);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("rejects invalid public workflow acceptance defaults before mission or script work", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
613
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
614
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
615
|
+
const params = { async: false, workflowScript: `return "workflow-default-ran";` };
|
|
616
|
+
const projectBefore = fs.readdirSync(tempDir, { recursive: true });
|
|
617
|
+
const agentBefore = fs.readdirSync(agentDir, { recursive: true });
|
|
618
|
+
const rejected = await executor.executePublic("invalid-workflow-default", { ...params, acceptance: true }, new AbortController().signal, undefined, ctx);
|
|
619
|
+
assert.equal(rejected.isError, true);
|
|
620
|
+
assert.match(rejected.content[0]?.text ?? "", /acceptance must be a string level, false, or an object/);
|
|
621
|
+
assert.equal(rejected.details.workflow, undefined, "invalid default must not execute the workflow");
|
|
622
|
+
assert.deepEqual(fs.readdirSync(tempDir, { recursive: true }), projectBefore, "invalid default must not create mission or workflow files");
|
|
623
|
+
assert.deepEqual(fs.readdirSync(agentDir, { recursive: true }), agentBefore, "invalid default must not create mission index files");
|
|
624
|
+
const accepted = await executor.executePublic("false-workflow-default", { ...params, acceptance: false }, new AbortController().signal, undefined, ctx);
|
|
625
|
+
assert.equal(accepted.isError, undefined, accepted.content[0]?.text);
|
|
626
|
+
assert.match(accepted.content[0]?.text ?? "", /workflow-default-ran/);
|
|
627
|
+
assert.equal(mockPi.callCount(), 0);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("runs a workflow host command without launching a child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
631
|
+
const scriptPath = path.join(tempDir, "host-command.cjs");
|
|
632
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("host command passed\\n");`);
|
|
633
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
634
|
+
const result = await executor.execute(
|
|
635
|
+
"host-command",
|
|
636
|
+
{
|
|
637
|
+
async: false,
|
|
638
|
+
output: "reports/host-command.log",
|
|
639
|
+
workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "reports/host-command.log", role: "ci", provider: "local" });`,
|
|
640
|
+
},
|
|
641
|
+
new AbortController().signal,
|
|
642
|
+
undefined,
|
|
643
|
+
makeMinimalCtx(tempDir),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "host command workflow failed");
|
|
647
|
+
assert.equal(mockPi.callCount(), 0);
|
|
648
|
+
const savedOutput = fs.readFileSync(path.join(tempDir, "reports", "host-command.log"), "utf8");
|
|
649
|
+
assert.match(savedOutput, /host command passed/);
|
|
650
|
+
assert.doesNotMatch(savedOutput, /Workflow completed/);
|
|
651
|
+
assert.deepEqual(result.details.workflow?.receipt?.hostSteps?.map(({ monitorKind, state, reportPath, exitCode }) => ({ monitorKind, state, reportPath, exitCode })), [{ monitorKind: "command", state: "done", reportPath: "reports/host-command.log", exitCode: 0 }]);
|
|
652
|
+
|
|
653
|
+
const failedScriptPath = path.join(tempDir, "host-command-failed.cjs");
|
|
654
|
+
fs.writeFileSync(failedScriptPath, `process.stderr.write("host command failed\\n"); process.exit(4);`);
|
|
655
|
+
const failed = await executor.execute(
|
|
656
|
+
"host-command-failed",
|
|
657
|
+
{
|
|
658
|
+
async: false,
|
|
659
|
+
workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(failedScriptPath)}`)}, timeoutMs: 5000, output: "reports/host-command-failed.log" });`,
|
|
660
|
+
},
|
|
661
|
+
new AbortController().signal,
|
|
662
|
+
undefined,
|
|
663
|
+
makeMinimalCtx(tempDir),
|
|
664
|
+
);
|
|
665
|
+
assert.equal(failed.isError, true);
|
|
666
|
+
assert.deepEqual(failed.details.workflow?.receipt?.hostSteps?.map(({ state, reasonCode, exitCode }) => ({ state, reasonCode, exitCode })), [{ state: "error", reasonCode: "command_failed", exitCode: 4 }]);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("resolves a named workflow resource internally and exposes its provenance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
670
|
+
mockPi.onCall({ output: "Named review completed" });
|
|
671
|
+
const result = await makeExecutor([makeAgent("reviewer")]).executePublic(
|
|
672
|
+
"named-review-resource",
|
|
673
|
+
{ workflow: "review", args: { task: "Review the change" }, async: false },
|
|
674
|
+
new AbortController().signal,
|
|
675
|
+
undefined,
|
|
676
|
+
makeMinimalCtx(tempDir),
|
|
677
|
+
);
|
|
678
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "named workflow failed");
|
|
679
|
+
assert.match(result.content[0]?.text ?? "", /Named review completed/);
|
|
680
|
+
assert.equal(result.details.workflow?.resource?.kind, "workflow");
|
|
681
|
+
assert.equal(result.details.workflow?.resource?.name, "review");
|
|
682
|
+
assert.equal(result.details.workflow?.resource?.invocation, "named");
|
|
683
|
+
assert.equal(result.details.workflow?.receipt?.resource?.id, result.details.workflow?.resource?.id);
|
|
684
|
+
assert.equal(mockPi.callCount(), 1);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("executes a registered mixed foreground workflow without widening session or child authority", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
688
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
689
|
+
// A persistent session's file path is not its SDK session ID.
|
|
690
|
+
ctx.sessionManager.getSessionFile = () => path.join(tempDir, "parent.jsonl");
|
|
691
|
+
const marker = path.join(tempDir, "registered-marker");
|
|
692
|
+
fs.writeFileSync(path.join(tempDir, "registered-check.cjs"), `require("node:fs").writeFileSync("registered-marker", "ran"); console.log("finite check passed");`);
|
|
693
|
+
const command = `${JSON.stringify(process.execPath)} registered-check.cjs`;
|
|
694
|
+
const host = `return await runs.host("check", ${JSON.stringify({ kind: "command", command, timeoutMs: 5000, output: "registered-check.log" })});`;
|
|
695
|
+
const script = `const child = await runs.run("review", { agent: "reviewer", task: "Review the change", capabilityCeiling: { version: 1, allowedAgents: ["reviewer"], sources: ["resource"] } }); if (!child.ok) throw new Error("Required review failed"); ${host}`;
|
|
696
|
+
const registration = registerWorkflowResource({ sessionId: ctx.sessionManager.getSessionId(), definition: {
|
|
697
|
+
name: "test.mixed", version: 1, resolve: () => ({ script, hostCommands: [{ key: "check", command }] }),
|
|
698
|
+
} });
|
|
699
|
+
const executor = makeExecutor([makeAgent("reviewer")]);
|
|
700
|
+
try {
|
|
701
|
+
const other = makeMinimalCtx(tempDir);
|
|
702
|
+
other.sessionManager.getSessionId = () => "other-session";
|
|
703
|
+
const wrongSession = await executor.executePublic("wrong-session", { workflow: "test.mixed", args: { sessionId: ctx.sessionManager.getSessionId() }, async: false }, new AbortController().signal, undefined, other);
|
|
704
|
+
assert.equal(wrongSession.isError, true);
|
|
705
|
+
assert.match(wrongSession.content[0]?.text ?? "", /Unknown workflow/);
|
|
706
|
+
assert.equal(fs.existsSync(marker), false);
|
|
707
|
+
assert.equal(mockPi.callCount(), 0);
|
|
708
|
+
|
|
709
|
+
const ceiling = registerSubagentCapabilityCeiling({ sessionId: ctx.sessionManager.getSessionFile()!, source: "test", ceiling: { allowedAgents: ["echo"] } });
|
|
710
|
+
try {
|
|
711
|
+
const denied = await executor.executePublic("ceiling-denied", { workflow: "test.mixed", async: false, capabilityCeiling: { version: 1, allowedAgents: ["reviewer"], sources: ["caller"] } }, new AbortController().signal, undefined, ctx);
|
|
712
|
+
assert.equal(denied.isError, true);
|
|
713
|
+
assert.match(denied.content[0]?.text ?? "", /Capability ceiling from caller, test does not allow agent 'reviewer'/);
|
|
714
|
+
assert.equal(fs.existsSync(marker), false);
|
|
715
|
+
assert.equal(mockPi.callCount(), 0);
|
|
716
|
+
} finally { ceiling.dispose(); }
|
|
717
|
+
|
|
718
|
+
mockPi.onCall({ output: "Registered review completed" });
|
|
719
|
+
const result = await executor.executePublic("registered-mixed", { workflow: "test.mixed", async: false }, new AbortController().signal, undefined, ctx);
|
|
720
|
+
assert.equal(result.isError, undefined, result.content[0]?.text);
|
|
721
|
+
assert.equal(mockPi.callCount(), 1);
|
|
722
|
+
assert.equal(fs.readFileSync(marker, "utf8"), "ran");
|
|
723
|
+
assert.match(fs.readFileSync(path.join(tempDir, "registered-check.log"), "utf8"), /finite check passed/);
|
|
724
|
+
assert.equal(result.details.workflow?.receipt?.resource?.name, "test.mixed");
|
|
725
|
+
assert.equal(result.details.workflow?.receipt?.state, "complete");
|
|
726
|
+
assert.equal(result.details.workflow?.receipt?.entries.review.agent, "reviewer");
|
|
727
|
+
assert.ok(result.details.workflow?.receipt?.entries.review.latestRunId);
|
|
728
|
+
assert.deepEqual(result.details.workflow?.receipt?.hostSteps?.map(({ id, state, exitCode }) => ({ id, state, exitCode })), [{ id: "check", state: "done", exitCode: 0 }]);
|
|
729
|
+
} finally { registration.dispose(); }
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("denies untrusted and out-of-grant registered commands before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
733
|
+
const marker = path.join(tempDir, "denied-marker");
|
|
734
|
+
fs.writeFileSync(path.join(tempDir, "denied-check.cjs"), `require("node:fs").writeFileSync("denied-marker", "ran");`);
|
|
735
|
+
const command = `${JSON.stringify(process.execPath)} denied-check.cjs`;
|
|
736
|
+
const script = `return await runs.host("check", ${JSON.stringify({ kind: "command", command, timeoutMs: 5000 })});`;
|
|
737
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
738
|
+
const executor = makeExecutor([]);
|
|
739
|
+
const registration = registerWorkflowResource({ sessionId: ctx.sessionManager.getSessionId(), definition: {
|
|
740
|
+
name: "test.denied", version: 1, resolve: () => ({ script, hostCommands: [{ key: "different-key", command }, { key: "check", command: `${command} unused` }] }),
|
|
741
|
+
} });
|
|
742
|
+
fs.writeFileSync(path.join(tempDir, "raw-workflow.js"), script);
|
|
743
|
+
try {
|
|
744
|
+
for (const params of [
|
|
745
|
+
{ workflowScript: script },
|
|
746
|
+
{ workflowScriptPath: "raw-workflow.js" },
|
|
747
|
+
{ workflow: "test.denied", workflowResourcePermit: {} },
|
|
748
|
+
{ workflow: "test.denied" },
|
|
749
|
+
]) {
|
|
750
|
+
const result = await executor.executePublic("denied-command", { ...params, async: false }, new AbortController().signal, undefined, ctx);
|
|
751
|
+
assert.equal(result.isError, true);
|
|
752
|
+
assert.match(result.content[0]?.text ?? "", /runs\.host is unavailable|provenance or permit|not allowed/);
|
|
753
|
+
assert.equal(fs.existsSync(marker), false);
|
|
754
|
+
assert.equal(mockPi.callCount(), 0);
|
|
755
|
+
}
|
|
756
|
+
} finally { registration.dispose(); }
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("denies host calls from raw public workflow scripts without resource authority", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
760
|
+
const result = await makeExecutor([makeAgent("echo")]).executePublic(
|
|
761
|
+
"raw-host-denied",
|
|
762
|
+
{ workflowScript: `return await runs.host("ci", { kind: "command", command: "npm test", timeoutMs: 1000 });`, async: false },
|
|
763
|
+
new AbortController().signal,
|
|
764
|
+
undefined,
|
|
765
|
+
makeMinimalCtx(tempDir),
|
|
766
|
+
);
|
|
767
|
+
assert.equal(result.isError, true);
|
|
768
|
+
assert.match(result.content[0]?.text ?? "", /runs\.host is unavailable/);
|
|
769
|
+
assert.equal(result.details.workflow?.resource, undefined);
|
|
770
|
+
assert.equal(result.details.workflow?.receipt?.resource, undefined);
|
|
771
|
+
assert.equal(mockPi.callCount(), 0);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("denies host calls when scheduled raw workflows replay", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
775
|
+
const markerPath = path.join(tempDir, "scheduled-host-marker.txt");
|
|
776
|
+
const script = `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "ran");`;
|
|
777
|
+
const result = await makeExecutor([makeAgent("echo")]).executeScheduled(
|
|
778
|
+
"scheduled-raw-host-denied",
|
|
779
|
+
{
|
|
780
|
+
workflowScript: `return await runs.host("ci", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`)}, timeoutMs: 1000 });`,
|
|
781
|
+
async: false,
|
|
782
|
+
scheduleOrigin: { id: "nightly" },
|
|
783
|
+
},
|
|
784
|
+
new AbortController().signal,
|
|
785
|
+
makeMinimalCtx(tempDir),
|
|
786
|
+
);
|
|
787
|
+
assert.equal(result.isError, true);
|
|
788
|
+
assert.match(result.content[0]?.text ?? "", /runs\.host is unavailable/);
|
|
789
|
+
assert.equal(fs.existsSync(markerPath), false);
|
|
790
|
+
assert.equal(result.details.workflow?.resource, undefined);
|
|
791
|
+
assert.equal(mockPi.callCount(), 0);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it("admits only the host command granted by a named workflow resource", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
795
|
+
const result = await makeExecutor([makeAgent("echo")]).executePublic(
|
|
796
|
+
"named-ci-resource",
|
|
797
|
+
{ workflow: "run-ci", args: { command: "npm run typecheck", timeoutMs: 120_000 }, async: false },
|
|
798
|
+
new AbortController().signal,
|
|
799
|
+
undefined,
|
|
800
|
+
makeMinimalCtx(path.resolve(".")),
|
|
801
|
+
);
|
|
802
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "named CI workflow failed");
|
|
803
|
+
assert.equal(result.details.workflow?.resource?.name, "run-ci");
|
|
804
|
+
assert.equal(result.details.workflow?.receipt?.resource?.name, "run-ci");
|
|
805
|
+
assert.deepEqual(result.details.workflow?.receipt?.hostSteps?.map(({ id, state, exitCode }) => ({ id, state, exitCode })), [{ id: "ci", state: "done", exitCode: 0 }]);
|
|
806
|
+
assert.equal(mockPi.callCount(), 0);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("explains the cwd workaround instead of launching a host step", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
810
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
811
|
+
const result = await executor.executePublic(
|
|
812
|
+
"host-command-cwd",
|
|
813
|
+
{
|
|
814
|
+
async: false,
|
|
815
|
+
workflowScript: `return await runs.host("tests", { kind: "command", command: "npm test", timeoutMs: 5000, cwd: "/tmp" });`,
|
|
816
|
+
},
|
|
817
|
+
new AbortController().signal,
|
|
818
|
+
undefined,
|
|
819
|
+
makeMinimalCtx(tempDir),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
assert.equal(result.isError, true);
|
|
823
|
+
assert.match(result.content[0]?.text ?? "", /does not accept per-step cwd.*workflow cwd.*outer subagent request.*cd \/path\/to\/worktree/);
|
|
824
|
+
assert.equal(mockPi.callCount(), 0);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("rejects a child output claimed by an earlier host command", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
828
|
+
const scriptPath = path.join(tempDir, "host-output-owner.cjs");
|
|
829
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("host owns output\\n");`);
|
|
830
|
+
const sharedOutput = path.join(tempDir, "reports", "shared.log");
|
|
831
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
832
|
+
const result = await executor.execute(
|
|
833
|
+
"host-output-collision",
|
|
834
|
+
{
|
|
835
|
+
async: false,
|
|
836
|
+
workflowScript: `await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "reports/shared.log" }); return runs.run("child", { agent: "echo", task: "unused", output: ${JSON.stringify(sharedOutput)} });`,
|
|
837
|
+
},
|
|
838
|
+
new AbortController().signal,
|
|
839
|
+
undefined,
|
|
840
|
+
makeMinimalCtx(tempDir),
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
assert.match(result.content[0]?.text ?? "", /output path is already claimed|resolve output to the same path/);
|
|
844
|
+
assert.equal(mockPi.callCount(), 0);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("rejects host and child output aliases through symlinks", { skip: !createSubagentExecutor || process.platform === "win32" ? "symlink output aliases are not portable on Windows CI" : undefined }, async () => {
|
|
848
|
+
const scriptPath = path.join(tempDir, "host-output-alias-owner.cjs");
|
|
849
|
+
const reportsDir = path.join(tempDir, "reports");
|
|
850
|
+
fs.mkdirSync(reportsDir);
|
|
851
|
+
fs.symlinkSync(reportsDir, path.join(tempDir, "linked-reports"), "dir");
|
|
852
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("host owns output alias\\n");`);
|
|
853
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
854
|
+
const result = await executor.execute(
|
|
855
|
+
"host-output-alias-collision",
|
|
856
|
+
{
|
|
857
|
+
async: false,
|
|
858
|
+
workflowScript: `await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "linked-reports/shared.log" }); return runs.run("child", { agent: "echo", task: "unused", output: ${JSON.stringify(path.join(reportsDir, "shared.log"))} });`,
|
|
859
|
+
},
|
|
860
|
+
new AbortController().signal,
|
|
861
|
+
undefined,
|
|
862
|
+
makeMinimalCtx(tempDir),
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
assert.match(result.content[0]?.text ?? "", /output path is already claimed|resolve output to the same path/);
|
|
866
|
+
assert.equal(mockPi.callCount(), 0);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("rejects host output aliases created after claim registration", { skip: !createSubagentExecutor || process.platform === "win32" ? "symlink output aliases are not portable on Windows CI" : undefined }, async () => {
|
|
870
|
+
const reportsDir = path.join(tempDir, "reports");
|
|
871
|
+
fs.mkdirSync(reportsDir);
|
|
872
|
+
const firstScriptPath = path.join(tempDir, "host-output-first.cjs");
|
|
873
|
+
const aliasScriptPath = path.join(tempDir, "host-output-alias.cjs");
|
|
874
|
+
fs.writeFileSync(firstScriptPath, `process.stdout.write("first evidence\\n");`);
|
|
875
|
+
fs.writeFileSync(aliasScriptPath, `const fs = require("node:fs"); fs.rmSync(${JSON.stringify(path.join(tempDir, "late-link"))}, { recursive: true, force: true }); fs.symlinkSync(${JSON.stringify(reportsDir)}, ${JSON.stringify(path.join(tempDir, "late-link"))}, "dir"); process.stdout.write("second evidence\\n");`);
|
|
876
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
877
|
+
"host-output-late-alias-collision",
|
|
878
|
+
{
|
|
879
|
+
async: false,
|
|
880
|
+
workflowScript: `await runs.host("first", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(firstScriptPath)}`)}, timeoutMs: 5000, output: "reports/shared.log" }); return await runs.host("second", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(aliasScriptPath)}`)}, timeoutMs: 5000, output: "late-link/shared.log" });`,
|
|
881
|
+
},
|
|
882
|
+
new AbortController().signal,
|
|
883
|
+
undefined,
|
|
884
|
+
makeMinimalCtx(tempDir),
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
assert.equal(result.isError, true);
|
|
888
|
+
assert.match(result.content[0]?.text ?? "", /output path changed after it was claimed/);
|
|
889
|
+
assert.match(fs.readFileSync(path.join(reportsDir, "shared.log"), "utf-8"), /first evidence/);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("rejects child output aliases created after claim registration", { skip: !createSubagentExecutor || process.platform === "win32" ? "symlink output aliases are not portable on Windows CI" : undefined }, async () => {
|
|
893
|
+
const reportsDir = path.join(tempDir, "reports");
|
|
894
|
+
fs.mkdirSync(reportsDir);
|
|
895
|
+
const sharedOutput = path.join(reportsDir, "shared.log");
|
|
896
|
+
fs.writeFileSync(sharedOutput, "prior output\n", "utf-8");
|
|
897
|
+
const lateLink = path.join(tempDir, "late-link");
|
|
898
|
+
const claimedOutput = path.join(lateLink, "shared.log");
|
|
899
|
+
const releasePath = path.join(tempDir, "release-child-output");
|
|
900
|
+
mockPi.onCall({ waitForPath: releasePath, output: "child fallback output" });
|
|
901
|
+
|
|
902
|
+
const pending = makeExecutor([makeAgent("echo")]).executePublic(
|
|
903
|
+
"child-output-late-alias-collision",
|
|
904
|
+
{
|
|
905
|
+
async: false,
|
|
906
|
+
workflowScript: `return await runs.run("child", { agent: "echo", task: "unused", output: ${JSON.stringify(claimedOutput)} });`,
|
|
907
|
+
},
|
|
908
|
+
new AbortController().signal,
|
|
909
|
+
undefined,
|
|
910
|
+
makeMinimalCtx(tempDir),
|
|
911
|
+
);
|
|
912
|
+
for (let attempt = 0; attempt < 100 && mockPi.callCount() === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
913
|
+
if (mockPi.callCount() !== 1) {
|
|
914
|
+
fs.writeFileSync(releasePath, "go", "utf-8");
|
|
915
|
+
await pending.catch(() => undefined);
|
|
916
|
+
}
|
|
917
|
+
assert.equal(mockPi.callCount(), 1);
|
|
918
|
+
fs.symlinkSync(reportsDir, lateLink, "dir");
|
|
919
|
+
fs.writeFileSync(releasePath, "go", "utf-8");
|
|
920
|
+
|
|
921
|
+
const result = await pending;
|
|
922
|
+
const child = (result.details as { results?: Array<{ exitCode?: number; outputSaveError?: string; savedOutputPath?: string }> } | undefined)?.results?.[0];
|
|
923
|
+
assert.equal(child?.exitCode, 1);
|
|
924
|
+
assert.match(child?.outputSaveError ?? "", /Output path changed after it was claimed/);
|
|
925
|
+
assert.equal(child?.savedOutputPath, undefined);
|
|
926
|
+
assert.equal(fs.readFileSync(sharedOutput, "utf-8"), "prior output\n");
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("persists async host command status and receipt evidence", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
930
|
+
const scriptPath = path.join(tempDir, "async-host-command.cjs");
|
|
931
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("async host passed\\n");`);
|
|
932
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
933
|
+
const result = await executor.execute(
|
|
934
|
+
"async-host-command",
|
|
935
|
+
{
|
|
936
|
+
async: true,
|
|
937
|
+
mission: false,
|
|
938
|
+
workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "reports/async-host.log", role: "ci", provider: "local" });`,
|
|
939
|
+
},
|
|
940
|
+
new AbortController().signal,
|
|
941
|
+
undefined,
|
|
942
|
+
makeMinimalCtx(tempDir),
|
|
943
|
+
);
|
|
944
|
+
assert.ok(result.details.asyncDir);
|
|
945
|
+
const statusPath = path.join(result.details.asyncDir!, "status.json");
|
|
946
|
+
let status: { state?: string; workflowGraph?: { nodes?: Array<{ hostStep?: { monitorKind?: string; state?: string; role?: string; reportPath?: string; exitCode?: number | null; updatedAt?: number; deadlineAt?: number } }> } } = {};
|
|
947
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
948
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
949
|
+
if (status.state === "complete" || status.state === "failed") break;
|
|
950
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
951
|
+
}
|
|
952
|
+
assert.equal(status.state, "complete");
|
|
953
|
+
assert.deepEqual(status.workflowGraph?.nodes?.[0]?.hostStep, {
|
|
954
|
+
version: 1, kind: "host-step", monitorKind: "command", id: "tests", label: "tests", role: "ci", provider: "local", state: "done", verdict: "pass", detail: "async host passed", reportPath: "reports/async-host.log", exitCode: 0,
|
|
955
|
+
updatedAt: status.workflowGraph?.nodes?.[0]?.hostStep?.updatedAt,
|
|
956
|
+
deadlineAt: status.workflowGraph?.nodes?.[0]?.hostStep?.deadlineAt,
|
|
957
|
+
});
|
|
958
|
+
const receipt = JSON.parse(fs.readFileSync(path.join(result.details.asyncDir!, "workflow-receipt.json"), "utf8")) as { hostSteps?: Array<{ monitorKind?: string; state?: string; reportPath?: string }> };
|
|
959
|
+
assert.deepEqual(receipt.hostSteps?.map(({ monitorKind, state, reportPath }) => ({ monitorKind, state, reportPath })), [{ monitorKind: "command", state: "done", reportPath: "reports/async-host.log" }]);
|
|
960
|
+
assert.match(fs.readFileSync(path.join(tempDir, "reports", "async-host.log"), "utf8"), /async host passed/);
|
|
961
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
|
|
962
|
+
if (result.details.asyncId) fs.rmSync(path.join(DIRS.results, `${result.details.asyncId}.json`), { force: true });
|
|
963
|
+
|
|
964
|
+
const failedScript = path.join(tempDir, "async-host-command-failed.cjs");
|
|
965
|
+
fs.writeFileSync(failedScript, `process.stderr.write("async host failed\\n"); process.exit(3);`);
|
|
966
|
+
const failed = await executor.execute(
|
|
967
|
+
"async-host-command-failed",
|
|
968
|
+
{
|
|
969
|
+
async: true,
|
|
970
|
+
mission: false,
|
|
971
|
+
workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(failedScript)}`)}, timeoutMs: 5000, output: "reports/async-host-failed.log" });`,
|
|
972
|
+
},
|
|
973
|
+
new AbortController().signal,
|
|
974
|
+
undefined,
|
|
975
|
+
makeMinimalCtx(tempDir),
|
|
976
|
+
);
|
|
977
|
+
assert.ok(failed.details.asyncDir);
|
|
978
|
+
const failedStatusPath = path.join(failed.details.asyncDir!, "status.json");
|
|
979
|
+
let failedStatus: { state?: string; workflowGraph?: { nodes?: Array<{ hostStep?: { state?: string; reasonCode?: string; exitCode?: number | null } }> } } = {};
|
|
980
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
981
|
+
failedStatus = JSON.parse(fs.readFileSync(failedStatusPath, "utf8"));
|
|
982
|
+
if (failedStatus.state === "complete" || failedStatus.state === "failed") break;
|
|
983
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
984
|
+
}
|
|
985
|
+
assert.equal(failedStatus.state, "failed");
|
|
986
|
+
assert.deepEqual(failedStatus.workflowGraph?.nodes?.map((node) => node.hostStep && { state: node.hostStep.state, reasonCode: node.hostStep.reasonCode, exitCode: node.hostStep.exitCode }), [{ state: "error", reasonCode: "command_failed", exitCode: 3 }]);
|
|
987
|
+
const failedReceipt = JSON.parse(fs.readFileSync(path.join(failed.details.asyncDir!, "workflow-receipt.json"), "utf8")) as { state?: string; hostSteps?: Array<{ state?: string; reasonCode?: string; exitCode?: number | null }> };
|
|
988
|
+
assert.equal(failedReceipt.state, "failed");
|
|
989
|
+
assert.deepEqual(failedReceipt.hostSteps?.map(({ state, reasonCode, exitCode }) => ({ state, reasonCode, exitCode })), [{ state: "error", reasonCode: "command_failed", exitCode: 3 }]);
|
|
990
|
+
assert.match(fs.readFileSync(path.join(tempDir, "reports", "async-host-failed.log"), "utf8"), /async host failed/);
|
|
991
|
+
fs.rmSync(failed.details.asyncDir!, { recursive: true, force: true });
|
|
992
|
+
if (failed.details.asyncId) fs.rmSync(path.join(DIRS.results, `${failed.details.asyncId}.json`), { force: true });
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it("loads workflowScriptPath from the request cwd for validation without launching", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
996
|
+
const requestCwd = path.join(tempDir, "request-cwd");
|
|
997
|
+
fs.mkdirSync(requestCwd);
|
|
998
|
+
fs.writeFileSync(path.join(requestCwd, "workflow.js"), `return runs.run("bad key", { agent: "echo" });`);
|
|
999
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
|
|
1000
|
+
throw new Error("validate must not discover or launch agents");
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
const result = await executor.executePublic(
|
|
1004
|
+
"file-validation",
|
|
1005
|
+
{ action: "validate", cwd: "request-cwd", workflowScriptPath: "workflow.js" },
|
|
1006
|
+
new AbortController().signal,
|
|
1007
|
+
undefined,
|
|
1008
|
+
makeMinimalCtx(tempDir),
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
assert.equal(result.isError, true);
|
|
1012
|
+
assert.deepEqual(JSON.parse(result.content[0]?.text ?? "null"), {
|
|
1013
|
+
ok: false,
|
|
1014
|
+
errors: [{ message: "runs.run key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.", line: 1, column: 17 }],
|
|
1015
|
+
});
|
|
1016
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("reports missing and empty workflowScriptPath files before validation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1020
|
+
fs.writeFileSync(path.join(tempDir, "empty.js"), " \n");
|
|
1021
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
|
|
1022
|
+
throw new Error("file input errors must not discover or launch agents");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const missing = await executor.executePublic("missing-file", { action: "validate", workflowScriptPath: "missing.js" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
1026
|
+
assert.equal(missing.isError, true);
|
|
1027
|
+
assert.match(missing.content[0]?.text ?? "", /Failed to read workflowScriptPath.*missing\.js/);
|
|
1028
|
+
assert.doesNotMatch(missing.content[0]?.text ?? "", /validation failed|valid JavaScript/);
|
|
1029
|
+
|
|
1030
|
+
const empty = await executor.executePublic("empty-file", { action: "validate", workflowScriptPath: "empty.js" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
1031
|
+
assert.equal(empty.isError, true);
|
|
1032
|
+
assert.match(empty.content[0]?.text ?? "", /workflowScriptPath file .*empty\.js.* is empty/);
|
|
1033
|
+
assert.doesNotMatch(empty.content[0]?.text ?? "", /validation failed|valid JavaScript/);
|
|
1034
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it("executes a workflow loaded from workflowScriptPath", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1038
|
+
fs.writeFileSync(path.join(tempDir, "workflow.js"), `return runs.run("main", { agent: "echo", task: "from file" });`);
|
|
1039
|
+
mockPi.onCall({ output: "loaded workflow" });
|
|
1040
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1041
|
+
|
|
1042
|
+
const result = await executor.executePublic(
|
|
1043
|
+
"file-execution",
|
|
1044
|
+
{ async: false, workflowScriptPath: path.join(tempDir, "workflow.js"), preflight: { version: 1, coverage: "complete", lanes: [{ key: "main", mode: "mutation" }] } },
|
|
1045
|
+
new AbortController().signal,
|
|
1046
|
+
undefined,
|
|
1047
|
+
makeMinimalCtx(tempDir),
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "file workflow failed");
|
|
1051
|
+
assert.deepEqual(result.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "main", mode: "mutation" }] });
|
|
1052
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it("starts workflow scripts asynchronously with a portable internal run id", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1056
|
+
mockPi.onCall({ output: "async child done" });
|
|
1057
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
1058
|
+
const executor = makeExecutor([makeAgent("echo", { aliases: ["helper"], defaultAsync: true })], { missions: { globalIndex: false } }, false, undefined, true, asyncJobs);
|
|
1059
|
+
const workflowCwd = path.join(tempDir, "workflow-cwd");
|
|
1060
|
+
fs.mkdirSync(workflowCwd);
|
|
1061
|
+
const toolCallId = "call_demo|fc_demo";
|
|
1062
|
+
const context = makeMinimalCtx(tempDir);
|
|
1063
|
+
context.sessionManager.getSessionFile = () => path.join(tempDir, "parent-session.jsonl");
|
|
1064
|
+
|
|
1065
|
+
const result = await executor.execute(
|
|
1066
|
+
toolCallId,
|
|
1067
|
+
{
|
|
1068
|
+
cwd: workflowCwd,
|
|
1069
|
+
workflowScript: `emit("starting"); await runs.run("work", { agent: "helper", label: "Run async child", phase: "Execution", task: "Async work" }); return { answer: 42 };`,
|
|
1070
|
+
preflight: { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] },
|
|
1071
|
+
mission: { summary: "Review the active backlog", labels: ["github-backlog", "review"] },
|
|
1072
|
+
},
|
|
1073
|
+
new AbortController().signal,
|
|
1074
|
+
undefined,
|
|
1075
|
+
context,
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
assert.equal(result.isError, undefined);
|
|
1079
|
+
assert.equal(result.details.mode, "workflow");
|
|
1080
|
+
assert.equal(result.details.toolCallId, toolCallId);
|
|
1081
|
+
assert.ok(result.details.asyncId);
|
|
1082
|
+
const workflowRunId = result.details.asyncId;
|
|
1083
|
+
assert.equal(result.details.runId, workflowRunId);
|
|
1084
|
+
assert.deepEqual(result.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
1085
|
+
assert.notEqual(workflowRunId, toolCallId);
|
|
1086
|
+
assert.match(workflowRunId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
1087
|
+
assert.equal(path.basename(result.details.asyncDir!), workflowRunId);
|
|
1088
|
+
assert.equal(asyncJobs.has(workflowRunId), true);
|
|
1089
|
+
assert.equal(asyncJobs.get(workflowRunId)?.cwd, workflowCwd);
|
|
1090
|
+
assert.equal(asyncJobs.get(workflowRunId)?.sessionRoot, path.join(tempDir, ".pi-subagents", "sessions"));
|
|
1091
|
+
assert.deepEqual(asyncJobs.get(workflowRunId)?.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
1092
|
+
assert.equal(asyncJobs.has(toolCallId), false);
|
|
1093
|
+
assert.equal(fs.existsSync(path.join(DIRS.async, toolCallId)), false);
|
|
1094
|
+
assert.match(result.content[0]?.text ?? "", /Preflight: v1 · complete · 1 lane/);
|
|
1095
|
+
assert.match(result.content[0]?.text ?? "", /Async workflow/);
|
|
1096
|
+
const statusPath = path.join(result.details.asyncDir!, "status.json");
|
|
1097
|
+
let status: { runId?: string; toolCallId?: string; cwd?: string; sessionRoot?: string; state?: string; preflight?: unknown; steps?: Array<{ agent?: string; sessionName?: string; label?: string; phase?: string; workflowKey?: string; parentWorkflowRunId?: string; async?: boolean }>; workflow?: { value?: unknown; emits?: unknown[]; trace?: Array<{ key?: string; agent?: string; label?: string; phase?: string; state?: string }> } } = {};
|
|
1098
|
+
for (let attempt = 0; attempt < 300; attempt++) {
|
|
1099
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
1100
|
+
if (status.state === "complete" || status.state === "failed") break;
|
|
1101
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1102
|
+
}
|
|
1103
|
+
assert.equal(status.state, "complete");
|
|
1104
|
+
assert.equal(status.runId, workflowRunId);
|
|
1105
|
+
assert.equal(status.toolCallId, toolCallId);
|
|
1106
|
+
assert.equal(status.cwd, workflowCwd);
|
|
1107
|
+
assert.equal(status.sessionRoot, path.join(tempDir, ".pi-subagents", "sessions"));
|
|
1108
|
+
assert.deepEqual(status.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
1109
|
+
const statusResult = await executor.execute(
|
|
1110
|
+
"status-preflight",
|
|
1111
|
+
{ action: "status", id: workflowRunId },
|
|
1112
|
+
new AbortController().signal,
|
|
1113
|
+
undefined,
|
|
1114
|
+
context,
|
|
1115
|
+
);
|
|
1116
|
+
assert.match(statusResult.content[0]?.text ?? "", /Plan: 1 lane · work/);
|
|
1117
|
+
assert.doesNotMatch(statusResult.content[0]?.text ?? "", /key \| mode \| decision \| claims \| expected output \| independence/);
|
|
1118
|
+
assert.deepEqual(statusResult.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
1119
|
+
assert.equal(status.steps?.length, 1);
|
|
1120
|
+
assert.deepEqual(status.steps?.map(({ agent, sessionName, label, phase, workflowKey }) => ({ agent, sessionName, label, phase, workflowKey })), [
|
|
1121
|
+
{ agent: "echo", sessionName: "echo: Async work", label: "Run async child", phase: "Execution", workflowKey: "work" },
|
|
1122
|
+
]);
|
|
1123
|
+
assert.ok(status.steps?.every((step) => step.parentWorkflowRunId === workflowRunId));
|
|
1124
|
+
assert.equal(status.steps?.[0]?.async, true);
|
|
1125
|
+
assert.deepEqual(status.workflow?.value, { answer: 42 });
|
|
1126
|
+
assert.deepEqual(status.workflow?.emits, ["starting"]);
|
|
1127
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1128
|
+
assert.ok(status.workflow?.trace?.some((entry) => entry.key === "work" && entry.agent === "echo" && entry.label === "Run async child" && entry.phase === "Execution" && entry.state === "completed"));
|
|
1129
|
+
const traceEvents = fs.readFileSync(path.join(result.details.asyncDir!, "events.jsonl"), "utf-8")
|
|
1130
|
+
.trim()
|
|
1131
|
+
.split("\n")
|
|
1132
|
+
.map((line) => JSON.parse(line) as { type?: string; trace?: Array<{ key?: string; state?: string }> })
|
|
1133
|
+
.filter((event) => event.type === "subagent.workflow.trace");
|
|
1134
|
+
assert.equal(traceEvents.length, 2);
|
|
1135
|
+
assert.deepEqual(traceEvents[0]?.trace?.map(({ key, state }) => ({ key, state })), [{ key: "work", state: "started" }]);
|
|
1136
|
+
assert.deepEqual(traceEvents[1]?.trace?.map(({ key, state }) => ({ key, state })), [
|
|
1137
|
+
{ key: "work", state: "started" },
|
|
1138
|
+
{ key: "work", state: "completed" },
|
|
1139
|
+
]);
|
|
1140
|
+
const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
1141
|
+
const persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { id?: string; runId?: string; toolCallId?: string; agent?: string; cwd?: string; summary?: string; workflow?: { value?: unknown; receipt?: unknown }; workflowReceipt?: { path?: string; receipt?: { workflowRunId?: string; entries?: Record<string, { key?: string; agent?: string; latestRunId?: string; resumability?: { state?: string; reason?: string }; continuation?: { runIds?: string[] } }> } }; results?: Array<{ agent?: string; sessionName?: string; workflowKey?: string; runId?: string; output?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number } }> };
|
|
1142
|
+
assert.equal(persistedResult.id, workflowRunId);
|
|
1143
|
+
assert.equal(persistedResult.runId, workflowRunId);
|
|
1144
|
+
assert.equal(persistedResult.toolCallId, toolCallId);
|
|
1145
|
+
assert.equal(persistedResult.agent, "workflow");
|
|
1146
|
+
assert.equal(persistedResult.cwd, workflowCwd);
|
|
1147
|
+
assert.deepEqual(persistedResult.results?.map(({ agent, sessionName, workflowKey }) => ({ agent, sessionName, workflowKey })), [
|
|
1148
|
+
{ agent: "echo", sessionName: "echo: Async work", workflowKey: "work" },
|
|
1149
|
+
]);
|
|
1150
|
+
assert.deepEqual(persistedResult.results?.[0]?.usage, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.001, turns: 1 });
|
|
1151
|
+
assert.equal(readCall().runtime?.steerInbox, undefined, "in-process workflow children are steered through their session, not a file inbox");
|
|
1152
|
+
assert.equal(fs.existsSync(path.join(result.details.asyncDir!, "control", "workflow-foreground")), false);
|
|
1153
|
+
assert.match(persistedResult.summary ?? "", /Return: \{\n "answer": 42\n\}/);
|
|
1154
|
+
assert.deepEqual(persistedResult.workflow?.value, { answer: 42 });
|
|
1155
|
+
assert.equal(persistedResult.workflow?.receipt, undefined, "status/result workflow projection must stay receipt-free");
|
|
1156
|
+
assert.equal(persistedResult.workflowReceipt?.path, path.join(result.details.asyncDir!, "workflow-receipt.json"));
|
|
1157
|
+
assert.equal(persistedResult.workflowReceipt?.receipt?.workflowRunId, workflowRunId);
|
|
1158
|
+
assert.equal(persistedResult.workflowReceipt?.receipt?.entries?.work?.key, "work");
|
|
1159
|
+
assert.equal(persistedResult.workflowReceipt?.receipt?.entries?.work?.agent, "echo");
|
|
1160
|
+
assert.equal(persistedResult.workflowReceipt?.receipt?.entries?.work?.latestRunId, persistedResult.results?.[0]?.runId);
|
|
1161
|
+
const childAsyncDir = path.join(DIRS.async, persistedResult.results?.[0]?.runId ?? "missing");
|
|
1162
|
+
assert.equal(fs.existsSync(childAsyncDir), true);
|
|
1163
|
+
assert.deepEqual(persistedResult.workflowReceipt?.receipt?.entries?.work?.continuation?.runIds, [persistedResult.results?.[0]?.runId]);
|
|
1164
|
+
assert.deepEqual(persistedResult.workflowReceipt?.receipt?.entries?.work?.resumability, { state: "resumable" });
|
|
1165
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(persistedResult.workflowReceipt!.path!, "utf-8")), persistedResult.workflowReceipt?.receipt);
|
|
1166
|
+
assert.equal(fs.existsSync(path.join(DIRS.results, `${toolCallId}.json`)), false);
|
|
1167
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
|
|
1168
|
+
fs.rmSync(resultPath, { force: true });
|
|
1169
|
+
fs.rmSync(childAsyncDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("flushes async workflow assembly after cleanup once children are terminal", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1173
|
+
mockPi.onCall({ output: "child output" });
|
|
1174
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
1175
|
+
const workflowControllers = new Map<string, AbortController>();
|
|
1176
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, asyncJobs, workflowControllers);
|
|
1177
|
+
const started = await executor.execute(
|
|
1178
|
+
`workflow-reload-assembly-${Date.now()}`,
|
|
1179
|
+
{
|
|
1180
|
+
async: true,
|
|
1181
|
+
mission: false,
|
|
1182
|
+
workflowScript: `const child = await runs.run("work", { agent: "echo", task: "Finish child" }); let checksum = 0; for (let index = 0; index < 100000000; index += 1) checksum = (checksum + index) % 97; return { phase: "assembled", output: child.output, checksum };`,
|
|
1183
|
+
},
|
|
1184
|
+
new AbortController().signal,
|
|
1185
|
+
undefined,
|
|
1186
|
+
makeMinimalCtx(tempDir),
|
|
1187
|
+
);
|
|
1188
|
+
assert.equal(started.isError, undefined, started.content[0]?.text ?? "workflow launch failed");
|
|
1189
|
+
const workflowRunId = started.details.asyncId;
|
|
1190
|
+
assert.ok(workflowRunId);
|
|
1191
|
+
const asyncDir = started.details.asyncDir;
|
|
1192
|
+
assert.ok(asyncDir);
|
|
1193
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
1194
|
+
let childCompleted = false;
|
|
1195
|
+
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
1196
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as { workflow?: { trace?: Array<{ key?: string; state?: string }> } };
|
|
1197
|
+
childCompleted = status.workflow?.trace?.some((entry) => entry.key === "work" && entry.state === "completed") ?? false;
|
|
1198
|
+
if (childCompleted) break;
|
|
1199
|
+
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
1200
|
+
}
|
|
1201
|
+
assert.equal(childCompleted, true, "expected the child to settle before simulating session cleanup");
|
|
1202
|
+
const controller = workflowControllers.get(workflowRunId);
|
|
1203
|
+
assert.ok(controller, "expected a live workflow controller before simulated cleanup");
|
|
1204
|
+
controller.abort(new Error("Workflow stopped because the extension session was replaced or reloaded."));
|
|
1205
|
+
workflowControllers.clear();
|
|
1206
|
+
asyncJobs.clear();
|
|
1207
|
+
|
|
1208
|
+
let finalStatus: { state?: string; workflow?: { value?: unknown } } = {};
|
|
1209
|
+
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
1210
|
+
finalStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as typeof finalStatus;
|
|
1211
|
+
if (finalStatus.state === "complete" || finalStatus.state === "failed" || finalStatus.state === "stopped") break;
|
|
1212
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1213
|
+
}
|
|
1214
|
+
assert.equal(finalStatus.state, "complete");
|
|
1215
|
+
assert.deepEqual(finalStatus.workflow?.value, { phase: "assembled", output: "child output", checksum: 39 });
|
|
1216
|
+
const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
1217
|
+
const result = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { state?: string; workflow?: { value?: unknown } };
|
|
1218
|
+
assert.equal(result.state, "complete");
|
|
1219
|
+
assert.deepEqual(result.workflow?.value, finalStatus.workflow?.value);
|
|
1220
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
1221
|
+
fs.rmSync(resultPath, { force: true });
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it("delivers a terminal Darwin workflow failure after demand disappears", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1225
|
+
const state: SubagentState = {
|
|
1226
|
+
baseCwd: tempDir,
|
|
1227
|
+
currentSessionId: "session-1700",
|
|
1228
|
+
completionOwnerId: "owner-1700",
|
|
1229
|
+
asyncJobs: new Map(),
|
|
1230
|
+
foregroundControls: new Map(),
|
|
1231
|
+
lastForegroundControlId: null,
|
|
1232
|
+
completionSeen: new Map(),
|
|
1233
|
+
resultFileCoalescer: { schedule: () => false, clear: () => {} },
|
|
1234
|
+
};
|
|
1235
|
+
let pollCreated = false;
|
|
1236
|
+
const delivered: Array<{ id?: string; state?: string }> = [];
|
|
1237
|
+
const piEvents = createEventBus();
|
|
1238
|
+
const watcher = createResultWatcher({ events: piEvents }, state, DIRS.results, 60_000, {
|
|
1239
|
+
platform: "darwin",
|
|
1240
|
+
deliverIntercomResults: false,
|
|
1241
|
+
coalesceDelayMs: 0,
|
|
1242
|
+
hasDeliveryDemand: () => [...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running"),
|
|
1243
|
+
notifier: { deliver: async (result) => { delivered.push({ id: result.id, state: result.state }); return true; } },
|
|
1244
|
+
timers: {
|
|
1245
|
+
setTimeout,
|
|
1246
|
+
clearTimeout,
|
|
1247
|
+
setInterval: ((handler: () => void, delay?: number) => {
|
|
1248
|
+
assert.equal(delay, 3000);
|
|
1249
|
+
pollCreated = true;
|
|
1250
|
+
return { unref() {} } as NodeJS.Timeout;
|
|
1251
|
+
}) as typeof setInterval,
|
|
1252
|
+
clearInterval: (() => {}) as typeof clearInterval,
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1255
|
+
let asyncDir: string | undefined;
|
|
1256
|
+
let resultPath: string | undefined;
|
|
1257
|
+
try {
|
|
1258
|
+
const executor = createSubagentExecutor!({
|
|
1259
|
+
pi: { events: piEvents, getSessionName: () => undefined },
|
|
1260
|
+
state,
|
|
1261
|
+
config: {},
|
|
1262
|
+
asyncByDefault: false,
|
|
1263
|
+
tempArtifactsDir: tempDir,
|
|
1264
|
+
getSubagentSessionRoot: () => path.join(tempDir, ".pi-subagents", "sessions"),
|
|
1265
|
+
expandTilde: (value: string) => value,
|
|
1266
|
+
discoverAgents: () => ({ agents: [makeAgent("echo")] }),
|
|
1267
|
+
refreshResultDelivery: watcher.refreshResultDelivery,
|
|
1268
|
+
});
|
|
1269
|
+
const launchPromise = executor.execute(
|
|
1270
|
+
"darwin-immediate-workflow-failure",
|
|
1271
|
+
{ async: true, workflowScript: "{" },
|
|
1272
|
+
new AbortController().signal,
|
|
1273
|
+
undefined,
|
|
1274
|
+
{ ...makeMinimalCtx(tempDir), sessionManager: { getSessionId: () => "session-1700", getSessionFile: () => null } },
|
|
1275
|
+
);
|
|
1276
|
+
watcher.startResultWatcher();
|
|
1277
|
+
assert.equal(pollCreated, true, "expected Darwin demand polling to be armed");
|
|
1278
|
+
const launch = await launchPromise;
|
|
1279
|
+
assert.equal(launch.isError, undefined, launch.content[0]?.text ?? "workflow launch failed");
|
|
1280
|
+
const workflowRunId = launch.details.asyncId;
|
|
1281
|
+
assert.ok(workflowRunId);
|
|
1282
|
+
asyncDir = launch.details.asyncDir;
|
|
1283
|
+
resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
1284
|
+
for (let attempt = 0; attempt < 100 && (state.asyncJobs.get(workflowRunId)?.status !== "failed" || delivered.length === 0); attempt += 1) {
|
|
1285
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1286
|
+
}
|
|
1287
|
+
assert.equal(state.asyncJobs.get(workflowRunId)?.status, "failed");
|
|
1288
|
+
assert.equal([...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running"), false);
|
|
1289
|
+
assert.deepEqual(delivered, [{ id: workflowRunId, state: "failed" }], "terminal completion must not require a manual result refresh");
|
|
1290
|
+
} finally {
|
|
1291
|
+
watcher.stopResultWatcher();
|
|
1292
|
+
if (asyncDir) fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
1293
|
+
if (resultPath) fs.rmSync(resultPath, { force: true });
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("keeps script workflow phase during async auto-resume", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1298
|
+
mockPi.onCall({
|
|
1299
|
+
jsonl: [
|
|
1300
|
+
events.toolStart("read", { path: "src/index.ts" }),
|
|
1301
|
+
events.toolEnd("read"),
|
|
1302
|
+
events.toolResult("read", "file contents"),
|
|
1303
|
+
{
|
|
1304
|
+
type: "message_end",
|
|
1305
|
+
message: {
|
|
1306
|
+
role: "assistant",
|
|
1307
|
+
content: [],
|
|
1308
|
+
model: "openai-codex/gpt-5.6-luna",
|
|
1309
|
+
stopReason: "error",
|
|
1310
|
+
errorMessage: "This operation was aborted",
|
|
1311
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
1312
|
+
},
|
|
1313
|
+
},
|
|
1314
|
+
],
|
|
1315
|
+
exitCode: 1,
|
|
1316
|
+
});
|
|
1317
|
+
const releasePath = path.join(tempDir, "release-auto-resume-child");
|
|
1318
|
+
mockPi.onCall({ waitForPath: releasePath, output: "Recovered after workflow auto-resume" });
|
|
1319
|
+
const executor = makeExecutor([makeAgent("echo", { aliases: ["helper"] })]);
|
|
1320
|
+
const result = await executor.execute(
|
|
1321
|
+
"workflow-auto-resume-phase-status",
|
|
1322
|
+
{ workflowScript: `await runs.run("work", { agent: "helper", label: "Review current diff", phase: "Review", task: "Review the current diff" }); return { ok: true };` },
|
|
1323
|
+
new AbortController().signal,
|
|
1324
|
+
undefined,
|
|
1325
|
+
makeMinimalCtx(tempDir),
|
|
1326
|
+
);
|
|
1327
|
+
|
|
1328
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
1329
|
+
const statusPath = path.join(result.details.asyncDir!, "status.json");
|
|
1330
|
+
let status: { state?: string; steps?: Array<{ workflowKey?: string; phase?: string }> } = {};
|
|
1331
|
+
for (let attempt = 0; attempt < 100 && mockPi.callCount() < 2; attempt++) {
|
|
1332
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1333
|
+
}
|
|
1334
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1335
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
1336
|
+
if (status.state === "running" && status.steps?.some((step) => step.workflowKey === "work" && step.phase !== undefined)) break;
|
|
1337
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1338
|
+
}
|
|
1339
|
+
assert.equal(status.state, "running");
|
|
1340
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "work")?.phase, "Review");
|
|
1341
|
+
|
|
1342
|
+
fs.writeFileSync(releasePath, "go", "utf-8");
|
|
1343
|
+
// Await persisted top-level logical completion, not a child process-terminal event.
|
|
1344
|
+
status = JSON.parse(await waitForFileContent(statusPath, '\n "state": "complete"'));
|
|
1345
|
+
assert.equal(status.state, "complete");
|
|
1346
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "work")?.phase, "Review");
|
|
1347
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1348
|
+
|
|
1349
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
|
|
1350
|
+
fs.rmSync(path.join(DIRS.results, `${result.details.asyncId}.json`), { force: true });
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
it("runs an external CLI workflow child with subagents.defaultModel configured", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1354
|
+
const markerPath = path.join(tempDir, "external-started");
|
|
1355
|
+
const executor = makeExecutor([
|
|
1356
|
+
makeAgent("external", {
|
|
1357
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started"); process.stdout.write("external result")`] },
|
|
1358
|
+
model: "mock/default-model",
|
|
1359
|
+
modelSource: { type: "subagents.defaultModel", scope: "user", path: "/settings.json", model: "mock/default-model" },
|
|
1360
|
+
}),
|
|
1361
|
+
]);
|
|
1362
|
+
const ctx = { ...makeMinimalCtx(tempDir), model: { provider: "mock", id: "parent-model" } };
|
|
1363
|
+
const started = await executor.execute(
|
|
1364
|
+
`external-workflow-${Date.now()}`,
|
|
1365
|
+
{ workflowScript: `return await runs.run("external", { agent: "external", task: "Run external", async: true });` },
|
|
1366
|
+
new AbortController().signal,
|
|
1367
|
+
undefined,
|
|
1368
|
+
ctx,
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
assert.equal(started.isError, undefined);
|
|
1372
|
+
assert.ok(started.details.asyncId);
|
|
1373
|
+
const workflowResultPath = path.join(DIRS.results, `${started.details.asyncId}.json`);
|
|
1374
|
+
let workflowResult: { state?: string; results?: Array<{ output?: string; runId?: string }> } = {};
|
|
1375
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1376
|
+
if (fs.existsSync(workflowResultPath)) workflowResult = JSON.parse(fs.readFileSync(workflowResultPath, "utf-8"));
|
|
1377
|
+
if (workflowResult.state === "complete" || workflowResult.state === "failed") break;
|
|
1378
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1379
|
+
}
|
|
1380
|
+
assert.equal(workflowResult.state, "complete");
|
|
1381
|
+
assert.match(workflowResult.results?.[0]?.output ?? "", /Async: external/);
|
|
1382
|
+
assert.equal(await waitForFileContent(markerPath, "started"), "started");
|
|
1383
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1384
|
+
|
|
1385
|
+
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1386
|
+
fs.rmSync(workflowResultPath, { force: true });
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it("awaits omitted external CLI workflow children through their async result", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1390
|
+
const markerPath = path.join(tempDir, "external-awaited-started");
|
|
1391
|
+
const executor = makeExecutor([
|
|
1392
|
+
makeAgent("external", {
|
|
1393
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started"); process.stdout.write("awaited external result")`] },
|
|
1394
|
+
model: "mock/default-model",
|
|
1395
|
+
modelSource: { type: "subagents.defaultModel", scope: "user", path: "/settings.json", model: "mock/default-model" },
|
|
1396
|
+
}),
|
|
1397
|
+
]);
|
|
1398
|
+
const result = await executor.execute(
|
|
1399
|
+
"external-awaited-workflow",
|
|
1400
|
+
{ workflowScript: `return await runs.run("external", { agent: "external", task: "Run external" });`, async: false },
|
|
1401
|
+
new AbortController().signal,
|
|
1402
|
+
undefined,
|
|
1403
|
+
{ ...makeMinimalCtx(tempDir), model: { provider: "mock", id: "parent-model" } },
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
1407
|
+
assert.equal(result.details.mode, "workflow");
|
|
1408
|
+
assert.match(result.content[0]?.text ?? "", /awaited external result/);
|
|
1409
|
+
assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
|
|
1410
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it("starts omitted external CLI single-child calls in async mode", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1414
|
+
const markerPath = path.join(tempDir, "external-single-omitted-async-started");
|
|
1415
|
+
const executor = makeExecutor([
|
|
1416
|
+
makeAgent("external", {
|
|
1417
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started"); process.stdout.write("single async result")`] },
|
|
1418
|
+
model: "mock/default-model",
|
|
1419
|
+
modelSource: { type: "subagents.defaultModel", scope: "user", path: "/settings.json", model: "mock/default-model" },
|
|
1420
|
+
}),
|
|
1421
|
+
]);
|
|
1422
|
+
const result = await executor.execute(
|
|
1423
|
+
"external-single-omitted-async",
|
|
1424
|
+
{ agent: "external", task: "Run external" },
|
|
1425
|
+
new AbortController().signal,
|
|
1426
|
+
undefined,
|
|
1427
|
+
{ ...makeMinimalCtx(tempDir), model: { provider: "mock", id: "parent-model" } },
|
|
1428
|
+
);
|
|
1429
|
+
|
|
1430
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "launch failed");
|
|
1431
|
+
assert.ok(result.details.asyncId);
|
|
1432
|
+
assert.match(result.content[0]?.text ?? "", /Async: external/);
|
|
1433
|
+
assert.equal(await waitForFileContent(markerPath, "started"), "started");
|
|
1434
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1435
|
+
|
|
1436
|
+
const resultPath = path.join(DIRS.results, `${result.details.asyncId}.json`);
|
|
1437
|
+
let runResult: { state?: string } = {};
|
|
1438
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1439
|
+
if (fs.existsSync(resultPath)) runResult = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1440
|
+
if (runResult.state === "complete" || runResult.state === "failed") break;
|
|
1441
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1442
|
+
}
|
|
1443
|
+
assert.equal(runResult.state, "complete");
|
|
1444
|
+
|
|
1445
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1446
|
+
fs.rmSync(resultPath, { force: true });
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it("lets explicit fast false opt out external CLI agents from inherited fast mode", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1450
|
+
const markerPath = path.join(tempDir, "external-fast-false-started");
|
|
1451
|
+
const executor = makeExecutor([
|
|
1452
|
+
makeAgent("external", {
|
|
1453
|
+
fast: true,
|
|
1454
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started"); process.stdout.write("external fast false result")`] },
|
|
1455
|
+
}),
|
|
1456
|
+
]);
|
|
1457
|
+
|
|
1458
|
+
const rejected = await executor.execute(
|
|
1459
|
+
"external-fast-inherited",
|
|
1460
|
+
{ agent: "external", task: "Run external", async: true },
|
|
1461
|
+
new AbortController().signal,
|
|
1462
|
+
undefined,
|
|
1463
|
+
makeMinimalCtx(tempDir),
|
|
1464
|
+
);
|
|
1465
|
+
assert.equal(rejected.isError, true);
|
|
1466
|
+
assert.match(rejected.content[0]?.text ?? "", /does not support fast mode/);
|
|
1467
|
+
assert.equal(fs.existsSync(markerPath), false);
|
|
1468
|
+
|
|
1469
|
+
const result = await executor.execute(
|
|
1470
|
+
"external-fast-false",
|
|
1471
|
+
{ agent: "external", task: "Run external", async: true, fast: false },
|
|
1472
|
+
new AbortController().signal,
|
|
1473
|
+
undefined,
|
|
1474
|
+
makeMinimalCtx(tempDir),
|
|
1475
|
+
);
|
|
1476
|
+
|
|
1477
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "launch failed");
|
|
1478
|
+
assert.ok(result.details.asyncId);
|
|
1479
|
+
assert.equal(await waitForFileContent(markerPath, "started"), "started");
|
|
1480
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1481
|
+
|
|
1482
|
+
const resultPath = path.join(DIRS.results, `${result.details.asyncId}.json`);
|
|
1483
|
+
let runResult: { state?: string } = {};
|
|
1484
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1485
|
+
if (fs.existsSync(resultPath)) runResult = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1486
|
+
if (runResult.state === "complete" || runResult.state === "failed") break;
|
|
1487
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1488
|
+
}
|
|
1489
|
+
assert.equal(runResult.state, "complete");
|
|
1490
|
+
|
|
1491
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1492
|
+
fs.rmSync(resultPath, { force: true });
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it("runs external CLI agents with fallback models without registry validation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1496
|
+
const markerPath = path.join(tempDir, "external-fallback-started");
|
|
1497
|
+
const executor = makeExecutor([
|
|
1498
|
+
makeAgent("external", {
|
|
1499
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started")`] },
|
|
1500
|
+
fallbackModels: ["mock/fallback"],
|
|
1501
|
+
}),
|
|
1502
|
+
]);
|
|
1503
|
+
const result = await executor.execute(
|
|
1504
|
+
"external-fallback-model",
|
|
1505
|
+
{ agent: "external", task: "Run external", async: true },
|
|
1506
|
+
new AbortController().signal,
|
|
1507
|
+
undefined,
|
|
1508
|
+
{
|
|
1509
|
+
...makeMinimalCtx(tempDir),
|
|
1510
|
+
modelRegistry: { getAvailable: () => [{ provider: "other", id: "known" }] },
|
|
1511
|
+
},
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
assert.equal(result.isError, undefined);
|
|
1515
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /Unknown subagent model/);
|
|
1516
|
+
assert.equal(await waitForFileContent(markerPath, "started"), "started");
|
|
1517
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1518
|
+
|
|
1519
|
+
assert.ok(result.details.asyncId);
|
|
1520
|
+
const resultPath = path.join(DIRS.results, `${result.details.asyncId}.json`);
|
|
1521
|
+
let runResult: { state?: string } = {};
|
|
1522
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1523
|
+
if (fs.existsSync(resultPath)) runResult = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1524
|
+
if (runResult.state === "complete" || runResult.state === "failed") break;
|
|
1525
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1526
|
+
}
|
|
1527
|
+
assert.equal(runResult.state, "complete");
|
|
1528
|
+
|
|
1529
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1530
|
+
fs.rmSync(resultPath, { force: true });
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it("rejects external CLI fork context before fallback model validation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1534
|
+
const markerPath = path.join(tempDir, "external-fork-started");
|
|
1535
|
+
const parentSessionFile = path.join(mockPi.dir, "external-fork-parent.jsonl");
|
|
1536
|
+
fs.writeFileSync(parentSessionFile, `${JSON.stringify({ type: "session", version: 3, id: "parent", cwd: tempDir })}\n`, "utf-8");
|
|
1537
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
1538
|
+
Object.assign(ctx.sessionManager, {
|
|
1539
|
+
getSessionFile: () => parentSessionFile,
|
|
1540
|
+
getLeafId: () => "parent-leaf",
|
|
1541
|
+
openSession: () => ({
|
|
1542
|
+
createBranchedSession: () => parentSessionFile,
|
|
1543
|
+
}),
|
|
1544
|
+
});
|
|
1545
|
+
const executor = makeExecutor([
|
|
1546
|
+
makeAgent("external", {
|
|
1547
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started")`] },
|
|
1548
|
+
defaultContext: "fork",
|
|
1549
|
+
fallbackModels: ["mock/fallback"],
|
|
1550
|
+
}),
|
|
1551
|
+
]);
|
|
1552
|
+
const result = await executor.execute(
|
|
1553
|
+
"external-fork-fallback",
|
|
1554
|
+
{ agent: "external", task: "Run external", async: true },
|
|
1555
|
+
new AbortController().signal,
|
|
1556
|
+
undefined,
|
|
1557
|
+
{
|
|
1558
|
+
...ctx,
|
|
1559
|
+
modelRegistry: { getAvailable: () => [{ provider: "other", id: "known" }] },
|
|
1560
|
+
},
|
|
1561
|
+
);
|
|
1562
|
+
|
|
1563
|
+
assert.equal(result.isError, true);
|
|
1564
|
+
assert.match(result.content[0]?.text ?? "", /does not support: fork context/);
|
|
1565
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /Unknown subagent model/);
|
|
1566
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1567
|
+
assert.equal(fs.existsSync(markerPath), false);
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
it("rejects explicit model overrides for external CLI agents", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1571
|
+
const executor = makeExecutor([
|
|
1572
|
+
makeAgent("external", {
|
|
1573
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", "process.stdout.write('unreachable')"] },
|
|
1574
|
+
}),
|
|
1575
|
+
]);
|
|
1576
|
+
const result = await executor.execute(
|
|
1577
|
+
"external-explicit-model",
|
|
1578
|
+
{ agent: "external", task: "Run external", async: true, model: "mock/override" },
|
|
1579
|
+
new AbortController().signal,
|
|
1580
|
+
undefined,
|
|
1581
|
+
{
|
|
1582
|
+
...makeMinimalCtx(tempDir),
|
|
1583
|
+
modelRegistry: { getAvailable: () => [{ provider: "other", id: "known" }] },
|
|
1584
|
+
},
|
|
1585
|
+
);
|
|
1586
|
+
|
|
1587
|
+
assert.equal(result.isError, true);
|
|
1588
|
+
assert.match(result.content[0]?.text ?? "", /does not support: model override/);
|
|
1589
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /Unknown subagent model/);
|
|
1590
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it("rejects external CLI agent models that differ from inherited subagents.defaultModel", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1594
|
+
const executor = makeExecutor([
|
|
1595
|
+
makeAgent("external", {
|
|
1596
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", "process.stdout.write('unreachable')"] },
|
|
1597
|
+
model: "mock/override-model",
|
|
1598
|
+
modelSource: { type: "subagents.defaultModel", scope: "user", path: "/settings.json", model: "mock/default-model" },
|
|
1599
|
+
}),
|
|
1600
|
+
]);
|
|
1601
|
+
const result = await executor.execute(
|
|
1602
|
+
"external-agent-override-model",
|
|
1603
|
+
{ agent: "external", task: "Run external", async: true },
|
|
1604
|
+
new AbortController().signal,
|
|
1605
|
+
undefined,
|
|
1606
|
+
makeMinimalCtx(tempDir),
|
|
1607
|
+
);
|
|
1608
|
+
|
|
1609
|
+
assert.equal(result.isError, true);
|
|
1610
|
+
assert.match(result.content[0]?.text ?? "", /does not support: model override/);
|
|
1611
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it("rejects external CLI agent models that equal inherited subagents.defaultModel without provenance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1615
|
+
const executor = makeExecutor([
|
|
1616
|
+
makeAgent("external", {
|
|
1617
|
+
runner: { type: "external-cli", command: process.execPath, args: ["-e", "process.stdout.write('unreachable')"] },
|
|
1618
|
+
model: "mock/default-model",
|
|
1619
|
+
}),
|
|
1620
|
+
]);
|
|
1621
|
+
const result = await executor.execute(
|
|
1622
|
+
"external-agent-same-value-override-model",
|
|
1623
|
+
{ agent: "external", task: "Run external", async: true },
|
|
1624
|
+
new AbortController().signal,
|
|
1625
|
+
undefined,
|
|
1626
|
+
makeMinimalCtx(tempDir),
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
assert.equal(result.isError, true);
|
|
1630
|
+
assert.match(result.content[0]?.text ?? "", /does not support: model override/);
|
|
1631
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
it("projects live child activity into async workflow status", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1635
|
+
mockPi.onCall({
|
|
1636
|
+
steps: [
|
|
1637
|
+
{ jsonl: [events.toolStart("read", { path: "src/example.ts" })] },
|
|
1638
|
+
{ delay: 2_500, jsonl: [events.toolEnd("read"), events.toolResult("read", "contents")] },
|
|
1639
|
+
{ jsonl: [events.assistantMessage("Done")] },
|
|
1640
|
+
],
|
|
1641
|
+
});
|
|
1642
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
1643
|
+
const executor = makeExecutor([makeAgent("echo")], {
|
|
1644
|
+
control: {
|
|
1645
|
+
enabled: true,
|
|
1646
|
+
needsAttentionAfterMs: 100,
|
|
1647
|
+
activeNoticeAfterMs: 100,
|
|
1648
|
+
activeNoticeAfterTurns: 999_999,
|
|
1649
|
+
activeNoticeAfterTokens: 999_999,
|
|
1650
|
+
notifyOn: ["active_long_running", "needs_attention"],
|
|
1651
|
+
notifyChannels: ["event"],
|
|
1652
|
+
},
|
|
1653
|
+
}, false, undefined, true, asyncJobs);
|
|
1654
|
+
|
|
1655
|
+
const result = await executor.execute(
|
|
1656
|
+
"workflow-live-activity",
|
|
1657
|
+
{ workflowScript: `return runs.run("main", { agent: "echo", task: "Inspect the file" });` },
|
|
1658
|
+
new AbortController().signal,
|
|
1659
|
+
undefined,
|
|
1660
|
+
makeMinimalCtx(tempDir),
|
|
1661
|
+
);
|
|
1662
|
+
const { asyncId: workflowRunId, asyncDir } = result.details;
|
|
1663
|
+
assert.ok(workflowRunId);
|
|
1664
|
+
assert.ok(asyncDir);
|
|
1665
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
1666
|
+
const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
1667
|
+
const activeMarkerPath = path.join(DIRS.async, ACTIVE_RUN_INDEX_DIR, workflowRunId);
|
|
1668
|
+
assert.equal(fs.existsSync(activeMarkerPath), true);
|
|
1669
|
+
let liveStatus: AsyncStatus | undefined;
|
|
1670
|
+
const activityDeadline = Date.now() + 5_000;
|
|
1671
|
+
while (Date.now() < activityDeadline && !fs.existsSync(resultPath)) {
|
|
1672
|
+
const candidate = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
1673
|
+
if (candidate.activityState === "needs_attention" && candidate.steps?.[0]?.currentTool === "read") {
|
|
1674
|
+
liveStatus = candidate;
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
assert.ok(liveStatus, "expected workflow status to expose live child activity");
|
|
1681
|
+
assert.equal(liveStatus.activityState, "needs_attention");
|
|
1682
|
+
assert.equal(typeof liveStatus.lastActivityAt, "number");
|
|
1683
|
+
assert.equal(liveStatus.currentTool, "read");
|
|
1684
|
+
assert.match(liveStatus.currentPath ?? "", /src[/\\]example\.ts$/);
|
|
1685
|
+
assert.equal(liveStatus.toolCount, 1);
|
|
1686
|
+
assert.equal(liveStatus.steps?.[0]?.status, "running");
|
|
1687
|
+
assert.equal(liveStatus.steps?.[0]?.agent, "echo");
|
|
1688
|
+
assert.match(liveStatus.steps?.[0]?.sessionFile ?? "", /session\.jsonl$/);
|
|
1689
|
+
assert.equal(fs.existsSync(liveStatus.steps?.[0]?.sessionFile ?? ""), true);
|
|
1690
|
+
assert.equal(liveStatus.steps?.[0]?.activityState, "needs_attention");
|
|
1691
|
+
assert.equal(typeof liveStatus.steps?.[0]?.lastActivityAt, "number");
|
|
1692
|
+
assert.equal(liveStatus.steps?.[0]?.toolCount, 1);
|
|
1693
|
+
assert.equal(asyncJobs.get(workflowRunId)?.activityState, "needs_attention");
|
|
1694
|
+
assert.equal(asyncJobs.get(workflowRunId)?.steps?.[0]?.currentTool, "read");
|
|
1695
|
+
|
|
1696
|
+
const completionDeadline = Date.now() + 5_000;
|
|
1697
|
+
while (!fs.existsSync(resultPath)) {
|
|
1698
|
+
if (Date.now() > completionDeadline) assert.fail("Timed out waiting for async workflow completion");
|
|
1699
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1700
|
+
}
|
|
1701
|
+
assert.equal(fs.existsSync(activeMarkerPath), false);
|
|
1702
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
1703
|
+
fs.rmSync(resultPath, { force: true });
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
it("notifies the parent when an async workflow child needs attention", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1707
|
+
mockPi.onCall({
|
|
1708
|
+
steps: [
|
|
1709
|
+
{ jsonl: [events.toolStart("read", { path: "src/example.ts" }), events.toolEnd("read"), events.toolResult("read", "contents"), mockAssistantMessage("Started", "tool_use")] },
|
|
1710
|
+
{ delay: 2_500, jsonl: [events.assistantMessage("Done")] },
|
|
1711
|
+
],
|
|
1712
|
+
});
|
|
1713
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
1714
|
+
const piEvents = createEventBus();
|
|
1715
|
+
const controlPayloads: Array<{ event?: ControlEvent; source?: string }> = [];
|
|
1716
|
+
piEvents.on(SUBAGENT_CONTROL_EVENT, (payload) => {
|
|
1717
|
+
controlPayloads.push(payload as { event?: ControlEvent; source?: string });
|
|
1718
|
+
});
|
|
1719
|
+
const executor = makeExecutor([makeAgent("echo")], {
|
|
1720
|
+
control: {
|
|
1721
|
+
enabled: true,
|
|
1722
|
+
needsAttentionAfterMs: 100,
|
|
1723
|
+
activeNoticeAfterMs: 999_999,
|
|
1724
|
+
activeNoticeAfterTurns: 999_999,
|
|
1725
|
+
activeNoticeAfterTokens: 999_999,
|
|
1726
|
+
notifyOn: ["needs_attention"],
|
|
1727
|
+
notifyChannels: ["event"],
|
|
1728
|
+
},
|
|
1729
|
+
}, false, undefined, true, asyncJobs, undefined, undefined, piEvents);
|
|
1730
|
+
|
|
1731
|
+
const result = await executor.execute(
|
|
1732
|
+
"workflow-child-attention-notice",
|
|
1733
|
+
{ workflowScript: `return runs.run("stalled-review", { agent: "echo", task: "Inspect the file" });` },
|
|
1734
|
+
new AbortController().signal,
|
|
1735
|
+
undefined,
|
|
1736
|
+
makeMinimalCtx(tempDir),
|
|
1737
|
+
);
|
|
1738
|
+
const { asyncId: workflowRunId, asyncDir } = result.details;
|
|
1739
|
+
assert.ok(workflowRunId);
|
|
1740
|
+
assert.ok(asyncDir);
|
|
1741
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
1742
|
+
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
1743
|
+
const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
1744
|
+
let liveStatus: AsyncStatus | undefined;
|
|
1745
|
+
const activityDeadline = Date.now() + 5_000;
|
|
1746
|
+
while (Date.now() < activityDeadline && !fs.existsSync(resultPath)) {
|
|
1747
|
+
if (fs.existsSync(statusPath)) {
|
|
1748
|
+
const candidate = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
1749
|
+
if (candidate.activityState === "needs_attention" && !candidate.steps?.[0]?.currentTool) {
|
|
1750
|
+
liveStatus = candidate;
|
|
1751
|
+
break;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
assert.ok(liveStatus, "expected workflow status to expose idle child attention");
|
|
1758
|
+
assert.equal(liveStatus.activityState, "needs_attention");
|
|
1759
|
+
assert.equal(liveStatus.steps?.[0]?.activityState, "needs_attention");
|
|
1760
|
+
assert.equal(liveStatus.steps?.[0]?.workflowKey, "stalled-review");
|
|
1761
|
+
|
|
1762
|
+
const attentionPayload = controlPayloads.find((payload) => payload.event?.type === "needs_attention");
|
|
1763
|
+
assert.ok(attentionPayload, "expected a live parent control event");
|
|
1764
|
+
assert.equal(attentionPayload.source, "async");
|
|
1765
|
+
assert.equal(attentionPayload.event?.workflowKey, "stalled-review");
|
|
1766
|
+
assert.equal(attentionPayload.event?.reason, "idle");
|
|
1767
|
+
const sent: Array<{ options?: { triggerTurn?: boolean } }> = [];
|
|
1768
|
+
handleSubagentControlNotice({
|
|
1769
|
+
pi: { sendMessage(_message, options) { sent.push({ options: options as { triggerTurn?: boolean } }); } },
|
|
1770
|
+
state: { asyncJobs } as SubagentState,
|
|
1771
|
+
visibleControlNotices: new Set(),
|
|
1772
|
+
details: { event: attentionPayload.event!, source: "async" },
|
|
1773
|
+
});
|
|
1774
|
+
assert.equal(sent.length, 1);
|
|
1775
|
+
assert.deepEqual(sent[0]?.options, { triggerTurn: true });
|
|
1776
|
+
|
|
1777
|
+
assert.equal(fs.existsSync(eventsPath), true);
|
|
1778
|
+
const controlRecords = fs.readFileSync(eventsPath, "utf-8")
|
|
1779
|
+
.split("\n")
|
|
1780
|
+
.filter((line) => line.trim())
|
|
1781
|
+
.map((line) => JSON.parse(line) as { type?: string; event?: ControlEvent; runId?: string })
|
|
1782
|
+
.filter((record) => record.type === "subagent.control");
|
|
1783
|
+
const persisted = controlRecords.find((record) => record.event?.type === "needs_attention");
|
|
1784
|
+
assert.ok(persisted, "expected a persisted workflow control event");
|
|
1785
|
+
assert.equal(persisted.runId, workflowRunId);
|
|
1786
|
+
assert.equal(persisted.event?.workflowKey, "stalled-review");
|
|
1787
|
+
assert.equal(controlRecords.filter((record) => record.event?.type === "needs_attention").length, 1);
|
|
1788
|
+
|
|
1789
|
+
const completionDeadline = Date.now() + 5_000;
|
|
1790
|
+
while (!fs.existsSync(resultPath)) {
|
|
1791
|
+
if (Date.now() > completionDeadline) assert.fail("Timed out waiting for async workflow completion");
|
|
1792
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1793
|
+
}
|
|
1794
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
1795
|
+
fs.rmSync(resultPath, { force: true });
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("rejects an invalid async workflow usage budget before creating run state", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1799
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
1800
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, asyncJobs);
|
|
1801
|
+
const runId = `scripted-workflow-invalid-budget-${Date.now()}`;
|
|
1802
|
+
|
|
1803
|
+
const result = await executor.execute(
|
|
1804
|
+
runId,
|
|
1805
|
+
{ workflowScript: `return "unreachable";`, usageBudget: { tokens: { hard: 0 } } },
|
|
1806
|
+
new AbortController().signal,
|
|
1807
|
+
undefined,
|
|
1808
|
+
makeMinimalCtx(tempDir),
|
|
1809
|
+
);
|
|
1810
|
+
|
|
1811
|
+
assert.equal(result.isError, true);
|
|
1812
|
+
assert.match(result.content[0]?.text ?? "", /usageBudget\.tokens\.hard must be a positive number/);
|
|
1813
|
+
assert.equal(result.details.asyncId, undefined);
|
|
1814
|
+
assert.equal(asyncJobs.has(runId), false);
|
|
1815
|
+
assert.equal(fs.existsSync(path.join(DIRS.async, runId)), false);
|
|
1816
|
+
assert.equal(fs.existsSync(path.join(DIRS.results, `${runId}.json`)), false);
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it("rejects async child launches from budgeted async workflows", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1820
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1821
|
+
const runId = `scripted-workflow-budget-async-child-${Date.now()}`;
|
|
1822
|
+
const started = await executor.execute(
|
|
1823
|
+
runId,
|
|
1824
|
+
{
|
|
1825
|
+
workflowScript: `await runs.run("background", { agent: "echo", task: "Async child", async: true }); return "unreachable";`,
|
|
1826
|
+
usageBudget: { tokens: { hard: 100 } },
|
|
1827
|
+
},
|
|
1828
|
+
new AbortController().signal,
|
|
1829
|
+
undefined,
|
|
1830
|
+
makeMinimalCtx(tempDir),
|
|
1831
|
+
);
|
|
1832
|
+
|
|
1833
|
+
assert.equal(started.isError, undefined);
|
|
1834
|
+
assert.ok(started.details.asyncId);
|
|
1835
|
+
assert.notEqual(started.details.asyncId, runId);
|
|
1836
|
+
const resultPath = path.join(DIRS.results, `${started.details.asyncId}.json`);
|
|
1837
|
+
let persisted: { state?: string; summary?: string; results?: Array<{ success?: boolean; output?: string }> } = {};
|
|
1838
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1839
|
+
if (fs.existsSync(resultPath)) persisted = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1840
|
+
if (persisted.state === "failed") break;
|
|
1841
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1842
|
+
}
|
|
1843
|
+
assert.equal(persisted.state, "failed");
|
|
1844
|
+
assert.match(persisted.summary ?? "", /workflow usageBudget does not support async runs\.run launches/);
|
|
1845
|
+
assert.equal(persisted.results?.length, 1);
|
|
1846
|
+
assert.equal(persisted.results?.[0]?.success, false);
|
|
1847
|
+
assert.match(persisted.results?.[0]?.output ?? "", /workflow usageBudget does not support async runs\.run launches/);
|
|
1848
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1849
|
+
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true });
|
|
1850
|
+
fs.rmSync(resultPath, { force: true });
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
it("honors an omitted agent async default while awaiting the workflow child result", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1854
|
+
mockPi.onCall({ output: "default async child done" });
|
|
1855
|
+
const executor = makeExecutor([makeAgent("echo", { defaultAsync: true })], {}, false);
|
|
1856
|
+
const result = await executor.execute(
|
|
1857
|
+
`scripted-workflow-agent-async-default-${Date.now()}`,
|
|
1858
|
+
{ workflowScript: `return await runs.run("background", { agent: "echo", task: "Async child" });`, async: false },
|
|
1859
|
+
new AbortController().signal,
|
|
1860
|
+
undefined,
|
|
1861
|
+
makeMinimalCtx(tempDir),
|
|
1862
|
+
);
|
|
1863
|
+
|
|
1864
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
1865
|
+
const child = result.details.workflow?.value as { ok?: boolean; output?: string; runId?: string } | undefined;
|
|
1866
|
+
assert.equal(child?.ok, true);
|
|
1867
|
+
assert.equal(child?.output, "default async child done");
|
|
1868
|
+
assert.ok(child?.runId);
|
|
1869
|
+
assert.equal(result.details.results[0]?.finalOutput, "default async child done");
|
|
1870
|
+
assert.equal(fs.existsSync(path.join(DIRS.async, child.runId)), true);
|
|
1871
|
+
fs.rmSync(path.join(DIRS.async, child.runId), { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1872
|
+
fs.rmSync(path.join(DIRS.results, `${child.runId}.json`), { force: true });
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it("keeps ordinary async workflow child results in the watcher-owned path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1876
|
+
mockPi.onCall({ output: "async child done" });
|
|
1877
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1878
|
+
const result = await executor.execute(
|
|
1879
|
+
`scripted-workflow-async-child-${Date.now()}`,
|
|
1880
|
+
{ workflowScript: `return await runs.run("background", { agent: "echo", task: "Async child", async: true });`, async: false },
|
|
1881
|
+
new AbortController().signal,
|
|
1882
|
+
undefined,
|
|
1883
|
+
makeMinimalCtx(tempDir),
|
|
1884
|
+
);
|
|
1885
|
+
const childRunId = (result.details.workflow?.value as { runId?: string } | undefined)?.runId;
|
|
1886
|
+
assert.ok(childRunId);
|
|
1887
|
+
const childDir = path.join(DIRS.async, childRunId);
|
|
1888
|
+
const childResultPath = path.join(DIRS.results, `${childRunId}.json`);
|
|
1889
|
+
for (let attempt = 0; attempt < 200 && !fs.existsSync(childResultPath); attempt++) {
|
|
1890
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1891
|
+
}
|
|
1892
|
+
assert.equal(fs.existsSync(childResultPath), true);
|
|
1893
|
+
assert.equal(fs.existsSync(path.join(childDir, "workflow-result.json")), false);
|
|
1894
|
+
fs.rmSync(childDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1895
|
+
fs.rmSync(childResultPath, { force: true });
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
it("applies an agent deadline to a workflow-launched async child", { skip: !createSubagentExecutor ? "executor not importable" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
1899
|
+
mockPi.onCall({ delay: 5_000, output: "too late" });
|
|
1900
|
+
const executor = makeExecutor([makeAgent("slow", { defaultTimeoutMs: 150 })]);
|
|
1901
|
+
const result = await executor.execute(
|
|
1902
|
+
`scripted-workflow-async-child-timeout-${Date.now()}`,
|
|
1903
|
+
{
|
|
1904
|
+
workflowScript: `return await runs.run("background", { agent: "slow", task: "Wait", async: true });`,
|
|
1905
|
+
async: false,
|
|
1906
|
+
},
|
|
1907
|
+
new AbortController().signal,
|
|
1908
|
+
undefined,
|
|
1909
|
+
makeMinimalCtx(tempDir),
|
|
1910
|
+
);
|
|
1911
|
+
const childRunId = (result.details.workflow?.value as { runId?: string } | undefined)?.runId;
|
|
1912
|
+
assert.ok(childRunId, JSON.stringify(result.details.workflow?.value ?? result.content));
|
|
1913
|
+
const childDir = path.join(DIRS.async, childRunId);
|
|
1914
|
+
const childResultPath = path.join(DIRS.results, `${childRunId}.json`);
|
|
1915
|
+
let persisted: { timeoutMs?: number; state?: string; results?: Array<{ timedOut?: boolean; error?: string }> } = {};
|
|
1916
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1917
|
+
if (fs.existsSync(childResultPath)) persisted = JSON.parse(fs.readFileSync(childResultPath, "utf-8"));
|
|
1918
|
+
if (persisted.state === "failed") break;
|
|
1919
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1920
|
+
}
|
|
1921
|
+
assert.equal(persisted.timeoutMs, 150);
|
|
1922
|
+
assert.equal(persisted.state, "failed");
|
|
1923
|
+
assert.deepEqual(persisted.results?.map((entry) => entry.timedOut), [true]);
|
|
1924
|
+
assert.deepEqual(persisted.results?.map((entry) => entry.error), ["Subagent timed out after 150ms."]);
|
|
1925
|
+
fs.rmSync(childDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1926
|
+
fs.rmSync(childResultPath, { force: true });
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
it("persists workflow parent metadata in an async worktree child status and result", { skip: !createSubagentExecutor || process.platform === "win32" ? "executor unavailable or worktree paths differ on Windows" : undefined }, async () => {
|
|
1930
|
+
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
1931
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
1932
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
1933
|
+
fs.writeFileSync(path.join(tempDir, "base.txt"), "base\n", "utf-8");
|
|
1934
|
+
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
1935
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
1936
|
+
mockPi.onCall({ output: "async child done", writeFiles: [{ path: "feature.txt", content: "feature\n" }] });
|
|
1937
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1938
|
+
const toolCallId = `scripted-workflow-parent-${Date.now()}`;
|
|
1939
|
+
const started = await executor.execute(
|
|
1940
|
+
toolCallId,
|
|
1941
|
+
{ workflowScript: `const child = await runs.run("background", { agent: "echo", task: "Async child", async: true, worktree: true, lane: { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] } }); return child.runId;` },
|
|
1942
|
+
new AbortController().signal,
|
|
1943
|
+
undefined,
|
|
1944
|
+
makeMinimalCtx(tempDir),
|
|
1945
|
+
);
|
|
1946
|
+
assert.ok(started.details.asyncId);
|
|
1947
|
+
const workflowRunId = started.details.asyncId;
|
|
1948
|
+
const workflowResultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
1949
|
+
let childRunId: string | undefined;
|
|
1950
|
+
for (let attempt = 0; attempt < 150; attempt++) {
|
|
1951
|
+
if (fs.existsSync(workflowResultPath)) {
|
|
1952
|
+
const workflowResult = JSON.parse(fs.readFileSync(workflowResultPath, "utf-8")) as { workflow?: { value?: unknown } };
|
|
1953
|
+
if (typeof workflowResult.workflow?.value === "string") { childRunId = workflowResult.workflow.value; break; }
|
|
1954
|
+
}
|
|
1955
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1956
|
+
}
|
|
1957
|
+
assert.ok(childRunId);
|
|
1958
|
+
const workflowStatus = JSON.parse(fs.readFileSync(path.join(started.details.asyncDir!, "status.json"), "utf-8")) as AsyncStatus;
|
|
1959
|
+
const workflowStepSessionFile = workflowStatus.steps?.[0]?.sessionFile ?? "";
|
|
1960
|
+
assert.equal(workflowStatus.steps?.[0]?.agent, "echo");
|
|
1961
|
+
assert.deepEqual(workflowStatus.steps?.[0]?.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
|
|
1962
|
+
assert.match(workflowStepSessionFile, /session\.jsonl$/);
|
|
1963
|
+
const childDir = path.join(DIRS.async, childRunId);
|
|
1964
|
+
const childStatusPath = path.join(childDir, "status.json");
|
|
1965
|
+
let childStatus: { state?: string; mode?: string; parentWorkflowRunId?: string; workflowKey?: string; lane?: { key: string; mode?: string; sourceRef?: string; claims?: string[] }; steps?: Array<{ lane?: { key: string }; worktreePath?: string; branch?: string }>; parallelHandoff?: { path?: string; changedPatches?: number } } = {};
|
|
1966
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1967
|
+
if (fs.existsSync(childStatusPath)) childStatus = JSON.parse(fs.readFileSync(childStatusPath, "utf-8"));
|
|
1968
|
+
if (["complete", "failed", "stopped"].includes(childStatus.state ?? "")) break;
|
|
1969
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1970
|
+
}
|
|
1971
|
+
assert.equal(childStatus.mode, "single");
|
|
1972
|
+
assert.equal(childStatus.parentWorkflowRunId, workflowRunId);
|
|
1973
|
+
assert.equal(childStatus.workflowKey, "background");
|
|
1974
|
+
assert.deepEqual(childStatus.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
|
|
1975
|
+
assert.deepEqual(childStatus.steps?.[0]?.lane, childStatus.lane);
|
|
1976
|
+
assert.equal(typeof childStatus.steps?.[0]?.worktreePath, "string");
|
|
1977
|
+
assert.equal(typeof childStatus.steps?.[0]?.branch, "string");
|
|
1978
|
+
assert.equal(typeof childStatus.parallelHandoff?.path, "string");
|
|
1979
|
+
assert.equal(childStatus.parallelHandoff?.changedPatches, 1);
|
|
1980
|
+
assert.equal(fs.existsSync(path.join(tempDir, "feature.txt")), false);
|
|
1981
|
+
const handoff = JSON.parse(fs.readFileSync(childStatus.parallelHandoff!.path!, "utf-8")) as { groups?: Array<{ children?: Array<{ workflowKey?: string; runId?: string; lane?: { key: string }; patch?: { changed?: boolean; filesChanged?: number } }>; cleanup?: { state?: string; tasks?: Array<{ path?: string; preserved?: boolean; worktreeRemoved?: boolean; reason?: string }> } }> };
|
|
1982
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.workflowKey, "background");
|
|
1983
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.runId, childRunId);
|
|
1984
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.lane?.key, "background");
|
|
1985
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.patch?.changed, true);
|
|
1986
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.patch?.filesChanged, 1);
|
|
1987
|
+
assert.equal(handoff.groups?.[0]?.cleanup?.state, "partial");
|
|
1988
|
+
assert.equal(handoff.groups?.[0]?.cleanup?.tasks?.[0]?.preserved, true);
|
|
1989
|
+
assert.equal(handoff.groups?.[0]?.cleanup?.tasks?.[0]?.worktreeRemoved, false);
|
|
1990
|
+
assert.equal(handoff.groups?.[0]?.cleanup?.tasks?.[0]?.reason, "retained child resume requires managed worktree cwd");
|
|
1991
|
+
assert.equal(fs.existsSync(handoff.groups?.[0]?.cleanup?.tasks?.[0]?.path ?? ""), true);
|
|
1992
|
+
const childResultPath = path.join(DIRS.results, `${childRunId}.json`);
|
|
1993
|
+
for (let attempt = 0; attempt < 200 && !fs.existsSync(childResultPath); attempt++) {
|
|
1994
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1995
|
+
}
|
|
1996
|
+
const childResult = JSON.parse(fs.readFileSync(childResultPath, "utf-8")) as { parentWorkflowRunId?: string; workflowKey?: string };
|
|
1997
|
+
assert.equal(childResult.parentWorkflowRunId, workflowRunId);
|
|
1998
|
+
assert.equal(childResult.workflowKey, "background");
|
|
1999
|
+
const workflowReceipt = JSON.parse(fs.readFileSync(path.join(started.details.asyncDir!, "workflow-receipt.json"), "utf-8")) as { entries?: Record<string, { lane?: { key: string; mode?: string } }> };
|
|
2000
|
+
assert.deepEqual(workflowReceipt.entries?.background?.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
|
|
2001
|
+
assert.equal(fs.existsSync(workflowStepSessionFile), true);
|
|
2002
|
+
const retainedCwd = handoff.groups?.[0]?.cleanup?.tasks?.[0]?.path;
|
|
2003
|
+
assert.ok(retainedCwd);
|
|
2004
|
+
const resumeTarget = resolveAsyncResumeTarget({ id: childRunId }, { asyncDirRoot: DIRS.async, resultsDir: DIRS.results });
|
|
2005
|
+
assert.equal(resumeTarget.recoveryDescriptor?.sourceRunId, childRunId);
|
|
2006
|
+
assert.equal(path.resolve(resumeTarget.cwd ?? ""), path.resolve(retainedCwd));
|
|
2007
|
+
discardPreservedWorktrees(childStatus.parallelHandoff!.path!, { kind: "confirmed" });
|
|
2008
|
+
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
2009
|
+
fs.rmSync(workflowResultPath, { force: true });
|
|
2010
|
+
fs.rmSync(childDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
2011
|
+
fs.rmSync(path.join(DIRS.results, `${childRunId}.json`), { force: true });
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
it("stops a live async workflow through its controller", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2015
|
+
const controller = new AbortController();
|
|
2016
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), new Map([["workflow-stop", controller]]));
|
|
2017
|
+
const result = await executor.execute(
|
|
2018
|
+
"stop-call",
|
|
2019
|
+
{ action: "stop", id: "workflow-stop" },
|
|
2020
|
+
new AbortController().signal,
|
|
2021
|
+
undefined,
|
|
2022
|
+
makeMinimalCtx(tempDir),
|
|
2023
|
+
);
|
|
2024
|
+
assert.equal(result.isError, undefined);
|
|
2025
|
+
assert.equal(controller.signal.aborted, true);
|
|
2026
|
+
assert.equal(controller.signal.reason instanceof Error ? controller.signal.reason.message : String(controller.signal.reason), "Workflow stopped.");
|
|
2027
|
+
assert.match(result.content[0]?.text ?? "", /Stop requested for async workflow workflow-stop/);
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
it("persists parent-stopped workflow children as stopped instead of failed", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2031
|
+
mockPi.onCall({ delay: 5_000, output: "too late" });
|
|
2032
|
+
const workflowControllers = new Map<string, AbortController>();
|
|
2033
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), workflowControllers);
|
|
2034
|
+
const started = await executor.execute(
|
|
2035
|
+
`workflow-stop-child-${Date.now()}`,
|
|
2036
|
+
{ workflowScript: `return await runs.run("review", { agent: "echo", task: "Wait" });` },
|
|
2037
|
+
new AbortController().signal,
|
|
2038
|
+
undefined,
|
|
2039
|
+
makeMinimalCtx(tempDir),
|
|
2040
|
+
);
|
|
2041
|
+
assert.ok(started.details.asyncId);
|
|
2042
|
+
const workflowRunId = started.details.asyncId;
|
|
2043
|
+
const statusPath = path.join(started.details.asyncDir!, "status.json");
|
|
2044
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
2045
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2046
|
+
if (status.steps?.some((step) => step.workflowKey === "review" && step.status === "running")) break;
|
|
2047
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2048
|
+
}
|
|
2049
|
+
for (let attempt = 0; attempt < 100 && mockPi.sessions[0]?.task === undefined; attempt++) {
|
|
2050
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2051
|
+
}
|
|
2052
|
+
assert.equal(mockPi.sessions.length, 1);
|
|
2053
|
+
|
|
2054
|
+
const stopped = await executor.execute(
|
|
2055
|
+
"stop-workflow-child",
|
|
2056
|
+
{ action: "stop", id: workflowRunId },
|
|
2057
|
+
new AbortController().signal,
|
|
2058
|
+
undefined,
|
|
2059
|
+
makeMinimalCtx(tempDir),
|
|
2060
|
+
);
|
|
2061
|
+
assert.equal(stopped.isError, undefined);
|
|
2062
|
+
|
|
2063
|
+
let status: AsyncStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
2064
|
+
for (let attempt = 0; attempt < 100 && status.state !== "stopped"; attempt++) {
|
|
2065
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2066
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
2067
|
+
}
|
|
2068
|
+
assert.equal(status.state, "stopped");
|
|
2069
|
+
assert.equal(status.error, "Workflow stopped.");
|
|
2070
|
+
assert.equal(status.steps?.[0]?.status, "stopped");
|
|
2071
|
+
assert.equal(status.steps?.[0]?.stopped, true);
|
|
2072
|
+
assert.equal(status.steps?.[0]?.error, "Workflow stopped.");
|
|
2073
|
+
assert.equal(status.workflow?.trace.some((entry) => entry.key === "review" && entry.state === "stopped"), true);
|
|
2074
|
+
assert.equal(status.workflow?.trace.some((entry) => entry.key === "review" && entry.state === "failed"), false);
|
|
2075
|
+
|
|
2076
|
+
let childSettled = false;
|
|
2077
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
2078
|
+
if (mockPi.sessions[0]?.aborted && mockPi.sessions[0]?.disposed) {
|
|
2079
|
+
childSettled = true;
|
|
2080
|
+
break;
|
|
2081
|
+
}
|
|
2082
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2083
|
+
}
|
|
2084
|
+
assert.equal(childSettled, true, "child session must be aborted and disposed after the workflow stop");
|
|
2085
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2086
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2087
|
+
assert.equal(status.steps?.[0]?.status, "stopped");
|
|
2088
|
+
assert.equal(status.steps?.[0]?.stopped, true);
|
|
2089
|
+
assert.equal(status.steps?.[0]?.error, "Workflow stopped.");
|
|
2090
|
+
|
|
2091
|
+
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
2092
|
+
fs.rmSync(path.join(DIRS.results, `${workflowRunId}.json`), { force: true });
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
it("stops one live async workflow child without stopping the parent or sibling", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2096
|
+
mockPi.onCall({ matchArgIncludes: "Slow child", delay: 5_000, output: "slow late" });
|
|
2097
|
+
mockPi.onCall({ matchArgIncludes: "Fast child", delay: 250, output: "fast done" });
|
|
2098
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
2099
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, asyncJobs);
|
|
2100
|
+
const started = await executor.execute(
|
|
2101
|
+
`workflow-child-stop-${Date.now()}`,
|
|
2102
|
+
{
|
|
2103
|
+
workflowScript: `
|
|
2104
|
+
const results = await runs.all([
|
|
2105
|
+
{ key: "slow", agent: "echo", task: "Slow child" },
|
|
2106
|
+
{ key: "fast", agent: "echo", task: "Fast child" }
|
|
2107
|
+
]);
|
|
2108
|
+
return results.map((result) => result.key + ":" + (result.stopped ? "stopped" : result.ok ? "ok" : "failed")).join(",");
|
|
2109
|
+
`,
|
|
2110
|
+
},
|
|
2111
|
+
new AbortController().signal,
|
|
2112
|
+
undefined,
|
|
2113
|
+
makeMinimalCtx(tempDir),
|
|
2114
|
+
);
|
|
2115
|
+
assert.equal(started.isError, undefined);
|
|
2116
|
+
assert.ok(started.details.asyncDir);
|
|
2117
|
+
const workflowRunId = started.details.asyncId!;
|
|
2118
|
+
const statusPath = path.join(started.details.asyncDir, "status.json");
|
|
2119
|
+
const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
2120
|
+
let status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2121
|
+
for (let attempt = 0; attempt < 150 && !status.steps?.some((step) => step.workflowKey === "slow" && step.status === "running"); attempt++) {
|
|
2122
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2123
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2124
|
+
}
|
|
2125
|
+
const listed = listAsyncRuns(DIRS.async, { sessionId: "session-123", runId: workflowRunId, exactRunId: true })
|
|
2126
|
+
.find((run) => run.id === workflowRunId);
|
|
2127
|
+
assert.equal(listed?.steps?.find((step) => step.workflowKey === "slow")?.childId, "slow");
|
|
2128
|
+
|
|
2129
|
+
const stop = await executor.execute(
|
|
2130
|
+
"stop-workflow-child-only",
|
|
2131
|
+
{ action: "stop", id: workflowRunId, childId: "slow" },
|
|
2132
|
+
new AbortController().signal,
|
|
2133
|
+
undefined,
|
|
2134
|
+
makeMinimalCtx(tempDir),
|
|
2135
|
+
);
|
|
2136
|
+
assert.equal(stop.isError, undefined, stop.content[0]?.text ?? "");
|
|
2137
|
+
assert.match(stop.content[0]?.text ?? "", /Stop requested for child slow/);
|
|
2138
|
+
|
|
2139
|
+
for (let attempt = 0; attempt < 150 && status.state !== "complete"; attempt++) {
|
|
2140
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2141
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2142
|
+
}
|
|
2143
|
+
assert.equal(status.state, "complete", status.error);
|
|
2144
|
+
assert.equal(status.stopped, undefined);
|
|
2145
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.status, "stopped");
|
|
2146
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.stopped, true);
|
|
2147
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.error, "Workflow child 'slow' stopped.");
|
|
2148
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "fast")?.status, "completed");
|
|
2149
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "fast")?.stopped, undefined);
|
|
2150
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { state?: string; stopped?: boolean; results?: Array<{ workflowKey?: string; success?: boolean; stopped?: boolean }> };
|
|
2151
|
+
assert.equal(payload.state, "complete");
|
|
2152
|
+
assert.equal(payload.stopped, undefined);
|
|
2153
|
+
assert.equal(payload.results?.find((entry) => entry.workflowKey === "slow")?.stopped, true);
|
|
2154
|
+
assert.equal(payload.results?.find((entry) => entry.workflowKey === "fast")?.success, true);
|
|
2155
|
+
const childStatusEvents = fs.readFileSync(path.join(started.details.asyncDir, "events.jsonl"), "utf-8")
|
|
2156
|
+
.trim()
|
|
2157
|
+
.split("\n")
|
|
2158
|
+
.map((line) => JSON.parse(line) as { type?: string; childId?: string; status?: string; reason?: string });
|
|
2159
|
+
assert.ok(childStatusEvents.some((event) => event.type === "subagent.child-status" && event.childId === "slow" && event.status === "stopping"));
|
|
2160
|
+
const stoppedChildStatus = childStatusEvents.findLast((event) => event.type === "subagent.child-status" && event.childId === "slow" && event.status === "stopped");
|
|
2161
|
+
assert.equal(stoppedChildStatus?.reason, "subagent-action");
|
|
2162
|
+
fs.rmSync(started.details.asyncDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
2163
|
+
fs.rmSync(resultPath, { force: true });
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
it("reports completed async workflows as not running when stopped after completion", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2167
|
+
const runId = `workflow-stop-complete-${Date.now()}`;
|
|
2168
|
+
const asyncDir = path.join(DIRS.async, runId);
|
|
2169
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
2170
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
2171
|
+
runId,
|
|
2172
|
+
sessionId: "session-123",
|
|
2173
|
+
mode: "workflow",
|
|
2174
|
+
state: "complete",
|
|
2175
|
+
startedAt: Date.now(),
|
|
2176
|
+
lastUpdate: Date.now(),
|
|
2177
|
+
cwd: tempDir,
|
|
2178
|
+
pid: process.pid,
|
|
2179
|
+
}), "utf-8");
|
|
2180
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map([[runId, {
|
|
2181
|
+
asyncId: runId,
|
|
2182
|
+
asyncDir,
|
|
2183
|
+
cwd: tempDir,
|
|
2184
|
+
status: "complete",
|
|
2185
|
+
mode: "workflow",
|
|
2186
|
+
agents: [],
|
|
2187
|
+
steps: [],
|
|
2188
|
+
startedAt: Date.now(),
|
|
2189
|
+
updatedAt: Date.now(),
|
|
2190
|
+
}]]);
|
|
2191
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, asyncJobs);
|
|
2192
|
+
|
|
2193
|
+
const result = await executor.execute(
|
|
2194
|
+
"stop-completed-workflow",
|
|
2195
|
+
{ action: "stop", id: runId },
|
|
2196
|
+
new AbortController().signal,
|
|
2197
|
+
undefined,
|
|
2198
|
+
makeMinimalCtx(tempDir),
|
|
2199
|
+
);
|
|
2200
|
+
|
|
2201
|
+
assert.equal(result.isError, true);
|
|
2202
|
+
assert.match(result.content[0]?.text ?? "", /No running or queued async run was found/);
|
|
2203
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /reload recovery/);
|
|
2204
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("keeps a git worktree clean while routing workflow children through one automatic mission", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2208
|
+
mockPi.onCall({ output: "scanned auth" });
|
|
2209
|
+
mockPi.onCall({ output: "reviewed auth" });
|
|
2210
|
+
const projectDir = path.join(tempDir, "project");
|
|
2211
|
+
const agentDir = path.join(tempDir, "agent");
|
|
2212
|
+
fs.mkdirSync(projectDir);
|
|
2213
|
+
execFileSync("git", ["init"], { cwd: projectDir, stdio: "ignore" });
|
|
2214
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: projectDir });
|
|
2215
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: projectDir });
|
|
2216
|
+
fs.writeFileSync(path.join(projectDir, "base.txt"), "base\n", "utf-8");
|
|
2217
|
+
execFileSync("git", ["add", "base.txt"], { cwd: projectDir });
|
|
2218
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: projectDir, stdio: "ignore" });
|
|
2219
|
+
const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
2220
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
2221
|
+
try {
|
|
2222
|
+
const executor = makeExecutor([makeAgent("echo")], { missions: { globalIndex: false } });
|
|
2223
|
+
const result = await executor.execute(
|
|
2224
|
+
"scripted-workflow",
|
|
2225
|
+
{
|
|
2226
|
+
async: false,
|
|
2227
|
+
workflowScript: `
|
|
2228
|
+
const stateType = typeof state;
|
|
2229
|
+
const scan = await runs.run("scan", { agent: "echo", task: "Scan auth" });
|
|
2230
|
+
const review = await runs.run("review", { agent: "echo", task: "Review: " + scan.output });
|
|
2231
|
+
return { output: review.output, stateType };
|
|
2232
|
+
`,
|
|
2233
|
+
},
|
|
2234
|
+
new AbortController().signal,
|
|
2235
|
+
undefined,
|
|
2236
|
+
makeMinimalCtx(projectDir),
|
|
2237
|
+
);
|
|
2238
|
+
|
|
2239
|
+
assert.equal(result.isError, undefined);
|
|
2240
|
+
assert.match(result.content[0]?.text ?? "", /reviewed auth/);
|
|
2241
|
+
assert.equal(result.details.mode, "workflow");
|
|
2242
|
+
assert.equal(result.details.results.length, 2);
|
|
2243
|
+
assert.deepEqual(result.details.results.map((entry) => entry.workflowKey), ["scan", "review"]);
|
|
2244
|
+
assert.equal(result.details.workflow?.value && (result.details.workflow.value as { stateType?: unknown }).stateType, "object");
|
|
2245
|
+
assert.ok(result.details.missionId);
|
|
2246
|
+
const missionFiles = fs.readdirSync(path.join(agentDir, "missions", "projects"), { recursive: true })
|
|
2247
|
+
.filter((entry) => typeof entry === "string" && entry.endsWith(".json"));
|
|
2248
|
+
assert.equal(missionFiles.length, 1);
|
|
2249
|
+
const mission = JSON.parse(fs.readFileSync(path.join(agentDir, "missions", "projects", missionFiles[0]!), "utf-8")) as { objective?: string };
|
|
2250
|
+
assert.equal(mission.objective, utils.PROMPT_REDACTED);
|
|
2251
|
+
assert.deepEqual(result.details.workflow?.trace.filter((entry) => entry.state === "completed").map((entry) => entry.key), ["scan", "review"]);
|
|
2252
|
+
assert.equal(execFileSync("git", ["status", "--porcelain"], { cwd: projectDir, encoding: "utf-8" }), "");
|
|
2253
|
+
} finally {
|
|
2254
|
+
if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
2255
|
+
else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
2258
|
+
|
|
2259
|
+
it("keeps workflow children mission-detached when automatic mission persistence fails", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2260
|
+
mockPi.onCall({ output: "scanned auth" });
|
|
2261
|
+
mockPi.onCall({ output: "reviewed auth" });
|
|
2262
|
+
const blockedIndex = path.join(tempDir, "blocked-mission-index");
|
|
2263
|
+
fs.writeFileSync(blockedIndex, "not a directory", "utf-8");
|
|
2264
|
+
const executor = makeExecutor([makeAgent("echo")], { missions: { directory: ".pi-subagents/missions", globalIndexDir: blockedIndex } });
|
|
2265
|
+
|
|
2266
|
+
const result = await executor.execute(
|
|
2267
|
+
"scripted-workflow-mission-warning",
|
|
2268
|
+
{
|
|
2269
|
+
async: false,
|
|
2270
|
+
workflowScript: `
|
|
2271
|
+
const scan = await runs.run("scan", { agent: "echo", task: "Scan auth" });
|
|
2272
|
+
const review = await runs.run("review", { agent: "echo", task: "Review: " + scan.output });
|
|
2273
|
+
return review.output;
|
|
2274
|
+
`,
|
|
2275
|
+
},
|
|
2276
|
+
new AbortController().signal,
|
|
2277
|
+
undefined,
|
|
2278
|
+
makeMinimalCtx(tempDir),
|
|
2279
|
+
);
|
|
2280
|
+
|
|
2281
|
+
assert.equal(result.isError, undefined);
|
|
2282
|
+
assert.equal(result.details.missionId, undefined);
|
|
2283
|
+
assert.match(result.details.missionWarning ?? "", /Mission tracking unavailable/);
|
|
2284
|
+
assert.equal(result.details.results.length, 2);
|
|
2285
|
+
const missionDir = path.join(tempDir, ".pi-subagents", "missions");
|
|
2286
|
+
const missionFiles = fs.existsSync(missionDir) ? fs.readdirSync(missionDir).filter((entry) => entry.endsWith(".json")) : [];
|
|
2287
|
+
assert.equal(missionFiles.length, 1);
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
it("shares durable workflow state across a mission and omits it for mission:false", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2291
|
+
const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
2292
|
+
const projectDir = path.join(tempDir, "project");
|
|
2293
|
+
const agentDir = path.join(tempDir, "agent");
|
|
2294
|
+
fs.mkdirSync(projectDir);
|
|
2295
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
2296
|
+
try {
|
|
2297
|
+
const executor = makeExecutor([makeAgent("echo")], { missions: { globalIndex: false } });
|
|
2298
|
+
const first = await executor.execute(
|
|
2299
|
+
"mission-state-first",
|
|
2300
|
+
{
|
|
2301
|
+
async: false,
|
|
2302
|
+
mission: { title: "Stateful workflow" },
|
|
2303
|
+
workflowScript: `await state.set("review.stage", { count: 1 }); return await state.get("review.stage");`,
|
|
2304
|
+
},
|
|
2305
|
+
new AbortController().signal,
|
|
2306
|
+
undefined,
|
|
2307
|
+
makeMinimalCtx(projectDir),
|
|
2308
|
+
);
|
|
2309
|
+
assert.equal(first.isError, undefined, first.content[0]?.text ?? "first workflow failed");
|
|
2310
|
+
assert.ok(first.details.missionId);
|
|
2311
|
+
assert.deepEqual(first.details.workflow?.value, { count: 1 });
|
|
2312
|
+
const location = resolveMissionStoreLocation({ projectRoot: projectDir, agentDir });
|
|
2313
|
+
const statePath = missionStatePath(location, first.details.missionId);
|
|
2314
|
+
assert.equal(fs.existsSync(statePath), true);
|
|
2315
|
+
assert.equal(path.relative(projectDir, statePath).startsWith(".."), true);
|
|
2316
|
+
|
|
2317
|
+
const second = await executor.execute(
|
|
2318
|
+
"mission-state-second",
|
|
2319
|
+
{ async: false, missionId: first.details.missionId, workflowScript: `return await state.get("review.stage");` },
|
|
2320
|
+
new AbortController().signal,
|
|
2321
|
+
undefined,
|
|
2322
|
+
makeMinimalCtx(projectDir),
|
|
2323
|
+
);
|
|
2324
|
+
assert.equal(second.isError, undefined, second.content[0]?.text ?? "second workflow failed");
|
|
2325
|
+
assert.deepEqual(second.details.workflow?.value, { count: 1 });
|
|
2326
|
+
|
|
2327
|
+
const ephemeral = await executor.execute(
|
|
2328
|
+
"mission-state-off",
|
|
2329
|
+
{ async: false, mission: false, workflowScript: `return typeof state;` },
|
|
2330
|
+
new AbortController().signal,
|
|
2331
|
+
undefined,
|
|
2332
|
+
makeMinimalCtx(projectDir),
|
|
2333
|
+
);
|
|
2334
|
+
assert.equal(ephemeral.isError, undefined, ephemeral.content[0]?.text ?? "ephemeral workflow failed");
|
|
2335
|
+
assert.equal(ephemeral.details.workflow?.value, "undefined");
|
|
2336
|
+
assert.equal(ephemeral.details.missionId, undefined);
|
|
2337
|
+
} finally {
|
|
2338
|
+
if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
2339
|
+
else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
it("runs a direct single child in a managed worktree", { skip: !createSubagentExecutor || process.platform === "win32" ? "executor unavailable or worktree paths differ on Windows" : undefined }, async () => {
|
|
2344
|
+
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
2345
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
2346
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
2347
|
+
fs.writeFileSync(path.join(tempDir, "base.txt"), "base\n", "utf-8");
|
|
2348
|
+
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
2349
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
2350
|
+
mockPi.onCall({ output: "isolated feature", writeFiles: [{ path: "feature.txt", content: "feature\n" }] });
|
|
2351
|
+
const executor = makeExecutor([makeAgent("worker", { completionGuard: false })]);
|
|
2352
|
+
|
|
2353
|
+
const result = await executor.execute(
|
|
2354
|
+
"direct-worktree",
|
|
2355
|
+
{ async: false, agent: "worker", task: "Implement feature", worktree: true },
|
|
2356
|
+
new AbortController().signal,
|
|
2357
|
+
undefined,
|
|
2358
|
+
makeMinimalCtx(tempDir),
|
|
2359
|
+
);
|
|
2360
|
+
|
|
2361
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "managed worktree child failed");
|
|
2362
|
+
assert.equal(mockPi.callCount(), 1);
|
|
2363
|
+
assert.equal(fs.existsSync(path.join(tempDir, "feature.txt")), false);
|
|
2364
|
+
const handoffPath = (result.content[0]?.text ?? "").match(/([^\s]+\/handoffs\/[^\s]+\.json)/)?.[1];
|
|
2365
|
+
assert.ok(handoffPath, result.content[0]?.text);
|
|
2366
|
+
const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
2367
|
+
groups: Array<{
|
|
2368
|
+
cleanup: { state: string; tasks: Array<{ worktreeRemoved: boolean }> };
|
|
2369
|
+
}>;
|
|
2370
|
+
};
|
|
2371
|
+
assert.equal(handoff.groups[0]?.cleanup.state, "complete");
|
|
2372
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
|
|
2373
|
+
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
it("aligns a forked workflow child session with its managed worktree cwd", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2377
|
+
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
2378
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
2379
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
2380
|
+
fs.writeFileSync(path.join(tempDir, "base.txt"), "base\n", "utf-8");
|
|
2381
|
+
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
2382
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
2383
|
+
|
|
2384
|
+
const parentSessionFile = path.join(mockPi.dir, "parent-session.jsonl");
|
|
2385
|
+
const childSessionFile = path.join(mockPi.dir, "forked-child-session.jsonl");
|
|
2386
|
+
fs.writeFileSync(parentSessionFile, `${JSON.stringify({ type: "session", version: 3, id: "parent", cwd: tempDir })}\n`, "utf-8");
|
|
2387
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
2388
|
+
Object.assign(ctx.sessionManager, {
|
|
2389
|
+
getSessionFile: () => parentSessionFile,
|
|
2390
|
+
getLeafId: () => "parent-leaf",
|
|
2391
|
+
openSession: () => ({
|
|
2392
|
+
createBranchedSession: () => {
|
|
2393
|
+
fs.writeFileSync(childSessionFile, `${JSON.stringify({ type: "session", version: 3, id: "child", cwd: tempDir })}\n`, "utf-8");
|
|
2394
|
+
return childSessionFile;
|
|
2395
|
+
},
|
|
2396
|
+
}),
|
|
2397
|
+
});
|
|
2398
|
+
mockPi.onCall({ output: "isolated fork child" });
|
|
2399
|
+
const executor = makeExecutor([makeAgent("worker", { defaultContext: "fork" })]);
|
|
2400
|
+
|
|
2401
|
+
const result = await executor.execute(
|
|
2402
|
+
"forked-worktree-workflow",
|
|
2403
|
+
{ async: false, workflowScript: `return runs.run("isolated", { agent: "worker", task: "Work in isolation", worktree: true });` },
|
|
2404
|
+
new AbortController().signal,
|
|
2405
|
+
undefined,
|
|
2406
|
+
ctx,
|
|
2407
|
+
);
|
|
2408
|
+
|
|
2409
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2410
|
+
const workflowValue = result.details.workflow?.value as { artifactPaths?: string[] } | undefined;
|
|
2411
|
+
const handoffPath = workflowValue?.artifactPaths?.find((candidate) => candidate.endsWith(".json") && candidate.includes("handoffs"));
|
|
2412
|
+
assert.ok(handoffPath, JSON.stringify(workflowValue));
|
|
2413
|
+
const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
2414
|
+
groups: Array<{ cleanup: { tasks: Array<{ path: string }> } }>;
|
|
2415
|
+
};
|
|
2416
|
+
const managedWorktreeCwd = handoff.groups[0]?.cleanup.tasks[0]?.path;
|
|
2417
|
+
assert.ok(managedWorktreeCwd);
|
|
2418
|
+
const callCwd = readCall().cwd;
|
|
2419
|
+
assert.ok(callCwd);
|
|
2420
|
+
assert.notEqual(path.resolve(callCwd), path.resolve(tempDir));
|
|
2421
|
+
assert.equal(path.basename(callCwd), path.basename(managedWorktreeCwd));
|
|
2422
|
+
const sessionHeader = JSON.parse(fs.readFileSync(childSessionFile, "utf-8").split("\n", 1)[0]!) as { cwd?: string };
|
|
2423
|
+
assert.ok(sessionHeader.cwd);
|
|
2424
|
+
assert.equal(path.basename(sessionHeader.cwd), path.basename(callCwd));
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
it("rejects workflowScript implementation children under a read-only capability ceiling before spawn", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2428
|
+
mockPi.onCall({ output: "completed without edits" });
|
|
2429
|
+
const executor = makeExecutor([makeAgent("worker")]);
|
|
2430
|
+
|
|
2431
|
+
const result = await executor.execute(
|
|
2432
|
+
"workflow-readonly-implementation-contract",
|
|
2433
|
+
{
|
|
2434
|
+
async: false,
|
|
2435
|
+
workflowScript: `return await runs.run("impl", { agent: "worker", task: "Implement the requested source fix" });`,
|
|
2436
|
+
capabilityCeiling: { version: 1, allowedTools: ["read", "grep", "find", "ls", "contact_supervisor"], denyExtensions: true, sources: ["test"] },
|
|
2437
|
+
},
|
|
2438
|
+
new AbortController().signal,
|
|
2439
|
+
undefined,
|
|
2440
|
+
makeMinimalCtx(tempDir),
|
|
2441
|
+
);
|
|
2442
|
+
|
|
2443
|
+
assert.equal(result.isError, true);
|
|
2444
|
+
assert.match(result.content[0]?.text ?? "", /no mutation-capable tools/);
|
|
2445
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /completed without making edits/);
|
|
2446
|
+
assert.equal(mockPi.callCount(), 0);
|
|
2447
|
+
});
|
|
2448
|
+
|
|
2449
|
+
it("stringifies workflow child results without object placeholders", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2450
|
+
mockPi.onCall({ output: "first report", matchArgIncludes: "Review" });
|
|
2451
|
+
mockPi.onCall({ output: "second report", matchArgIncludes: "Monitor" });
|
|
2452
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
2453
|
+
|
|
2454
|
+
const result = await executor.execute(
|
|
2455
|
+
"scripted-workflow-stringified-child-results",
|
|
2456
|
+
{
|
|
2457
|
+
async: false,
|
|
2458
|
+
workflowScript: `
|
|
2459
|
+
const [review, monitor] = await runs.all([
|
|
2460
|
+
{ key: "review", agent: "echo", task: "Review" },
|
|
2461
|
+
{ key: "monitor", agent: "echo", task: "Monitor" }
|
|
2462
|
+
]);
|
|
2463
|
+
return "## Lane 1\\n" + review + "\\n\\n---\\n\\n## Lane 2\\n" + monitor;
|
|
2464
|
+
`,
|
|
2465
|
+
},
|
|
2466
|
+
new AbortController().signal,
|
|
2467
|
+
undefined,
|
|
2468
|
+
makeMinimalCtx(tempDir),
|
|
2469
|
+
);
|
|
2470
|
+
|
|
2471
|
+
const text = result.content[0]?.text ?? "";
|
|
2472
|
+
assert.equal(result.isError, undefined, text || "workflow failed");
|
|
2473
|
+
assert.doesNotMatch(text, /\[object Object\]/);
|
|
2474
|
+
assert.match(text, /## Lane 1\nfirst report/);
|
|
2475
|
+
assert.match(text, /## Lane 2\nsecond report/);
|
|
2476
|
+
assert.equal(result.details.workflow?.value, "## Lane 1\nfirst report\n\n---\n\n## Lane 2\nsecond report");
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2479
|
+
it("stringifies awaited workflow child results without object placeholders", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2480
|
+
mockPi.onCall({ output: "single report", matchArgIncludes: "Review" });
|
|
2481
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
2482
|
+
|
|
2483
|
+
const result = await executor.execute(
|
|
2484
|
+
"scripted-workflow-stringified-single-child-result",
|
|
2485
|
+
{
|
|
2486
|
+
async: false,
|
|
2487
|
+
workflowScript: `
|
|
2488
|
+
const review = await runs.run("review", { agent: "echo", task: "Review" });
|
|
2489
|
+
return "## Lane\\n" + review;
|
|
2490
|
+
`,
|
|
2491
|
+
},
|
|
2492
|
+
new AbortController().signal,
|
|
2493
|
+
undefined,
|
|
2494
|
+
makeMinimalCtx(tempDir),
|
|
2495
|
+
);
|
|
2496
|
+
|
|
2497
|
+
const text = result.content[0]?.text ?? "";
|
|
2498
|
+
assert.equal(result.isError, undefined, text || "workflow failed");
|
|
2499
|
+
assert.doesNotMatch(text, /\[object Object\]/);
|
|
2500
|
+
assert.match(text, /## Lane\nsingle report/);
|
|
2501
|
+
assert.equal(result.details.workflow?.value, "## Lane\nsingle report");
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
it("derives workflow child output paths from the workflow output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2505
|
+
mockPi.onCall({ output: "first report", matchArgIncludes: "Review" });
|
|
2506
|
+
mockPi.onCall({ output: "second report", matchArgIncludes: "Monitor" });
|
|
2507
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
2508
|
+
const workflowOutput = path.join(tempDir, "workflow-report.md");
|
|
2509
|
+
|
|
2510
|
+
const result = await executor.execute(
|
|
2511
|
+
"scripted-workflow-child-output-defaults",
|
|
2512
|
+
{
|
|
2513
|
+
async: false,
|
|
2514
|
+
output: workflowOutput,
|
|
2515
|
+
workflowScript: `
|
|
2516
|
+
const children = await runs.all([
|
|
2517
|
+
{ key: "review", agent: "echo", task: "Review" },
|
|
2518
|
+
{ key: "monitor", agent: "echo", task: "Monitor" }
|
|
2519
|
+
]);
|
|
2520
|
+
return children.map(({ key, artifactPaths }) => ({ key, artifactPaths }));
|
|
2521
|
+
`,
|
|
2522
|
+
},
|
|
2523
|
+
new AbortController().signal,
|
|
2524
|
+
undefined,
|
|
2525
|
+
makeMinimalCtx(tempDir),
|
|
2526
|
+
);
|
|
2527
|
+
|
|
2528
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2529
|
+
assert.match(fs.readFileSync(workflowOutput, "utf-8"), /Workflow completed\./);
|
|
2530
|
+
const value = result.details.workflow?.value as Array<{ key: string; artifactPaths: string[] }>;
|
|
2531
|
+
const childOutputs = value.map((child) => child.artifactPaths.find((candidate) => candidate.endsWith(".md")) ?? "").sort();
|
|
2532
|
+
assert.deepEqual(childOutputs, [
|
|
2533
|
+
path.join(tempDir, "workflow-report.monitor.md"),
|
|
2534
|
+
path.join(tempDir, "workflow-report.review.md"),
|
|
2535
|
+
]);
|
|
2536
|
+
assert.equal(fs.readFileSync(path.join(tempDir, "workflow-report.review.md"), "utf-8"), "first report");
|
|
2537
|
+
assert.equal(fs.readFileSync(path.join(tempDir, "workflow-report.monitor.md"), "utf-8"), "second report");
|
|
2538
|
+
});
|
|
2539
|
+
|
|
2540
|
+
it("keys concurrent workflow children under distinct run-id session roots for an explicit sessionDir", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2541
|
+
mockPi.onCall({ output: "first child" });
|
|
2542
|
+
mockPi.onCall({ output: "second child" });
|
|
2543
|
+
const sessionDir = path.join(tempDir, "fanout-sessions");
|
|
2544
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
2545
|
+
|
|
2546
|
+
const result = await executor.execute(
|
|
2547
|
+
"workflow-session-dir-fanout",
|
|
2548
|
+
{
|
|
2549
|
+
async: false,
|
|
2550
|
+
sessionDir,
|
|
2551
|
+
workflowScript: `
|
|
2552
|
+
const children = await runs.all([
|
|
2553
|
+
{ key: "first", agent: "echo", task: "First" },
|
|
2554
|
+
{ key: "second", agent: "echo", task: "Second" }
|
|
2555
|
+
]);
|
|
2556
|
+
return children.map(({ key }) => key);
|
|
2557
|
+
`,
|
|
2558
|
+
},
|
|
2559
|
+
new AbortController().signal,
|
|
2560
|
+
undefined,
|
|
2561
|
+
makeMinimalCtx(tempDir),
|
|
2562
|
+
);
|
|
2563
|
+
|
|
2564
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2565
|
+
const sessionArgs = readAllCallArgs(true)
|
|
2566
|
+
.map((args) => {
|
|
2567
|
+
const index = args.indexOf("--session");
|
|
2568
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
2569
|
+
})
|
|
2570
|
+
.filter((value): value is string => value !== undefined);
|
|
2571
|
+
assert.equal(sessionArgs.length, 2, `expected two --session child args, got ${JSON.stringify(sessionArgs)}`);
|
|
2572
|
+
const [firstSession, secondSession] = sessionArgs;
|
|
2573
|
+
assert.notEqual(firstSession, secondSession);
|
|
2574
|
+
for (const sessionFile of sessionArgs) {
|
|
2575
|
+
const relative = path.relative(sessionDir, sessionFile);
|
|
2576
|
+
const segments = relative.split(path.sep);
|
|
2577
|
+
assert.match(segments[0]!, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
2578
|
+
assert.deepEqual(segments.slice(1), ["run-0", "session.jsonl"]);
|
|
2579
|
+
}
|
|
2580
|
+
const runIdDirs = fs.readdirSync(sessionDir).sort();
|
|
2581
|
+
assert.equal(runIdDirs.length, 2);
|
|
2582
|
+
for (const runIdDir of runIdDirs) {
|
|
2583
|
+
assert.deepEqual(fs.readdirSync(path.join(sessionDir, runIdDir)), ["run-0"]);
|
|
2584
|
+
}
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
it("maps a task-requested report path to the workflow-saved child output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2588
|
+
mockPi.onCall({ output: "review report" });
|
|
2589
|
+
const requestedReport = path.join(tempDir, "requested-review.md");
|
|
2590
|
+
const workflowOutput = path.join(tempDir, "workflow-report.md");
|
|
2591
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
2592
|
+
"scripted-workflow-requested-output-mapping",
|
|
2593
|
+
{
|
|
2594
|
+
async: false,
|
|
2595
|
+
output: workflowOutput,
|
|
2596
|
+
workflowScript: `return await runs.run("review", { agent: "echo", task: ${JSON.stringify(`Review the change.\n\nWrite your findings to exactly this path: ${requestedReport}`)} });`,
|
|
2597
|
+
},
|
|
2598
|
+
new AbortController().signal,
|
|
2599
|
+
undefined,
|
|
2600
|
+
makeMinimalCtx(tempDir),
|
|
2601
|
+
);
|
|
2602
|
+
|
|
2603
|
+
const savedReport = path.join(tempDir, "workflow-report.review.md");
|
|
2604
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2605
|
+
assert.equal(fs.existsSync(requestedReport), false);
|
|
2606
|
+
assert.equal(fs.readFileSync(savedReport, "utf-8"), "review report");
|
|
2607
|
+
assert.deepEqual((result.details.workflow?.value as { outputPathMapping?: unknown }).outputPathMapping, {
|
|
2608
|
+
requestedPath: requestedReport,
|
|
2609
|
+
savedPath: savedReport,
|
|
2610
|
+
});
|
|
2611
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(`Output path mappings: 'review': requested ${escapeRegExp(requestedReport)} -> saved ${escapeRegExp(savedReport)}`));
|
|
2612
|
+
assert.match(fs.readFileSync(workflowOutput, "utf-8"), /Output path mappings:/);
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
it("preserves output path mappings when an async workflow fails after a completed child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2616
|
+
mockPi.onCall({ output: "review report", matchArgIncludes: "Review first" });
|
|
2617
|
+
mockPi.onCall({ exitCode: 1, stderr: "later child failure", matchArgIncludes: "Fail later" });
|
|
2618
|
+
const requestedReport = path.join(tempDir, "requested-review.md");
|
|
2619
|
+
const workflowOutput = path.join(tempDir, "failed-workflow.md");
|
|
2620
|
+
const started = await makeExecutor([makeAgent("echo")]).execute(
|
|
2621
|
+
"async-workflow-failed-output-mapping",
|
|
2622
|
+
{
|
|
2623
|
+
async: true,
|
|
2624
|
+
output: workflowOutput,
|
|
2625
|
+
workflowScript: `
|
|
2626
|
+
await runs.run("review", { agent: "echo", task: ${JSON.stringify(`Review first.\n\nWrite your findings to exactly this path: ${requestedReport}`)} });
|
|
2627
|
+
await runs.run("fails", { agent: "echo", task: "Fail later" });
|
|
2628
|
+
throw new Error("later workflow failure");
|
|
2629
|
+
`,
|
|
2630
|
+
},
|
|
2631
|
+
new AbortController().signal,
|
|
2632
|
+
undefined,
|
|
2633
|
+
makeMinimalCtx(tempDir),
|
|
2634
|
+
);
|
|
2635
|
+
|
|
2636
|
+
assert.equal(started.isError, undefined);
|
|
2637
|
+
assert.ok(started.details.asyncId);
|
|
2638
|
+
assert.ok(started.details.asyncDir);
|
|
2639
|
+
const resultPath = path.join(DIRS.results, `${started.details.asyncId}.json`);
|
|
2640
|
+
for (let attempt = 0; attempt < 150 && !fs.existsSync(resultPath); attempt++) {
|
|
2641
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2642
|
+
}
|
|
2643
|
+
const persisted = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { state?: string; summary?: string; results?: Array<{ workflowKey?: string; outputReference?: string; output?: string }> };
|
|
2644
|
+
const savedReport = path.join(tempDir, "failed-workflow.review.md");
|
|
2645
|
+
const expectedMapping = `Output path mappings: 'review': requested ${requestedReport} -> saved ${savedReport}`;
|
|
2646
|
+
assert.equal(persisted.state, "failed");
|
|
2647
|
+
assert.equal(persisted.results?.[0]?.workflowKey, "review");
|
|
2648
|
+
assert.equal(persisted.results?.[0]?.outputReference, savedReport);
|
|
2649
|
+
assert.equal(persisted.results?.[0]?.output, "review report");
|
|
2650
|
+
assert.match(persisted.summary ?? "", new RegExp(escapeRegExp(expectedMapping)));
|
|
2651
|
+
assert.match(fs.readFileSync(workflowOutput, "utf-8"), new RegExp(escapeRegExp(expectedMapping)));
|
|
2652
|
+
assert.equal(fs.readFileSync(savedReport, "utf-8"), "review report");
|
|
2653
|
+
|
|
2654
|
+
fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
|
|
2655
|
+
fs.rmSync(resultPath, { force: true });
|
|
2656
|
+
});
|
|
2657
|
+
|
|
2658
|
+
it("uses child-cwd agent output defaults for omitted workflow child output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2659
|
+
mockPi.onCall({ output: "app report" });
|
|
2660
|
+
const appDir = path.join(tempDir, "packages", "app");
|
|
2661
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
2662
|
+
const rootAgents = [makeAgent("echo", { output: "root-report.md" })];
|
|
2663
|
+
const appAgents = [makeAgent("echo", { output: "app-report.md" })];
|
|
2664
|
+
const executor = makeExecutor(rootAgents, {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), (cwd) => path.resolve(cwd) === path.resolve(appDir) ? appAgents : rootAgents);
|
|
2665
|
+
|
|
2666
|
+
const result = await executor.execute(
|
|
2667
|
+
"scripted-workflow-child-cwd-omitted-output-default",
|
|
2668
|
+
{
|
|
2669
|
+
async: false,
|
|
2670
|
+
workflowScript: `return await runs.run("app", { agent: "echo", task: "Review app", cwd: "packages/app" });`,
|
|
2671
|
+
},
|
|
2672
|
+
new AbortController().signal,
|
|
2673
|
+
undefined,
|
|
2674
|
+
makeMinimalCtx(tempDir),
|
|
2675
|
+
);
|
|
2676
|
+
|
|
2677
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2678
|
+
assert.equal(fs.existsSync(path.join(appDir, "app-report.md")), false);
|
|
2679
|
+
assert.ok(result.details.results[0]?.savedOutputPath && pathContainsSegments(result.details.results[0].savedOutputPath, "artifacts", "outputs", "scripted-workflow-child-cwd-omitted-output-default"));
|
|
2680
|
+
assert.equal(path.basename(result.details.results[0]?.savedOutputPath ?? ""), "app-report.md");
|
|
2681
|
+
assert.equal(fs.readFileSync(result.details.results[0]?.savedOutputPath ?? "", "utf-8"), "app report");
|
|
2682
|
+
assert.equal(fs.existsSync(path.join(tempDir, "root-report.md")), false);
|
|
2683
|
+
});
|
|
2684
|
+
|
|
2685
|
+
it("uses child-cwd agent output defaults for workflow output true", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2686
|
+
mockPi.onCall({ output: "app report" });
|
|
2687
|
+
const appDir = path.join(tempDir, "packages", "app");
|
|
2688
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
2689
|
+
const rootAgents = [makeAgent("echo", { output: "root-report.md" })];
|
|
2690
|
+
const appAgents = [makeAgent("echo", { output: "app-report.md" })];
|
|
2691
|
+
const executor = makeExecutor(rootAgents, {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), (cwd) => path.resolve(cwd) === path.resolve(appDir) ? appAgents : rootAgents);
|
|
2692
|
+
|
|
2693
|
+
const result = await executor.execute(
|
|
2694
|
+
"scripted-workflow-child-cwd-output-default",
|
|
2695
|
+
{
|
|
2696
|
+
async: false,
|
|
2697
|
+
workflowScript: `return await runs.run("app", { agent: "echo", task: "Review app", cwd: "packages/app", output: true });`,
|
|
2698
|
+
},
|
|
2699
|
+
new AbortController().signal,
|
|
2700
|
+
undefined,
|
|
2701
|
+
makeMinimalCtx(tempDir),
|
|
2702
|
+
);
|
|
2703
|
+
|
|
2704
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2705
|
+
assert.equal(fs.existsSync(path.join(appDir, "app-report.md")), false);
|
|
2706
|
+
assert.ok(result.details.results[0]?.savedOutputPath && pathContainsSegments(result.details.results[0].savedOutputPath, "artifacts", "outputs", "scripted-workflow-child-cwd-output-default"));
|
|
2707
|
+
assert.equal(path.basename(result.details.results[0]?.savedOutputPath ?? ""), "app-report.md");
|
|
2708
|
+
assert.equal(fs.readFileSync(result.details.results[0]?.savedOutputPath ?? "", "utf-8"), "app report");
|
|
2709
|
+
assert.equal(fs.existsSync(path.join(tempDir, "root-report.md")), false);
|
|
2710
|
+
});
|
|
2711
|
+
|
|
2712
|
+
it("reports workflow aggregate output write failures without throwing", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2713
|
+
const outputDir = path.join(tempDir, "aggregate-dir");
|
|
2714
|
+
fs.mkdirSync(outputDir);
|
|
2715
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
2716
|
+
"scripted-workflow-aggregate-output-write-error",
|
|
2717
|
+
{
|
|
2718
|
+
async: false,
|
|
2719
|
+
output: outputDir,
|
|
2720
|
+
workflowScript: `return "ok";`,
|
|
2721
|
+
},
|
|
2722
|
+
new AbortController().signal,
|
|
2723
|
+
undefined,
|
|
2724
|
+
makeMinimalCtx(tempDir),
|
|
2725
|
+
);
|
|
2726
|
+
|
|
2727
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2728
|
+
assert.match(result.content[0]?.text ?? "", /Workflow completed\./);
|
|
2729
|
+
assert.match(result.content[0]?.text ?? "", /Output file error:/);
|
|
2730
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(escapeRegExp(outputDir)));
|
|
2731
|
+
});
|
|
2732
|
+
|
|
2733
|
+
it("routes workflow relative outputs to the run output artifact directory", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2734
|
+
mockPi.onCall({ output: "child report" });
|
|
2735
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
2736
|
+
|
|
2737
|
+
const result = await executor.execute(
|
|
2738
|
+
"scripted-workflow-relative-output-base",
|
|
2739
|
+
{
|
|
2740
|
+
async: false,
|
|
2741
|
+
output: "workflow-summary.md",
|
|
2742
|
+
workflowScript: `return await runs.run("review", { agent: "echo", task: "Review", output: "plans/review.md" });`,
|
|
2743
|
+
},
|
|
2744
|
+
new AbortController().signal,
|
|
2745
|
+
undefined,
|
|
2746
|
+
makeMinimalCtx(tempDir),
|
|
2747
|
+
);
|
|
2748
|
+
|
|
2749
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2750
|
+
assert.equal(fs.existsSync(path.join(tempDir, "workflow-summary.md")), false);
|
|
2751
|
+
assert.equal(fs.existsSync(path.join(tempDir, "plans", "review.md")), false);
|
|
2752
|
+
const workflowOutputPath = path.join(TEMP_ARTIFACTS_DIR, "outputs", "scripted-workflow-relative-output-base", "workflow-summary.md");
|
|
2753
|
+
assert.match(fs.readFileSync(workflowOutputPath, "utf-8"), /Workflow completed\./);
|
|
2754
|
+
assert.ok(result.details.results[0]?.savedOutputPath && pathContainsSegments(result.details.results[0].savedOutputPath, "artifacts", "outputs", "scripted-workflow-relative-output-base", "plans"));
|
|
2755
|
+
assert.equal(path.basename(result.details.results[0]?.savedOutputPath ?? ""), "review.md");
|
|
2756
|
+
assert.equal(fs.readFileSync(result.details.results[0]?.savedOutputPath ?? "", "utf-8"), "child report");
|
|
2757
|
+
});
|
|
2758
|
+
|
|
2759
|
+
it("rejects workflow child output collisions before launch", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2760
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
2761
|
+
const sharedOutput = path.join(tempDir, "shared.md");
|
|
2762
|
+
|
|
2763
|
+
const duplicate = await executor.execute(
|
|
2764
|
+
"scripted-workflow-duplicate-child-output",
|
|
2765
|
+
{
|
|
2766
|
+
async: false,
|
|
2767
|
+
workflowScript: `return await runs.all([
|
|
2768
|
+
{ key: "review", agent: "echo", task: "Review", output: ${JSON.stringify(sharedOutput)} },
|
|
2769
|
+
{ key: "monitor", agent: "echo", task: "Monitor", output: ${JSON.stringify(sharedOutput)} }
|
|
2770
|
+
]);`,
|
|
2771
|
+
},
|
|
2772
|
+
new AbortController().signal,
|
|
2773
|
+
undefined,
|
|
2774
|
+
makeMinimalCtx(tempDir),
|
|
2775
|
+
);
|
|
2776
|
+
|
|
2777
|
+
assert.equal(duplicate.isError, undefined, duplicate.content[0]?.text ?? "workflow failed");
|
|
2778
|
+
const duplicateChildren = duplicate.details.workflow?.value as Array<{ ok: boolean; error?: string }>;
|
|
2779
|
+
assert.deepEqual(duplicateChildren.map(({ ok }) => ok), [false, false]);
|
|
2780
|
+
for (const child of duplicateChildren) {
|
|
2781
|
+
assert.match(child.error ?? "", /Workflow children 'review' and 'monitor' resolve output to the same path/);
|
|
2782
|
+
assert.match(child.error ?? "", new RegExp(escapeRegExp(sharedOutput)));
|
|
2783
|
+
}
|
|
2784
|
+
assert.equal(mockPi.callCount(), 0);
|
|
2785
|
+
|
|
2786
|
+
const relativeDuplicateOutput = "relative-shared.md";
|
|
2787
|
+
const relativeDuplicate = await executor.execute(
|
|
2788
|
+
"scripted-workflow-relative-duplicate-child-output",
|
|
2789
|
+
{
|
|
2790
|
+
async: false,
|
|
2791
|
+
workflowScript: `return await runs.all([
|
|
2792
|
+
{ key: "review", agent: "echo", task: "Review", output: ${JSON.stringify(relativeDuplicateOutput)} },
|
|
2793
|
+
{ key: "monitor", agent: "echo", task: "Monitor", output: ${JSON.stringify(relativeDuplicateOutput)} }
|
|
2794
|
+
]);`,
|
|
2795
|
+
},
|
|
2796
|
+
new AbortController().signal,
|
|
2797
|
+
undefined,
|
|
2798
|
+
makeMinimalCtx(tempDir),
|
|
2799
|
+
);
|
|
2800
|
+
|
|
2801
|
+
assert.equal(relativeDuplicate.isError, undefined, relativeDuplicate.content[0]?.text ?? "workflow failed");
|
|
2802
|
+
const relativeDuplicateChildren = relativeDuplicate.details.workflow?.value as Array<{ ok: boolean; error?: string }>;
|
|
2803
|
+
assert.deepEqual(relativeDuplicateChildren.map(({ ok }) => ok), [false, false]);
|
|
2804
|
+
for (const child of relativeDuplicateChildren) {
|
|
2805
|
+
assert.match(child.error ?? "", /Workflow children 'review' and 'monitor' resolve output to the same path/);
|
|
2806
|
+
assert.match(child.error ?? "", new RegExp(`${escapeRegExp(TEMP_ARTIFACTS_DIR)}.*outputs.*${escapeRegExp(relativeDuplicateOutput)}`));
|
|
2807
|
+
}
|
|
2808
|
+
assert.equal(mockPi.callCount(), 0);
|
|
2809
|
+
});
|
|
2810
|
+
|
|
2811
|
+
it("isolates colliding inherited agent-default outputs for parallel workflow children", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2812
|
+
mockPi.onCall({ output: "review report", matchArgIncludes: "Review" });
|
|
2813
|
+
mockPi.onCall({ output: "monitor report", matchArgIncludes: "Monitor" });
|
|
2814
|
+
const result = await makeExecutor([makeAgent("echo", { output: "context.md" })]).execute(
|
|
2815
|
+
"scripted-workflow-parallel-inherited-output-collision",
|
|
2816
|
+
{
|
|
2817
|
+
async: false,
|
|
2818
|
+
workflowScript: `return await runs.all([
|
|
2819
|
+
{ key: "review", agent: "echo", task: "Review" },
|
|
2820
|
+
{ key: "monitor", agent: "echo", task: "Monitor" }
|
|
2821
|
+
]);`,
|
|
2822
|
+
},
|
|
2823
|
+
new AbortController().signal,
|
|
2824
|
+
undefined,
|
|
2825
|
+
makeMinimalCtx(tempDir),
|
|
2826
|
+
);
|
|
2827
|
+
|
|
2828
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2829
|
+
const children = result.details.workflow?.value as Array<{ ok: boolean }>;
|
|
2830
|
+
assert.deepEqual(children.map(({ ok }) => ok), [true, true]);
|
|
2831
|
+
const outputPaths = result.details.results.map(({ savedOutputPath }) => savedOutputPath ?? "").sort();
|
|
2832
|
+
assert.equal(outputPaths.length, 2);
|
|
2833
|
+
assert.notEqual(outputPaths[0], outputPaths[1]);
|
|
2834
|
+
assert.ok(pathContainsSegments(outputPaths[0]!, "artifacts", "outputs"));
|
|
2835
|
+
assert.ok(pathContainsSegments(outputPaths[1]!, "artifacts", "outputs"));
|
|
2836
|
+
assert.match(path.basename(outputPaths[0]!), /^(monitor|review)\.md$/);
|
|
2837
|
+
assert.match(path.basename(outputPaths[1]!), /^(monitor|review)\.md$/);
|
|
2838
|
+
assert.deepEqual(outputPaths.map((outputPath) => fs.readFileSync(outputPath, "utf-8")).sort(), ["monitor report", "review report"]);
|
|
2839
|
+
assert.equal(fs.existsSync(path.join(tempDir, "context.md")), false);
|
|
2840
|
+
});
|
|
2841
|
+
|
|
2842
|
+
it("isolates inherited outputs that collide with a resumed child output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2843
|
+
const retainedRunId = `retained-output-${Date.now()}`;
|
|
2844
|
+
const retainedAsyncDir = path.join(DIRS.async, retainedRunId);
|
|
2845
|
+
const retainedSessionFile = path.join(tempDir, "retained-session.jsonl");
|
|
2846
|
+
const retainedOutputPath = path.join(tempDir, "context.md");
|
|
2847
|
+
const runFanoutBudget = createRunFanoutBudget(retainedRunId, 10);
|
|
2848
|
+
fs.mkdirSync(retainedAsyncDir, { recursive: true });
|
|
2849
|
+
fs.writeFileSync(retainedSessionFile, "{}\n", "utf-8");
|
|
2850
|
+
fs.writeFileSync(path.join(retainedAsyncDir, "status.json"), JSON.stringify({
|
|
2851
|
+
runId: retainedRunId,
|
|
2852
|
+
sessionId: "session-123",
|
|
2853
|
+
state: "failed",
|
|
2854
|
+
cwd: tempDir,
|
|
2855
|
+
sessionFile: retainedSessionFile,
|
|
2856
|
+
steps: [
|
|
2857
|
+
{ agent: "echo", status: "failed", sessionFile: retainedSessionFile },
|
|
2858
|
+
{ agent: "echo", status: "failed", sessionFile: retainedSessionFile },
|
|
2859
|
+
],
|
|
2860
|
+
}), "utf-8");
|
|
2861
|
+
fs.writeFileSync(path.join(retainedAsyncDir, "recovery-descriptor.json"), JSON.stringify({
|
|
2862
|
+
version: 1,
|
|
2863
|
+
runFanoutBudget,
|
|
2864
|
+
sourceRunId: retainedRunId,
|
|
2865
|
+
agent: "echo",
|
|
2866
|
+
cwd: tempDir,
|
|
2867
|
+
systemPromptMode: "append",
|
|
2868
|
+
inheritProjectContext: true,
|
|
2869
|
+
inheritSkills: true,
|
|
2870
|
+
outputPath: retainedOutputPath,
|
|
2871
|
+
outputMode: "inline",
|
|
2872
|
+
maxSubagentDepth: 1,
|
|
2873
|
+
share: false,
|
|
2874
|
+
}), "utf-8");
|
|
2875
|
+
mockPi.onCall({ output: "resumed report", matchArgIncludes: "Resume" });
|
|
2876
|
+
mockPi.onCall({ output: "review report", matchArgIncludes: "Review" });
|
|
2877
|
+
|
|
2878
|
+
try {
|
|
2879
|
+
const result = await makeExecutor([makeAgent("echo", { output: "context.md" })]).execute(
|
|
2880
|
+
"scripted-workflow-resumed-inherited-output-collision",
|
|
2881
|
+
{
|
|
2882
|
+
async: false,
|
|
2883
|
+
workflowScript: `return await runs.all([
|
|
2884
|
+
{ key: "resume", resume: ${JSON.stringify(retainedRunId)}, index: 1, task: "Resume" },
|
|
2885
|
+
{ key: "review", agent: "echo", task: "Review" }
|
|
2886
|
+
]);`,
|
|
2887
|
+
},
|
|
2888
|
+
new AbortController().signal,
|
|
2889
|
+
undefined,
|
|
2890
|
+
makeMinimalCtx(tempDir),
|
|
2891
|
+
);
|
|
2892
|
+
|
|
2893
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2894
|
+
const children = result.details.workflow?.value as Array<{ ok: boolean }>;
|
|
2895
|
+
assert.deepEqual(children.map(({ ok }) => ok), [true, true]);
|
|
2896
|
+
assert.equal(fs.readFileSync(retainedOutputPath, "utf-8"), "resumed report");
|
|
2897
|
+
const outputPaths = result.details.results.map(({ savedOutputPath }) => savedOutputPath ?? "");
|
|
2898
|
+
const inheritedOutputPaths = outputPaths.filter((outputPath) => outputPath && outputPath !== retainedOutputPath).sort();
|
|
2899
|
+
assert.deepEqual(inheritedOutputPaths.map((outputPath) => path.basename(outputPath)), ["context.md"]);
|
|
2900
|
+
assert.ok(inheritedOutputPaths.every((outputPath) => pathContainsSegments(outputPath, "artifacts", "outputs")));
|
|
2901
|
+
} finally {
|
|
2902
|
+
fs.rmSync(retainedAsyncDir, { recursive: true, force: true });
|
|
2903
|
+
fs.rmSync(runFanoutBudget.directory, { recursive: true, force: true });
|
|
2904
|
+
}
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
it("reroutes a later inherited agent-default output collision", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2908
|
+
mockPi.onCall({ output: "first report", matchArgIncludes: "First" });
|
|
2909
|
+
mockPi.onCall({ output: "second report", matchArgIncludes: "Second" });
|
|
2910
|
+
const result = await makeExecutor([makeAgent("echo", { output: "context.md" })]).execute(
|
|
2911
|
+
"scripted-workflow-sequential-inherited-output-collision",
|
|
2912
|
+
{
|
|
2913
|
+
async: false,
|
|
2914
|
+
workflowScript: `
|
|
2915
|
+
const first = await runs.run("first", { agent: "echo", task: "First" });
|
|
2916
|
+
const second = await runs.run("second", { agent: "echo", task: "Second" });
|
|
2917
|
+
return [first, second];
|
|
2918
|
+
`,
|
|
2919
|
+
},
|
|
2920
|
+
new AbortController().signal,
|
|
2921
|
+
undefined,
|
|
2922
|
+
makeMinimalCtx(tempDir),
|
|
2923
|
+
);
|
|
2924
|
+
|
|
2925
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2926
|
+
const children = result.details.workflow?.value as Array<{ ok: boolean }>;
|
|
2927
|
+
assert.deepEqual(children.map(({ ok }) => ok), [true, true]);
|
|
2928
|
+
assert.equal(fs.existsSync(path.join(tempDir, "context.md")), false);
|
|
2929
|
+
const outputPaths = result.details.results.map(({ savedOutputPath }) => savedOutputPath ?? "");
|
|
2930
|
+
assert.ok(pathContainsSegments(outputPaths[0]!, "artifacts", "outputs"));
|
|
2931
|
+
assert.equal(path.basename(outputPaths[0]!), "context.md");
|
|
2932
|
+
assert.equal(fs.readFileSync(outputPaths[0]!, "utf-8"), "first report");
|
|
2933
|
+
assert.ok(pathContainsSegments(outputPaths[1]!, "artifacts", "outputs"));
|
|
2934
|
+
assert.equal(path.basename(outputPaths[1]!), "second.md");
|
|
2935
|
+
assert.equal(fs.readFileSync(outputPaths[1]!, "utf-8"), "second report");
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
it("preserves a rejected file-only child report when its path matches workflow output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2939
|
+
const usefulReport = "# Review findings\n\nThe implementation loses the final report.";
|
|
2940
|
+
const sharedOutput = path.join(tempDir, "review.md");
|
|
2941
|
+
mockPi.onCall({ stdoutRaw: `${JSON.stringify(events.assistantMessage(usefulReport))}\n` });
|
|
2942
|
+
const executor = makeExecutor([makeAgent("reviewer", { tools: ["read"], completionGuard: false })]);
|
|
2943
|
+
|
|
2944
|
+
const result = await executor.execute(
|
|
2945
|
+
"scripted-workflow-file-only-acceptance-collision",
|
|
2946
|
+
{
|
|
2947
|
+
async: false,
|
|
2948
|
+
output: sharedOutput,
|
|
2949
|
+
workflowScript: `
|
|
2950
|
+
const child = await runs.run("review", {
|
|
2951
|
+
agent: "reviewer",
|
|
2952
|
+
task: "Write a structured review report.",
|
|
2953
|
+
output: ${JSON.stringify(sharedOutput)},
|
|
2954
|
+
outputMode: "file-only",
|
|
2955
|
+
acceptance: { level: "checked", criteria: ["Return the structured review report"] }
|
|
2956
|
+
});
|
|
2957
|
+
if (!child.ok) throw new Error(child.error);
|
|
2958
|
+
return child;
|
|
2959
|
+
`,
|
|
2960
|
+
},
|
|
2961
|
+
new AbortController().signal,
|
|
2962
|
+
undefined,
|
|
2963
|
+
makeMinimalCtx(tempDir),
|
|
2964
|
+
);
|
|
2965
|
+
|
|
2966
|
+
assert.equal(result.isError, true);
|
|
2967
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(escapeRegExp(sharedOutput)));
|
|
2968
|
+
assert.equal(result.details.results[0]?.acceptance?.status, "rejected");
|
|
2969
|
+
assert.match(result.details.results[0]?.acceptance?.runtimeChecks[0]?.message ?? "", /Structured acceptance report not found/);
|
|
2970
|
+
assert.equal(result.details.results[0]?.savedOutputPath, sharedOutput);
|
|
2971
|
+
assert.deepEqual(fs.readFileSync(sharedOutput), Buffer.from(usefulReport));
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
it("continues to a read-only review after malformed file-only acceptance metadata", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2975
|
+
const sharedOutput = path.join(tempDir, "implementation-report.md");
|
|
2976
|
+
const malformed = `ACCEPTANCE_REPORT: ${JSON.stringify({ criteriaSatisfied: true, commandsRun: ["npm test"] })}`;
|
|
2977
|
+
mockPi.onCall({
|
|
2978
|
+
output: "Implementation report persisted.",
|
|
2979
|
+
matchArgIncludes: "Write implementation report",
|
|
2980
|
+
jsonl: [...events.completedWrite(sharedOutput, malformed), events.assistantMessage("Implementation report persisted.")],
|
|
2981
|
+
writeFiles: [{ path: sharedOutput, content: malformed }],
|
|
2982
|
+
});
|
|
2983
|
+
mockPi.onCall({ output: "Read-only review completed.", matchArgIncludes: "Review the persisted implementation report without editing it" });
|
|
2984
|
+
const executor = makeExecutor([
|
|
2985
|
+
makeAgent("worker", { tools: ["read", "write"], completionGuard: false }),
|
|
2986
|
+
makeAgent("reviewer", { tools: ["read"], completionGuard: false }),
|
|
2987
|
+
]);
|
|
2988
|
+
|
|
2989
|
+
const result = await executor.execute(
|
|
2990
|
+
"scripted-workflow-malformed-acceptance-recovery",
|
|
2991
|
+
{
|
|
2992
|
+
async: false,
|
|
2993
|
+
workflowScript: `
|
|
2994
|
+
const writer = await runs.run("writer", {
|
|
2995
|
+
agent: "worker",
|
|
2996
|
+
task: "Write implementation report",
|
|
2997
|
+
output: ${JSON.stringify(sharedOutput)},
|
|
2998
|
+
outputMode: "file-only",
|
|
2999
|
+
acceptance: { level: "checked", criteria: ["Return the implementation report"] }
|
|
3000
|
+
});
|
|
3001
|
+
const review = await runs.run("review", {
|
|
3002
|
+
agent: "reviewer",
|
|
3003
|
+
task: "Review the persisted implementation report without editing it",
|
|
3004
|
+
acceptance: false
|
|
3005
|
+
});
|
|
3006
|
+
return { writerOk: writer.ok, writerRecovery: writer.recovery, reviewOk: review.ok };
|
|
3007
|
+
`,
|
|
3008
|
+
},
|
|
3009
|
+
new AbortController().signal,
|
|
3010
|
+
undefined,
|
|
3011
|
+
makeMinimalCtx(tempDir),
|
|
3012
|
+
);
|
|
3013
|
+
|
|
3014
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3015
|
+
const value = result.details.workflow?.value as { writerOk?: boolean; writerRecovery?: { status?: string; reason?: string; reportPath?: string; reportHash?: string }; reviewOk?: boolean };
|
|
3016
|
+
assert.equal(value.writerOk, false);
|
|
3017
|
+
assert.equal(value.writerRecovery?.status, "available-for-review");
|
|
3018
|
+
assert.equal(value.writerRecovery?.reason, "acceptance-metadata-rejected");
|
|
3019
|
+
assert.equal(value.writerRecovery?.reportPath, sharedOutput);
|
|
3020
|
+
assert.match(value.writerRecovery?.reportHash ?? "", /^[0-9a-f]{64}$/);
|
|
3021
|
+
assert.equal(value.reviewOk, true);
|
|
3022
|
+
assert.equal(result.details.results[0]?.acceptance?.status, "rejected");
|
|
3023
|
+
assert.equal(result.details.results[0]?.outputReference?.path, sharedOutput);
|
|
3024
|
+
assert.equal(result.details.results[0]?.savedOutputPath, sharedOutput);
|
|
3025
|
+
assert.equal(result.details.workflowChildren?.children.find((child) => child.childId === "writer")?.state, "rejected");
|
|
3026
|
+
assert.deepEqual(fs.readFileSync(sharedOutput, "utf-8"), malformed);
|
|
3027
|
+
});
|
|
3028
|
+
|
|
3029
|
+
it("identifies validation failures before any workflow child launches", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3030
|
+
const executor = makeExecutor([makeAgent("worker")]);
|
|
3031
|
+
const workflowId = "scripted-workflow-invalid-nested-async";
|
|
3032
|
+
const result = await executor.execute(
|
|
3033
|
+
workflowId,
|
|
3034
|
+
{
|
|
3035
|
+
async: false,
|
|
3036
|
+
workflowScript: `const lane = async () => runs.run("writer", { agent: "worker", task: "write" }); return lane();`,
|
|
3037
|
+
},
|
|
3038
|
+
new AbortController().signal,
|
|
3039
|
+
undefined,
|
|
3040
|
+
makeMinimalCtx(tempDir),
|
|
3041
|
+
);
|
|
3042
|
+
|
|
3043
|
+
assert.equal(result.isError, true);
|
|
3044
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(`Workflow '${workflowId}' validation failed before child launch; no children launched`));
|
|
3045
|
+
assert.match(result.content[0]?.text ?? "", /Parallel plus sequential rewrite/);
|
|
3046
|
+
assert.deepEqual(result.details.results, []);
|
|
3047
|
+
});
|
|
3048
|
+
|
|
3049
|
+
it("replaces stale workflow output when a child claims its path but writes no report", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3050
|
+
const sharedOutput = path.join(tempDir, "failed-review.md");
|
|
3051
|
+
fs.writeFileSync(sharedOutput, "stale workflow output", "utf-8");
|
|
3052
|
+
mockPi.onCall({ exitCode: 1, stderr: "review child failed before writing output" });
|
|
3053
|
+
const executor = makeExecutor([makeAgent("reviewer", { completionGuard: false })]);
|
|
3054
|
+
|
|
3055
|
+
const result = await executor.execute(
|
|
3056
|
+
"scripted-workflow-missing-child-output-collision",
|
|
3057
|
+
{
|
|
3058
|
+
async: false,
|
|
3059
|
+
output: sharedOutput,
|
|
3060
|
+
workflowScript: `
|
|
3061
|
+
const child = await runs.run("review", {
|
|
3062
|
+
agent: "reviewer",
|
|
3063
|
+
task: "Write a review report.",
|
|
3064
|
+
output: ${JSON.stringify(sharedOutput)},
|
|
3065
|
+
outputMode: "file-only"
|
|
3066
|
+
});
|
|
3067
|
+
if (!child.ok) throw new Error(child.error);
|
|
3068
|
+
return child;
|
|
3069
|
+
`,
|
|
3070
|
+
},
|
|
3071
|
+
new AbortController().signal,
|
|
3072
|
+
undefined,
|
|
3073
|
+
makeMinimalCtx(tempDir),
|
|
3074
|
+
);
|
|
3075
|
+
|
|
3076
|
+
assert.equal(result.isError, true);
|
|
3077
|
+
assert.match(result.content[0]?.text ?? "", /review child failed before writing output/);
|
|
3078
|
+
const workflowOutput = fs.readFileSync(sharedOutput, "utf-8");
|
|
3079
|
+
assert.match(workflowOutput, /Workflow failed:.*review child failed before writing output/s);
|
|
3080
|
+
assert.doesNotMatch(workflowOutput, /stale workflow output/);
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
it("rejects sequential workflow child output collisions before launch", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3084
|
+
mockPi.onCall({ output: "first report" });
|
|
3085
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3086
|
+
const sharedOutput = path.join(tempDir, "sequential-shared.md");
|
|
3087
|
+
|
|
3088
|
+
const result = await executor.execute(
|
|
3089
|
+
"scripted-workflow-sequential-output-collision",
|
|
3090
|
+
{
|
|
3091
|
+
async: false,
|
|
3092
|
+
workflowScript: `
|
|
3093
|
+
const first = await runs.run("review", { agent: "echo", task: "Review", output: ${JSON.stringify(sharedOutput)} });
|
|
3094
|
+
const second = await runs.run("monitor", { agent: "echo", task: "Monitor", output: ${JSON.stringify(sharedOutput)} })
|
|
3095
|
+
.catch((error) => ({ ok: false, error: error.message }));
|
|
3096
|
+
return [first, second];
|
|
3097
|
+
`,
|
|
3098
|
+
},
|
|
3099
|
+
new AbortController().signal,
|
|
3100
|
+
undefined,
|
|
3101
|
+
makeMinimalCtx(tempDir),
|
|
3102
|
+
);
|
|
3103
|
+
|
|
3104
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3105
|
+
const children = result.details.workflow?.value as Array<{ ok: boolean; error?: string }>;
|
|
3106
|
+
assert.deepEqual(children.map(({ ok }) => ok), [true, false]);
|
|
3107
|
+
assert.match(children[1]?.error ?? "", /Workflow children 'review' and 'monitor' resolve output to the same path/);
|
|
3108
|
+
assert.match(children[1]?.error ?? "", new RegExp(escapeRegExp(sharedOutput)));
|
|
3109
|
+
assert.equal(mockPi.callCount(), 1);
|
|
3110
|
+
});
|
|
3111
|
+
|
|
3112
|
+
it("checks workflow child output collisions against configured output base", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3113
|
+
const configuredBase = path.join(tempDir, "configured-outputs");
|
|
3114
|
+
const workflowOutput = "shared.md";
|
|
3115
|
+
|
|
3116
|
+
const resolvedSharedOutput = path.join(configuredBase, workflowOutput);
|
|
3117
|
+
const agentDefaultResult = await makeExecutor([makeAgent("echo", { output: workflowOutput })], { singleRunOutputBaseDir: configuredBase }).execute(
|
|
3118
|
+
"scripted-workflow-configured-agent-default-output-collision",
|
|
3119
|
+
{
|
|
3120
|
+
async: false,
|
|
3121
|
+
workflowScript: `return await runs.all([
|
|
3122
|
+
{ key: "review", agent: "echo", task: "Review", output: true },
|
|
3123
|
+
{ key: "monitor", agent: "echo", task: "Monitor", output: true }
|
|
3124
|
+
]);`,
|
|
3125
|
+
},
|
|
3126
|
+
new AbortController().signal,
|
|
3127
|
+
undefined,
|
|
3128
|
+
makeMinimalCtx(tempDir),
|
|
3129
|
+
);
|
|
3130
|
+
|
|
3131
|
+
assert.equal(agentDefaultResult.isError, undefined, agentDefaultResult.content[0]?.text ?? "workflow failed");
|
|
3132
|
+
const agentDefaultChildren = agentDefaultResult.details.workflow?.value as Array<{ ok: boolean; error?: string }>;
|
|
3133
|
+
assert.deepEqual(agentDefaultChildren.map(({ ok }) => ok), [false, false]);
|
|
3134
|
+
for (const child of agentDefaultChildren) {
|
|
3135
|
+
assert.match(child.error ?? "", /Workflow children 'review' and 'monitor' resolve output to the same path/);
|
|
3136
|
+
assert.match(child.error ?? "", new RegExp(escapeRegExp(resolvedSharedOutput)));
|
|
3137
|
+
}
|
|
3138
|
+
assert.equal(mockPi.callCount(), 0);
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
it("lets runs.all siblings settle when one child fails", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3142
|
+
mockPi.onCall({ exitCode: 1, stderr: "first child failed" });
|
|
3143
|
+
mockPi.onCall({ output: "second child completed" });
|
|
3144
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3145
|
+
|
|
3146
|
+
const result = await executor.execute(
|
|
3147
|
+
"scripted-workflow-settlement",
|
|
3148
|
+
{
|
|
3149
|
+
async: false,
|
|
3150
|
+
workflowScript: `
|
|
3151
|
+
const children = await runs.all([
|
|
3152
|
+
{ key: "first", agent: "echo", task: "First task" },
|
|
3153
|
+
{ key: "second", agent: "echo", task: "Second task" }
|
|
3154
|
+
]);
|
|
3155
|
+
return children.map(({ key, ok, error }) => error === undefined ? { key, ok } : { key, ok, error });
|
|
3156
|
+
`,
|
|
3157
|
+
},
|
|
3158
|
+
new AbortController().signal,
|
|
3159
|
+
undefined,
|
|
3160
|
+
makeMinimalCtx(tempDir),
|
|
3161
|
+
);
|
|
3162
|
+
|
|
3163
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3164
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3165
|
+
const value = result.details.workflow?.value as Array<{ key: string; ok: boolean; error?: string }>;
|
|
3166
|
+
assert.deepEqual(value.map(({ key }) => key), ["first", "second"]);
|
|
3167
|
+
assert.deepEqual(value.map(({ ok }) => ok).sort(), [false, true]);
|
|
3168
|
+
const failed = value.find(({ ok }) => !ok);
|
|
3169
|
+
const succeeded = value.find(({ ok }) => ok);
|
|
3170
|
+
assert.match(failed?.error ?? "", /first child failed/);
|
|
3171
|
+
assert.equal(failed?.error?.match(/first child failed/g)?.length, 1);
|
|
3172
|
+
assert.equal(succeeded?.error, undefined);
|
|
3173
|
+
assert.deepEqual(result.details.workflow?.trace.filter((entry) => entry.state !== "started").map(({ state }) => state).sort(), ["completed", "failed"]);
|
|
3174
|
+
});
|
|
3175
|
+
|
|
3176
|
+
it("reports keyed runs.all result access after siblings settle", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3177
|
+
mockPi.onCall({ output: "first child completed", matchArgIncludes: "First task" });
|
|
3178
|
+
mockPi.onCall({ output: "second child completed", matchArgIncludes: "Second task" });
|
|
3179
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3180
|
+
|
|
3181
|
+
const result = await executor.execute(
|
|
3182
|
+
"scripted-workflow-runs-all-keyed-result-access",
|
|
3183
|
+
{
|
|
3184
|
+
async: false,
|
|
3185
|
+
workflowScript: `
|
|
3186
|
+
const children = await runs.all([
|
|
3187
|
+
{ key: "first", agent: "echo", task: "First task" },
|
|
3188
|
+
{ key: "second", agent: "echo", task: "Second task" }
|
|
3189
|
+
]);
|
|
3190
|
+
return children.first.output;
|
|
3191
|
+
`,
|
|
3192
|
+
},
|
|
3193
|
+
new AbortController().signal,
|
|
3194
|
+
undefined,
|
|
3195
|
+
makeMinimalCtx(tempDir),
|
|
3196
|
+
);
|
|
3197
|
+
|
|
3198
|
+
assert.equal(result.isError, true);
|
|
3199
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3200
|
+
assert.match(result.content[0]?.text ?? "", /runs\.all resolves to an ordered array, not a key map/);
|
|
3201
|
+
assert.match(result.content[0]?.text ?? "", /Use results\[0\], array destructuring, or results\.map/);
|
|
3202
|
+
assert.deepEqual(result.details.workflow?.trace.filter((entry) => entry.state === "completed").map(({ key }) => key).sort(), ["first", "second"]);
|
|
3203
|
+
});
|
|
3204
|
+
|
|
3205
|
+
it("keeps array access working when runs.all child keys collide with array properties", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3206
|
+
mockPi.onCall({ output: "length child completed", matchArgIncludes: "Length task" });
|
|
3207
|
+
mockPi.onCall({ output: "map child completed", matchArgIncludes: "Map task" });
|
|
3208
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3209
|
+
|
|
3210
|
+
const result = await executor.execute(
|
|
3211
|
+
"scripted-workflow-runs-all-colliding-key-access",
|
|
3212
|
+
{
|
|
3213
|
+
async: false,
|
|
3214
|
+
workflowScript: `
|
|
3215
|
+
const children = await runs.all([
|
|
3216
|
+
{ key: "length", agent: "echo", task: "Length task" },
|
|
3217
|
+
{ key: "map", agent: "echo", task: "Map task" }
|
|
3218
|
+
]);
|
|
3219
|
+
const [, second] = children;
|
|
3220
|
+
return {
|
|
3221
|
+
length: children.length,
|
|
3222
|
+
first: children[0].output,
|
|
3223
|
+
second: second.output,
|
|
3224
|
+
outputs: children.map((child) => child.output),
|
|
3225
|
+
children
|
|
3226
|
+
};
|
|
3227
|
+
`,
|
|
3228
|
+
},
|
|
3229
|
+
new AbortController().signal,
|
|
3230
|
+
undefined,
|
|
3231
|
+
makeMinimalCtx(tempDir),
|
|
3232
|
+
);
|
|
3233
|
+
|
|
3234
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3235
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3236
|
+
const value = result.details.workflow?.value as { length?: number; first?: string; second?: string; outputs?: string[]; children?: Array<{ key?: string; ok?: boolean; output?: string }> } | undefined;
|
|
3237
|
+
assert.equal(value?.length, 2);
|
|
3238
|
+
assert.equal(value?.first, "length child completed");
|
|
3239
|
+
assert.equal(value?.second, "map child completed");
|
|
3240
|
+
assert.deepEqual(value?.outputs, ["length child completed", "map child completed"]);
|
|
3241
|
+
assert.deepEqual(value?.children?.map(({ key, ok, output }) => ({ key, ok, output })), [
|
|
3242
|
+
{ key: "length", ok: true, output: "length child completed" },
|
|
3243
|
+
{ key: "map", ok: true, output: "map child completed" },
|
|
3244
|
+
]);
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
it("emits runs.all results as plain arrays", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3248
|
+
mockPi.onCall({ output: "first child completed", matchArgIncludes: "First task" });
|
|
3249
|
+
mockPi.onCall({ output: "second child completed", matchArgIncludes: "Second task" });
|
|
3250
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3251
|
+
|
|
3252
|
+
const result = await executor.execute(
|
|
3253
|
+
"scripted-workflow-runs-all-emitted-array",
|
|
3254
|
+
{
|
|
3255
|
+
async: false,
|
|
3256
|
+
workflowScript: `
|
|
3257
|
+
const children = await runs.all([
|
|
3258
|
+
{ key: "first", agent: "echo", task: "First task" },
|
|
3259
|
+
{ key: "second", agent: "echo", task: "Second task" }
|
|
3260
|
+
]);
|
|
3261
|
+
emit(children);
|
|
3262
|
+
return children.map((child) => child.output);
|
|
3263
|
+
`,
|
|
3264
|
+
},
|
|
3265
|
+
new AbortController().signal,
|
|
3266
|
+
undefined,
|
|
3267
|
+
makeMinimalCtx(tempDir),
|
|
3268
|
+
);
|
|
3269
|
+
|
|
3270
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3271
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3272
|
+
assert.deepEqual((result.details.workflow?.emits[0] as Array<{ key: string; output: string }>).map(({ key, output }) => ({ key, output })), [
|
|
3273
|
+
{ key: "first", output: "first child completed" },
|
|
3274
|
+
{ key: "second", output: "second child completed" },
|
|
3275
|
+
]);
|
|
3276
|
+
assert.deepEqual(result.details.workflow?.value, ["first child completed", "second child completed"]);
|
|
3277
|
+
});
|
|
3278
|
+
|
|
3279
|
+
it("rejects an over-limit runs.all batch before launching any workflow child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3280
|
+
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerRun: 1 });
|
|
3281
|
+
|
|
3282
|
+
const result = await executor.execute(
|
|
3283
|
+
"scripted-workflow-fanout-limit",
|
|
3284
|
+
{
|
|
3285
|
+
async: false,
|
|
3286
|
+
workflowScript: `return await runs.all([
|
|
3287
|
+
{ key: "first", agent: "echo", task: "First task" },
|
|
3288
|
+
{ key: "second", agent: "echo", task: "Second task" }
|
|
3289
|
+
]);`,
|
|
3290
|
+
},
|
|
3291
|
+
new AbortController().signal,
|
|
3292
|
+
undefined,
|
|
3293
|
+
makeMinimalCtx(tempDir),
|
|
3294
|
+
);
|
|
3295
|
+
|
|
3296
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3297
|
+
assert.equal(mockPi.callCount(), 0);
|
|
3298
|
+
const children = result.details.workflow?.value as Array<{ ok: boolean; error?: string }>;
|
|
3299
|
+
assert.deepEqual(children.map(({ ok }) => ok), [false, false]);
|
|
3300
|
+
for (const child of children) assert.match(child.error ?? "", /workflow\[second\].*0\/1 used; 2 requested, 1 remaining/);
|
|
3301
|
+
});
|
|
3302
|
+
|
|
3303
|
+
it("lets an explicit workflow spawn override exceed config", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3304
|
+
mockPi.onCall({ output: "first child completed" });
|
|
3305
|
+
mockPi.onCall({ output: "second child completed" });
|
|
3306
|
+
const result = await makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerRun: 1 }).execute(
|
|
3307
|
+
"scripted-workflow-fanout-override",
|
|
3308
|
+
{
|
|
3309
|
+
async: false,
|
|
3310
|
+
maxSubagentSpawnsPerRun: 2,
|
|
3311
|
+
workflowScript: `return await runs.all([
|
|
3312
|
+
{ key: "first", agent: "echo", task: "First task" },
|
|
3313
|
+
{ key: "second", agent: "echo", task: "Second task" }
|
|
3314
|
+
]);`,
|
|
3315
|
+
},
|
|
3316
|
+
new AbortController().signal,
|
|
3317
|
+
undefined,
|
|
3318
|
+
makeMinimalCtx(tempDir),
|
|
3319
|
+
);
|
|
3320
|
+
|
|
3321
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3322
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3323
|
+
});
|
|
3324
|
+
|
|
3325
|
+
it("runs a direct child gate as host-verified acceptance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3326
|
+
const markerFile = "direct-gate.txt";
|
|
3327
|
+
const markerPath = path.join(tempDir, markerFile);
|
|
3328
|
+
mockPi.onCall({ output: [
|
|
3329
|
+
"done",
|
|
3330
|
+
"```acceptance-report",
|
|
3331
|
+
JSON.stringify({
|
|
3332
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "implemented" }],
|
|
3333
|
+
changedFiles: ["src/file.ts"],
|
|
3334
|
+
testsAddedOrUpdated: ["test/file.test.ts"],
|
|
3335
|
+
commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
|
|
3336
|
+
validationOutput: ["tests passed"],
|
|
3337
|
+
residualRisks: [],
|
|
3338
|
+
noStagedFiles: true,
|
|
3339
|
+
}),
|
|
3340
|
+
"```",
|
|
3341
|
+
].join("\n") });
|
|
3342
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3343
|
+
|
|
3344
|
+
const result = await executor.execute(
|
|
3345
|
+
"direct-gate",
|
|
3346
|
+
{ async: false, agent: "echo", task: "Validate the result without edits", gate: `${process.execPath} -e "require('node:fs').writeFileSync('${markerFile}','verified')"` },
|
|
3347
|
+
new AbortController().signal,
|
|
3348
|
+
undefined,
|
|
3349
|
+
makeMinimalCtx(tempDir),
|
|
3350
|
+
);
|
|
3351
|
+
|
|
3352
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "direct gate failed");
|
|
3353
|
+
assert.equal(fs.readFileSync(markerPath, "utf-8"), "verified");
|
|
3354
|
+
assert.equal(result.details.results[0]?.acceptance?.status, "verified");
|
|
3355
|
+
assert.equal(result.details.results[0]?.acceptance?.verifyRuns[0]?.id, "gate");
|
|
3356
|
+
});
|
|
3357
|
+
|
|
3358
|
+
it("lets runs.all siblings settle when one verified gate fails", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3359
|
+
const acceptedReport = [
|
|
3360
|
+
"done",
|
|
3361
|
+
"```acceptance-report",
|
|
3362
|
+
JSON.stringify({
|
|
3363
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "implemented" }],
|
|
3364
|
+
changedFiles: ["src/file.ts"],
|
|
3365
|
+
testsAddedOrUpdated: ["test/file.test.ts"],
|
|
3366
|
+
commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
|
|
3367
|
+
validationOutput: ["tests passed"],
|
|
3368
|
+
residualRisks: [],
|
|
3369
|
+
noStagedFiles: true,
|
|
3370
|
+
}),
|
|
3371
|
+
"```",
|
|
3372
|
+
].join("\n");
|
|
3373
|
+
mockPi.onCall({ output: acceptedReport });
|
|
3374
|
+
mockPi.onCall({ output: acceptedReport });
|
|
3375
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3376
|
+
|
|
3377
|
+
const result = await executor.execute(
|
|
3378
|
+
"scripted-workflow-gates",
|
|
3379
|
+
{
|
|
3380
|
+
async: false,
|
|
3381
|
+
workflowScript: `
|
|
3382
|
+
const children = await runs.all([
|
|
3383
|
+
{ key: "fails-gate", agent: "echo", task: "First task", gate: ${JSON.stringify(`${process.execPath} -e "process.exit(7)"`)} },
|
|
3384
|
+
{ key: "passes-gate", agent: "echo", task: "Second task", gate: ${JSON.stringify(`${process.execPath} -e "process.exit(0)"`)} }
|
|
3385
|
+
]);
|
|
3386
|
+
return children.map(({ key, ok }) => ({ key, ok }));
|
|
3387
|
+
`,
|
|
3388
|
+
},
|
|
3389
|
+
new AbortController().signal,
|
|
3390
|
+
undefined,
|
|
3391
|
+
makeMinimalCtx(tempDir),
|
|
3392
|
+
);
|
|
3393
|
+
|
|
3394
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3395
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3396
|
+
assert.deepEqual(result.details.workflow?.value, [
|
|
3397
|
+
{ key: "fails-gate", ok: false },
|
|
3398
|
+
{ key: "passes-gate", ok: true },
|
|
3399
|
+
]);
|
|
3400
|
+
const [failed, passed] = result.details.results;
|
|
3401
|
+
assert.equal(failed?.acceptance?.status, "rejected");
|
|
3402
|
+
assert.equal(failed?.acceptance?.verifyRuns[0]?.status, "failed");
|
|
3403
|
+
assert.equal(passed?.acceptance?.status, "verified");
|
|
3404
|
+
assert.equal(passed?.acceptance?.verifyRuns[0]?.status, "passed");
|
|
3405
|
+
});
|
|
3406
|
+
|
|
3407
|
+
it("gives parallel workflow children separate managed worktrees and durable handoffs", { skip: !createSubagentExecutor || process.platform === "win32" ? "executor unavailable or worktree paths differ on Windows" : undefined }, async () => {
|
|
3408
|
+
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
3409
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
3410
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
3411
|
+
fs.writeFileSync(path.join(tempDir, "base.txt"), "base\n", "utf-8");
|
|
3412
|
+
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
3413
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
3414
|
+
mockPi.onCall({ output: "feature a", writeFiles: [{ path: "feature-a.txt", content: "a\n" }] });
|
|
3415
|
+
mockPi.onCall({ output: "feature b", writeFiles: [{ path: "feature-b.txt", content: "b\n" }] });
|
|
3416
|
+
const executor = makeExecutor([makeAgent("worker")]);
|
|
3417
|
+
|
|
3418
|
+
const result = await executor.execute(
|
|
3419
|
+
"scripted-workflow-worktrees",
|
|
3420
|
+
{
|
|
3421
|
+
async: false,
|
|
3422
|
+
workflowScript: `
|
|
3423
|
+
const children = await runs.all([
|
|
3424
|
+
{ key: "feature-a", agent: "worker", task: "Implement A", worktree: true, lane: { version: 1, key: "feature-a", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-a.txt"] } },
|
|
3425
|
+
{ key: "feature-b", agent: "worker", task: "Implement B", worktree: true, lane: { version: 1, key: "feature-b", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-b.txt"] } }
|
|
3426
|
+
]);
|
|
3427
|
+
return children.map(({ key, artifactPaths }) => ({ key, artifactPaths }));
|
|
3428
|
+
`,
|
|
3429
|
+
},
|
|
3430
|
+
new AbortController().signal,
|
|
3431
|
+
undefined,
|
|
3432
|
+
makeMinimalCtx(tempDir),
|
|
3433
|
+
);
|
|
3434
|
+
|
|
3435
|
+
assert.equal(result.isError, undefined);
|
|
3436
|
+
assert.equal(mockPi.callCount(), 2, result.content[0]?.text ?? "workflow produced no output");
|
|
3437
|
+
assert.deepEqual(result.details.workflow?.receipt?.entries["feature-a"]?.lane, { version: 1, key: "feature-a", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-a.txt"] });
|
|
3438
|
+
assert.deepEqual(result.details.workflow?.receipt?.entries["feature-b"]?.lane, { version: 1, key: "feature-b", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-b.txt"] });
|
|
3439
|
+
assert.equal(fs.existsSync(path.join(tempDir, "feature-a.txt")), false);
|
|
3440
|
+
assert.equal(fs.existsSync(path.join(tempDir, "feature-b.txt")), false);
|
|
3441
|
+
const output = result.content[0]?.text ?? "";
|
|
3442
|
+
const handoffPaths = [...output.matchAll(/"([^"\n]*\/handoffs\/[^"\n]+\.json)"/g)].map((match) => match[1]!);
|
|
3443
|
+
assert.equal(handoffPaths.length, 2, output);
|
|
3444
|
+
const worktreePaths = new Set<string>();
|
|
3445
|
+
for (const handoffPath of handoffPaths) {
|
|
3446
|
+
const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
3447
|
+
groups: Array<{
|
|
3448
|
+
children: Array<{ workflowKey: string; runId: string; lane: { key: string; mode: string; sourceRef: string; claims: string[] }; patch: { changed: boolean; path: string } }>;
|
|
3449
|
+
cleanup: { state: string; tasks: Array<{ path: string; worktreeRemoved: boolean; branchRemoved: boolean }> };
|
|
3450
|
+
}>;
|
|
3451
|
+
};
|
|
3452
|
+
assert.equal(handoff.groups.length, 1);
|
|
3453
|
+
assert.equal(handoff.groups[0]?.children.length, 1);
|
|
3454
|
+
assert.equal(handoff.groups[0]?.children[0]?.workflowKey, handoff.groups[0]?.children[0]?.lane.key);
|
|
3455
|
+
assert.equal(handoff.groups[0]?.children[0]?.runId?.length > 0, true);
|
|
3456
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.changed, true);
|
|
3457
|
+
assert.equal(fs.existsSync(handoff.groups[0]!.children[0]!.patch.path), true);
|
|
3458
|
+
assert.equal(handoff.groups[0]?.cleanup.state, "complete");
|
|
3459
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
|
|
3460
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.branchRemoved, true);
|
|
3461
|
+
worktreePaths.add(handoff.groups[0]!.cleanup.tasks[0]!.path);
|
|
3462
|
+
}
|
|
3463
|
+
assert.equal(worktreePaths.size, 2);
|
|
3464
|
+
for (const worktreePath of worktreePaths) assert.equal(fs.existsSync(worktreePath), false);
|
|
3465
|
+
assert.match(result.content[0]?.text ?? "", /handoffs/);
|
|
3466
|
+
});
|
|
3467
|
+
|
|
3468
|
+
it("finalizes a workflow worktree when its child detaches for supervisor coordination", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
|
|
3469
|
+
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
3470
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
3471
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
3472
|
+
fs.writeFileSync(path.join(tempDir, "base.txt"), "base\n", "utf-8");
|
|
3473
|
+
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
3474
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
3475
|
+
mockPi.onCall({
|
|
3476
|
+
writeFiles: [{ path: "feature.txt", content: "feature\n" }],
|
|
3477
|
+
steps: [
|
|
3478
|
+
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
3479
|
+
{ delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
|
|
3480
|
+
],
|
|
3481
|
+
});
|
|
3482
|
+
const piEvents = createEventBus();
|
|
3483
|
+
const executor = makeExecutor(
|
|
3484
|
+
[makeAgent("worker", { systemPrompt: "Intercom orchestration channel:" })],
|
|
3485
|
+
{},
|
|
3486
|
+
false,
|
|
3487
|
+
undefined,
|
|
3488
|
+
true,
|
|
3489
|
+
new Map(),
|
|
3490
|
+
undefined,
|
|
3491
|
+
undefined,
|
|
3492
|
+
piEvents,
|
|
3493
|
+
);
|
|
3494
|
+
let detachAccepted = false;
|
|
3495
|
+
piEvents.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => {
|
|
3496
|
+
if ((payload as { requestId?: unknown }).requestId === "workflow-worktree-detach") {
|
|
3497
|
+
detachAccepted ||= (payload as { accepted?: unknown }).accepted === true;
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
const detachTimer = setInterval(() => {
|
|
3501
|
+
if (!detachAccepted) piEvents.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "workflow-worktree-detach" });
|
|
3502
|
+
}, 10);
|
|
3503
|
+
detachTimer.unref();
|
|
3504
|
+
|
|
3505
|
+
const result = await executor.execute(
|
|
3506
|
+
"scripted-workflow-detached-worktree",
|
|
3507
|
+
{
|
|
3508
|
+
async: false,
|
|
3509
|
+
workflowScript: `
|
|
3510
|
+
const children = await runs.all([
|
|
3511
|
+
{ key: "detaches", agent: "worker", task: "Ask then continue", worktree: true }
|
|
3512
|
+
]);
|
|
3513
|
+
return children.map(({ key, ok, artifactPaths }) => ({ key, ok, artifactPaths }));
|
|
3514
|
+
`,
|
|
3515
|
+
},
|
|
3516
|
+
new AbortController().signal,
|
|
3517
|
+
undefined,
|
|
3518
|
+
makeMinimalCtx(tempDir),
|
|
3519
|
+
);
|
|
3520
|
+
clearInterval(detachTimer);
|
|
3521
|
+
|
|
3522
|
+
assert.equal(detachAccepted, true);
|
|
3523
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
3524
|
+
assert.match(result.content[0]?.text ?? "", /run detaches: detached/);
|
|
3525
|
+
const workflowValue = result.details.workflow?.value as Array<{ ok: boolean; artifactPaths: string[] }>;
|
|
3526
|
+
assert.equal(workflowValue[0]?.ok, false);
|
|
3527
|
+
const handoffPath = workflowValue[0]?.artifactPaths.find((candidate) => candidate.endsWith(".json"));
|
|
3528
|
+
assert.ok(handoffPath, result.content[0]?.text ?? "missing pending handoff");
|
|
3529
|
+
let handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
3530
|
+
groups: Array<{
|
|
3531
|
+
children: Array<{ status: string; patch: { changed: boolean; filesChanged: number } }>;
|
|
3532
|
+
cleanup: { state: string; tasks: Array<{ path: string; branch: string; preserved: boolean; worktreeRemoved: boolean; branchRemoved: boolean }> };
|
|
3533
|
+
}>;
|
|
3534
|
+
};
|
|
3535
|
+
const cleanup = handoff.groups[0]?.cleanup;
|
|
3536
|
+
assert.equal(cleanup?.state, "partial");
|
|
3537
|
+
assert.equal(cleanup?.tasks[0]?.preserved, true);
|
|
3538
|
+
assert.equal(cleanup?.tasks[0]?.worktreeRemoved, false);
|
|
3539
|
+
assert.equal(cleanup?.tasks[0]?.branchRemoved, false);
|
|
3540
|
+
const worktreePath = cleanup?.tasks[0]?.path;
|
|
3541
|
+
const branch = cleanup?.tasks[0]?.branch;
|
|
3542
|
+
assert.ok(worktreePath);
|
|
3543
|
+
assert.ok(branch);
|
|
3544
|
+
assert.equal(fs.existsSync(worktreePath), true, "live detached worktree must remain present");
|
|
3545
|
+
|
|
3546
|
+
for (let attempt = 0; attempt < 150 && handoff.groups[0]?.cleanup.state !== "complete"; attempt++) {
|
|
3547
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3548
|
+
handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as typeof handoff;
|
|
3549
|
+
}
|
|
3550
|
+
assert.equal(handoff.groups[0]?.children[0]?.status, "completed");
|
|
3551
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.changed, true);
|
|
3552
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.filesChanged, 1);
|
|
3553
|
+
assert.equal(handoff.groups[0]?.cleanup.state, "complete");
|
|
3554
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.preserved, undefined);
|
|
3555
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
|
|
3556
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.branchRemoved, true);
|
|
3557
|
+
assert.equal(fs.existsSync(worktreePath), false);
|
|
3558
|
+
assert.equal(fs.existsSync(path.join(tempDir, "feature.txt")), false);
|
|
3559
|
+
});
|
|
3560
|
+
|
|
3561
|
+
it("continues an async sequential workflow after supervisor coordination settles", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
|
|
3562
|
+
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
3563
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
3564
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
3565
|
+
fs.writeFileSync(path.join(tempDir, "base.txt"), "base\n", "utf-8");
|
|
3566
|
+
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
3567
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
3568
|
+
mockPi.onCall({
|
|
3569
|
+
matchArgIncludes: "Ask then continue",
|
|
3570
|
+
steps: [
|
|
3571
|
+
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
3572
|
+
{ delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
|
|
3573
|
+
],
|
|
3574
|
+
});
|
|
3575
|
+
mockPi.onCall({ matchArgIncludes: "Use coordinated output: done after coordination", output: "tail completed" });
|
|
3576
|
+
const piEvents = createEventBus();
|
|
3577
|
+
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
3578
|
+
const executor = makeExecutor(
|
|
3579
|
+
[makeAgent("worker", { systemPrompt: "Intercom orchestration channel:" })],
|
|
3580
|
+
{},
|
|
3581
|
+
false,
|
|
3582
|
+
undefined,
|
|
3583
|
+
true,
|
|
3584
|
+
asyncJobs,
|
|
3585
|
+
undefined,
|
|
3586
|
+
undefined,
|
|
3587
|
+
piEvents,
|
|
3588
|
+
);
|
|
3589
|
+
let detachAccepted = false;
|
|
3590
|
+
piEvents.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => {
|
|
3591
|
+
if ((payload as { requestId?: unknown }).requestId === "async-workflow-detach") {
|
|
3592
|
+
detachAccepted ||= (payload as { accepted?: unknown }).accepted === true;
|
|
3593
|
+
}
|
|
3594
|
+
});
|
|
3595
|
+
const detachTimer = setInterval(() => {
|
|
3596
|
+
if (!detachAccepted) piEvents.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "async-workflow-detach" });
|
|
3597
|
+
}, 10);
|
|
3598
|
+
detachTimer.unref();
|
|
3599
|
+
|
|
3600
|
+
const started = await executor.execute(
|
|
3601
|
+
"async-scripted-workflow-detached-worktree",
|
|
3602
|
+
{
|
|
3603
|
+
workflowScript: `
|
|
3604
|
+
const child = await runs.run("detaches", { agent: "worker", task: "Ask then continue", worktree: true });
|
|
3605
|
+
const tail = await runs.run("tail", { agent: "worker", task: "Use coordinated output: " + child.output });
|
|
3606
|
+
return tail.output;
|
|
3607
|
+
`,
|
|
3608
|
+
},
|
|
3609
|
+
new AbortController().signal,
|
|
3610
|
+
undefined,
|
|
3611
|
+
makeMinimalCtx(tempDir),
|
|
3612
|
+
);
|
|
3613
|
+
assert.equal(started.isError, undefined);
|
|
3614
|
+
assert.ok(started.details.asyncId);
|
|
3615
|
+
assert.ok(started.details.asyncDir);
|
|
3616
|
+
const workflowRunId = started.details.asyncId;
|
|
3617
|
+
const statusPath = path.join(started.details.asyncDir, "status.json");
|
|
3618
|
+
const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
3619
|
+
const activeMarkerPath = path.join(DIRS.async, ACTIVE_RUN_INDEX_DIR, workflowRunId);
|
|
3620
|
+
|
|
3621
|
+
let status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
3622
|
+
for (let attempt = 0; attempt < 150 && status.steps?.[0]?.activityState !== "needs_attention"; attempt++) {
|
|
3623
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3624
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
3625
|
+
}
|
|
3626
|
+
clearInterval(detachTimer);
|
|
3627
|
+
|
|
3628
|
+
assert.equal(detachAccepted, true);
|
|
3629
|
+
|
|
3630
|
+
let reconciled: AsyncStatus | undefined;
|
|
3631
|
+
for (let attempt = 0; attempt < 150; attempt++) {
|
|
3632
|
+
reconciled = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
3633
|
+
if (reconciled.state === "complete" || reconciled.state === "failed") break;
|
|
3634
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3635
|
+
}
|
|
3636
|
+
assert.equal(reconciled?.state, "complete", reconciled?.error);
|
|
3637
|
+
assert.equal(reconciled?.activityState, undefined);
|
|
3638
|
+
assert.equal(reconciled?.steps?.[0]?.status, "completed");
|
|
3639
|
+
assert.equal(reconciled?.steps?.[0]?.activityState, undefined);
|
|
3640
|
+
assert.equal(reconciled?.steps?.[1]?.workflowKey, "tail");
|
|
3641
|
+
assert.equal(reconciled?.steps?.[1]?.status, "completed");
|
|
3642
|
+
assert.equal(asyncJobs.get(workflowRunId)?.status, "complete");
|
|
3643
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3644
|
+
|
|
3645
|
+
const persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as {
|
|
3646
|
+
state?: string;
|
|
3647
|
+
success?: boolean;
|
|
3648
|
+
activityState?: string;
|
|
3649
|
+
error?: string;
|
|
3650
|
+
results?: Array<{ workflowKey?: string; success?: boolean; output?: string; detached?: boolean; artifactPaths?: { outputPath?: string } }>;
|
|
3651
|
+
};
|
|
3652
|
+
assert.equal(persistedResult.state, "complete");
|
|
3653
|
+
assert.equal(persistedResult.success, true);
|
|
3654
|
+
assert.equal(persistedResult.activityState, undefined);
|
|
3655
|
+
assert.equal(persistedResult.error, undefined);
|
|
3656
|
+
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.detached, undefined);
|
|
3657
|
+
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "tail")?.output, "tail completed");
|
|
3658
|
+
const handoffPath = persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.artifactPaths?.outputPath;
|
|
3659
|
+
assert.ok(handoffPath, "missing finalized worktree handoff path");
|
|
3660
|
+
const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
3661
|
+
groups: Array<{
|
|
3662
|
+
children: Array<{ status: string; patch: { changed: boolean; filesChanged: number } }>;
|
|
3663
|
+
cleanup: { state: string; tasks: Array<{ path: string; worktreeRemoved: boolean; branchRemoved: boolean }> };
|
|
3664
|
+
}>;
|
|
3665
|
+
};
|
|
3666
|
+
assert.equal(handoff.groups[0]?.children[0]?.status, "completed");
|
|
3667
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.changed, false);
|
|
3668
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.filesChanged, 0);
|
|
3669
|
+
assert.equal(handoff.groups[0]?.cleanup.state, "complete");
|
|
3670
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
|
|
3671
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.branchRemoved, true);
|
|
3672
|
+
assert.equal(fs.existsSync(handoff.groups[0]?.cleanup.tasks[0]?.path ?? ""), false);
|
|
3673
|
+
assert.equal(fs.existsSync(activeMarkerPath), false);
|
|
3674
|
+
fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
|
|
3675
|
+
fs.rmSync(resultPath, { force: true });
|
|
3676
|
+
});
|
|
3677
|
+
|
|
3678
|
+
});
|