@selesai/code 0.13.0 → 0.13.2
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 +22 -0
- package/dist/core/agent-session.d.ts +1 -1
- package/dist/core/agent-session.js +1 -1
- package/dist/core/sdk.d.ts +2 -2
- package/dist/core/sdk.js +1 -1
- package/dist/core/system-prompt.d.ts +1 -1
- package/dist/core/system-prompt.js +1 -1
- package/dist/extensions/package.json +1 -2
- package/dist/extensions/pi-hermes-memory/CHANGELOG.md +371 -0
- package/dist/extensions/pi-hermes-memory/LICENSE +21 -0
- package/dist/extensions/pi-hermes-memory/README.md +643 -0
- package/dist/extensions/pi-hermes-memory/docs/0.1/TASKS.md +197 -0
- package/dist/extensions/pi-hermes-memory/docs/0.2/PLAN.md +290 -0
- package/dist/extensions/pi-hermes-memory/docs/0.2/TASKS.md +134 -0
- package/dist/extensions/pi-hermes-memory/docs/0.2/TEST-PLAN.md +216 -0
- package/dist/extensions/pi-hermes-memory/docs/0.3/PLAN.md +330 -0
- package/dist/extensions/pi-hermes-memory/docs/0.3/TASKS.md +125 -0
- package/dist/extensions/pi-hermes-memory/docs/0.4/PLAN.md +160 -0
- package/dist/extensions/pi-hermes-memory/docs/0.4/TASKS.md +113 -0
- package/dist/extensions/pi-hermes-memory/docs/0.6/CHANGELOG.md +57 -0
- package/dist/extensions/pi-hermes-memory/docs/0.6/PLAN.md +202 -0
- package/dist/extensions/pi-hermes-memory/docs/0.6/TASKS.md +144 -0
- package/dist/extensions/pi-hermes-memory/docs/0.7/PLAN.md +349 -0
- package/dist/extensions/pi-hermes-memory/docs/0.7/TASKS.md +110 -0
- package/dist/extensions/pi-hermes-memory/docs/PUBLISHING.md +149 -0
- package/dist/extensions/pi-hermes-memory/docs/ROADMAP.md +468 -0
- package/dist/extensions/pi-hermes-memory/docs/images/memory-architecture.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/images/pi-logo.svg +22 -0
- package/dist/extensions/pi-hermes-memory/docs/images/pi_memory.png +0 -0
- package/dist/extensions/pi-hermes-memory/docs/images/pi_memory.svg +19 -0
- package/dist/extensions/pi-hermes-memory/docs/images/security-flow.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/images/session-lifecycle.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/images/source-architecture.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/memory-architecture.mmd +49 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/security-flow.mmd +40 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/session-lifecycle.mmd +56 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/source-architecture.mmd +54 -0
- package/dist/extensions/pi-hermes-memory/package-lock.json +3799 -0
- package/dist/extensions/pi-hermes-memory/package.json +66 -0
- package/dist/extensions/pi-hermes-memory/scripts/check-min-sdk.mjs +99 -0
- package/dist/extensions/pi-hermes-memory/scripts/ensure-dev.mjs +57 -0
- package/dist/extensions/pi-hermes-memory/src/auto-consolidation-warning.ts +6 -0
- package/dist/extensions/pi-hermes-memory/src/config.ts +178 -0
- package/dist/extensions/pi-hermes-memory/src/constants.ts +424 -0
- package/dist/extensions/pi-hermes-memory/src/extension-root-migration.ts +639 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +401 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/background-review.ts +297 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/child-process-watchdog.mjs +90 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/correction-detector.ts +300 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/index-sessions.ts +86 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/insights.ts +78 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/interview.ts +37 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/learn-memory.ts +186 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/message-parts.ts +27 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/pi-child-process.ts +485 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/preview-context.ts +105 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/review-memory-ops.ts +506 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/session-backfill.ts +145 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/session-flush.ts +151 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/session-live-index.ts +94 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/skills-command.ts +1334 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/standing-pin.ts +111 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/switch-project.ts +75 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/sync-markdown-memories.ts +266 -0
- package/dist/extensions/pi-hermes-memory/src/index.ts +378 -0
- package/dist/extensions/pi-hermes-memory/src/lifecycle-timing.ts +54 -0
- package/dist/extensions/pi-hermes-memory/src/paths.ts +61 -0
- package/dist/extensions/pi-hermes-memory/src/project-context.ts +13 -0
- package/dist/extensions/pi-hermes-memory/src/project-memory-migration.ts +93 -0
- package/dist/extensions/pi-hermes-memory/src/project.ts +157 -0
- package/dist/extensions/pi-hermes-memory/src/prompt-context.ts +55 -0
- package/dist/extensions/pi-hermes-memory/src/store/atomic-lock-coordinator.ts +375 -0
- package/dist/extensions/pi-hermes-memory/src/store/canonical-storage-path.ts +54 -0
- package/dist/extensions/pi-hermes-memory/src/store/content-scanner.ts +101 -0
- package/dist/extensions/pi-hermes-memory/src/store/db.ts +1180 -0
- package/dist/extensions/pi-hermes-memory/src/store/fts-query.ts +101 -0
- package/dist/extensions/pi-hermes-memory/src/store/markdown-mutation-lock.ts +38 -0
- package/dist/extensions/pi-hermes-memory/src/store/memory-lookup.ts +24 -0
- package/dist/extensions/pi-hermes-memory/src/store/memory-store.ts +1191 -0
- package/dist/extensions/pi-hermes-memory/src/store/recovery-maintenance.ts +66 -0
- package/dist/extensions/pi-hermes-memory/src/store/schema.ts +118 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-anchor-search.ts +472 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-indexer.ts +511 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-parser.ts +213 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-search.ts +234 -0
- package/dist/extensions/pi-hermes-memory/src/store/skill-store.ts +950 -0
- package/dist/extensions/pi-hermes-memory/src/store/skill-utils.ts +133 -0
- package/dist/extensions/pi-hermes-memory/src/store/sqlite-memory-store.ts +1002 -0
- package/dist/extensions/pi-hermes-memory/src/store/sqlite-native.ts +241 -0
- package/dist/extensions/pi-hermes-memory/src/store/standing-instructions.ts +247 -0
- package/dist/extensions/pi-hermes-memory/src/tools/memory-search-tool.ts +95 -0
- package/dist/extensions/pi-hermes-memory/src/tools/memory-tool.ts +469 -0
- package/dist/extensions/pi-hermes-memory/src/tools/session-search-tool.ts +246 -0
- package/dist/extensions/pi-hermes-memory/src/tools/shared-output-view.ts +170 -0
- package/dist/extensions/pi-hermes-memory/src/tools/skill-tool.ts +333 -0
- package/dist/extensions/pi-hermes-memory/src/tools/tool-result-views.ts +105 -0
- package/dist/extensions/pi-hermes-memory/src/types.ts +214 -0
- package/dist/extensions/pi-hermes-memory/tests/config.test.ts +434 -0
- package/dist/extensions/pi-hermes-memory/tests/extension-root-migration.test.ts +368 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +984 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/background-review.test.ts +950 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/correction-detector.test.ts +671 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/insights.test.ts +157 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/interview.test.ts +127 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/pi-child-process.test.ts +905 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/preview-context.test.ts +152 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/prompt-context.test.ts +166 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/resources-discover.test.ts +71 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/review-memory-ops.test.ts +614 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-backfill.test.ts +192 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-flush.test.ts +677 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-live-index.test.ts +225 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/skills-command.test.ts +827 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/standing-pin.test.ts +134 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/sync-markdown-memories.test.ts +491 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/system-prompt.test.ts +199 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/thinking-max.test.ts +64 -0
- package/dist/extensions/pi-hermes-memory/tests/index.test.ts +30 -0
- package/dist/extensions/pi-hermes-memory/tests/integration/flow.test.ts +126 -0
- package/dist/extensions/pi-hermes-memory/tests/lifecycle-timing.test.ts +76 -0
- package/dist/extensions/pi-hermes-memory/tests/paths.test.ts +23 -0
- package/dist/extensions/pi-hermes-memory/tests/project-memory-migration.test.ts +80 -0
- package/dist/extensions/pi-hermes-memory/tests/project-rebinding.test.ts +115 -0
- package/dist/extensions/pi-hermes-memory/tests/project.test.ts +120 -0
- package/dist/extensions/pi-hermes-memory/tests/run-all-timeout.test.ts +107 -0
- package/dist/extensions/pi-hermes-memory/tests/run-all.sh +45 -0
- package/dist/extensions/pi-hermes-memory/tests/store/atomic-lock-coordinator.test.ts +381 -0
- package/dist/extensions/pi-hermes-memory/tests/store/content-scanner.test.ts +388 -0
- package/dist/extensions/pi-hermes-memory/tests/store/db.test.ts +1046 -0
- package/dist/extensions/pi-hermes-memory/tests/store/markdown-mutation-lock.test.ts +34 -0
- package/dist/extensions/pi-hermes-memory/tests/store/memory-store.test.ts +2150 -0
- package/dist/extensions/pi-hermes-memory/tests/store/recovery-maintenance.test.ts +170 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-anchor-search.test.ts +210 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-indexer.test.ts +643 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-parser.test.ts +282 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-search.test.ts +287 -0
- package/dist/extensions/pi-hermes-memory/tests/store/skill-store.test.ts +678 -0
- package/dist/extensions/pi-hermes-memory/tests/store/skill-utils.test.ts +21 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-lazy-load.test.ts +78 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-memory-store.test.ts +543 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-native.test.ts +164 -0
- package/dist/extensions/pi-hermes-memory/tests/store/standing-instructions.test.ts +171 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-search-tool.test.ts +84 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-tool.test.ts +709 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/session-search-tool.test.ts +345 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/shared-output-view.test.ts +362 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/skill-tool.test.ts +478 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/tool-result-renderer-wiring.test.ts +40 -0
- package/dist/extensions/pi-hermes-memory/tsconfig.json +19 -0
- package/dist/extensions/pi-rewind-hook/README.md +4 -1
- package/dist/extensions/pi-rewind-hook/index.ts +151 -21
- package/dist/extensions/pi-rewind-hook/package.json +5 -3
- package/dist/extensions/pi-subagents/.oxlintrc.json +43 -0
- package/dist/extensions/pi-subagents/AGENTS.md +5 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +157 -0
- package/dist/extensions/pi-subagents/README.md +12 -6
- package/dist/extensions/pi-subagents/VISION.md +81 -0
- package/dist/extensions/pi-subagents/agents/claude-code-writer.md +15 -0
- package/dist/extensions/pi-subagents/agents/claude-code.md +15 -0
- package/dist/extensions/pi-subagents/agents/codex-exec-writer.md +15 -0
- package/dist/extensions/pi-subagents/agents/codex-exec.md +15 -0
- package/dist/extensions/pi-subagents/agents/cursor-agent-writer.md +14 -0
- package/dist/extensions/pi-subagents/agents/cursor-agent.md +14 -0
- package/dist/extensions/pi-subagents/agents/reviewer.md +1 -3
- package/dist/extensions/pi-subagents/docs/agents.md +488 -0
- package/dist/extensions/pi-subagents/docs/configuration.md +46 -9
- package/dist/extensions/pi-subagents/docs/extension-api.md +42 -3
- package/dist/extensions/pi-subagents/docs/models.md +27 -4
- package/dist/extensions/pi-subagents/docs/observability.md +10 -9
- package/dist/extensions/pi-subagents/docs/tool-reference.md +102 -6
- package/dist/extensions/pi-subagents/docs/workflows.md +135 -0
- package/dist/extensions/pi-subagents/package-lock.json +389 -2
- package/dist/extensions/pi-subagents/package.json +3 -1
- package/dist/extensions/pi-subagents/prompts/review-loop.md +2 -2
- package/dist/extensions/pi-subagents/skills/council-mode/SKILL.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +3 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +7 -3
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +26 -5
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +41 -22
- package/dist/extensions/pi-subagents/src/agents/agent-refinements.ts +4 -4
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +9 -2
- package/dist/extensions/pi-subagents/src/agents/agents.ts +322 -75
- package/dist/extensions/pi-subagents/src/agents/builtin-names.ts +6 -0
- package/dist/extensions/pi-subagents/src/agents/runtime-agent-events.ts +70 -0
- package/dist/extensions/pi-subagents/src/agents/runtime-agent-registry.ts +27 -20
- package/dist/extensions/pi-subagents/src/api/agents.ts +10 -5
- package/dist/extensions/pi-subagents/src/api/background-work.ts +5 -1
- package/dist/extensions/pi-subagents/src/api/delegation.ts +0 -7
- package/dist/extensions/pi-subagents/src/api/preflight.ts +34 -12
- package/dist/extensions/pi-subagents/src/extension/config.ts +70 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +3 -3
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +5 -3
- package/dist/extensions/pi-subagents/src/extension/index.ts +83 -30
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +44 -15
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +55 -19
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +41 -15
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +28 -31
- package/dist/extensions/pi-subagents/src/inspectors/herdr/actions.ts +2 -1
- package/dist/extensions/pi-subagents/src/inspectors/herdr/inspector-runner.ts +2 -10
- package/dist/extensions/pi-subagents/src/inspectors/herdr/session-roots-codec.ts +42 -0
- package/dist/extensions/pi-subagents/src/integrations/herdr-status.ts +51 -7
- package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +2 -0
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +5 -6
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +77 -10
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +176 -53
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +19 -12
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +29 -27
- package/dist/extensions/pi-subagents/src/runs/background/async-retention.ts +20 -3
- package/dist/extensions/pi-subagents/src/runs/background/async-status-snapshot.ts +23 -261
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +73 -7
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +8 -3
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +74 -8
- package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +37 -21
- package/dist/extensions/pi-subagents/src/runs/background/inspect-rpc.ts +8 -8
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +184 -9
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +45 -0
- package/dist/extensions/pi-subagents/src/runs/background/result-files.ts +28 -14
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +38 -15
- package/dist/extensions/pi-subagents/src/runs/background/resume-guidance.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +50 -13
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +93 -8
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +10 -4
- package/dist/extensions/pi-subagents/src/runs/background/steering.ts +4 -14
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +549 -377
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +58 -10
- package/dist/extensions/pi-subagents/src/runs/background/terminal-run-index.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +26 -1
- package/dist/extensions/pi-subagents/src/runs/background/wait-config.ts +23 -9
- package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +9 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +204 -129
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +9 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +23 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +773 -311
- package/dist/extensions/pi-subagents/src/runs/foreground/workflow-detach-reconcile.ts +140 -115
- package/dist/extensions/pi-subagents/src/runs/shared/abort-recovery.ts +119 -0
- package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +463 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-identity.ts +19 -4
- package/dist/extensions/pi-subagents/src/runs/shared/child-launch-plan.ts +151 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +21 -7
- package/dist/extensions/pi-subagents/src/runs/shared/claude-code-adapter.ts +129 -0
- package/dist/extensions/pi-subagents/src/runs/shared/codex-exec-adapter.ts +129 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-evidence.ts +89 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +5 -4
- package/dist/extensions/pi-subagents/src/runs/shared/cursor-agent-adapter.ts +114 -0
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-contract.ts +167 -0
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-preflight.ts +122 -0
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +348 -55
- package/dist/extensions/pi-subagents/src/runs/shared/fast-mode-extension.ts +5 -5
- package/dist/extensions/pi-subagents/src/runs/shared/host-step-status.ts +230 -0
- package/dist/extensions/pi-subagents/src/runs/shared/lane-metadata.ts +105 -0
- package/dist/extensions/pi-subagents/src/runs/shared/launch-cwd.ts +16 -0
- package/dist/extensions/pi-subagents/src/runs/shared/long-running-guard.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-config-sources.ts +422 -0
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-exclusions.ts +69 -7
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +47 -6
- package/dist/extensions/pi-subagents/src/runs/shared/mutation-evidence.ts +7 -2
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +9 -3
- package/dist/extensions/pi-subagents/src/runs/shared/nested-render.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +419 -7
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +15 -1
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +81 -8
- package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +13 -0
- package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +21 -1
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +44 -4
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +98 -13
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/worktree-cleanup-plan.ts +847 -0
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +18 -0
- package/dist/extensions/pi-subagents/src/shared/child-session-name.ts +46 -0
- package/dist/extensions/pi-subagents/src/shared/extension-context.ts +24 -0
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +21 -0
- package/dist/extensions/pi-subagents/src/shared/formatters.ts +18 -3
- package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +5 -1
- package/dist/extensions/pi-subagents/src/shared/pruned-fork.ts +450 -0
- package/dist/extensions/pi-subagents/src/shared/session-file-trust.ts +19 -0
- package/dist/extensions/pi-subagents/src/shared/session-tokens.ts +14 -3
- package/dist/extensions/pi-subagents/src/shared/settings.ts +19 -105
- package/dist/extensions/pi-subagents/src/shared/shortcuts.ts +17 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +359 -40
- package/dist/extensions/pi-subagents/src/shared/utils.ts +41 -84
- package/dist/extensions/pi-subagents/src/shared/workflow-child-permit.ts +116 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +1 -9
- package/dist/extensions/pi-subagents/src/slash/delegation-request.ts +0 -4
- package/dist/extensions/pi-subagents/src/slash/slash-bridge.ts +1 -2
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +378 -88
- package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +22 -11
- package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +3 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +147 -79
- package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +11 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +39 -18
- package/dist/extensions/pi-subagents/src/tui/render.ts +369 -46
- package/dist/extensions/pi-subagents/src/watchdog/turn-delta.ts +1 -1
- package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +6 -3
- package/dist/extensions/pi-subagents/src/workflows/host-command.ts +230 -0
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +740 -58
- package/dist/extensions/pi-subagents/src/workflows/workflow-child-summary.ts +121 -0
- package/dist/extensions/pi-subagents/src/workflows/workflow-preflight.ts +270 -0
- package/dist/extensions/pi-subagents/src/workflows/workflow-receipt.ts +195 -6
- package/dist/extensions/pi-subagents/src/workflows/workflow-settlement.ts +246 -0
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +1114 -260
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +150 -3
- package/dist/extensions/pi-subagents/test/integration/claude-code-smoke.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/integration/claude-code-writer-smoke.test.ts +55 -0
- package/dist/extensions/pi-subagents/test/integration/codex-exec-smoke.test.ts +53 -0
- package/dist/extensions/pi-subagents/test/integration/codex-exec-writer-smoke.test.ts +57 -0
- package/dist/extensions/pi-subagents/test/integration/cursor-agent-smoke.test.ts +59 -0
- package/dist/extensions/pi-subagents/test/integration/cursor-agent-writer-smoke.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +57 -1
- package/dist/extensions/pi-subagents/test/integration/external-cli-runner.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +20 -0
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/integration/orca-progress-tabs.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +54 -4
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +482 -4
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +157 -9
- package/dist/extensions/pi-subagents/test/integration/session-tokens.test.ts +5 -5
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1508 -291
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +423 -1
- package/dist/extensions/pi-subagents/test/integration/template-resolution.test.ts +8 -0
- package/dist/extensions/pi-subagents/test/support/cursor-smoke-inputs.ts +51 -0
- package/dist/extensions/pi-subagents/test/support/helpers.ts +2 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +5 -5
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +6 -6
- package/dist/extensions/pi-subagents/test/unit/abort-recovery.test.ts +152 -0
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +48 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +184 -6
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +66 -6
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +77 -0
- package/dist/extensions/pi-subagents/test/unit/agent-refinements.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/anti-slop-oxlint.test.ts +86 -0
- package/dist/extensions/pi-subagents/test/unit/artifacts.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +101 -1
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +14 -7
- package/dist/extensions/pi-subagents/test/unit/async-retention.test.ts +53 -0
- package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +190 -0
- package/dist/extensions/pi-subagents/test/unit/async-status-snapshot.test.ts +42 -0
- package/dist/extensions/pi-subagents/test/unit/auto-drain.test.ts +8 -4
- package/dist/extensions/pi-subagents/test/unit/background-work.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +78 -1
- package/dist/extensions/pi-subagents/test/unit/child-launch-plan.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/child-session-name.test.ts +50 -0
- package/dist/extensions/pi-subagents/test/unit/claude-code-adapter.test.ts +245 -0
- package/dist/extensions/pi-subagents/test/unit/codex-exec-adapter.test.ts +192 -0
- package/dist/extensions/pi-subagents/test/unit/compaction-resume.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/completion-evidence.test.ts +150 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/cursor-agent-adapter.test.ts +259 -0
- package/dist/extensions/pi-subagents/test/unit/cursor-smoke-inputs.test.ts +77 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +1 -4
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/extension-context.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +223 -0
- package/dist/extensions/pi-subagents/test/unit/fast-mode-extension.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +208 -2
- package/dist/extensions/pi-subagents/test/unit/fleet-transcript.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +110 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/formatters.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/herdr-inspector.test.ts +18 -9
- package/dist/extensions/pi-subagents/test/unit/herdr-session-roots-codec.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/unit/herdr-shell-command.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/herdr-status-bridge.test.ts +136 -6
- package/dist/extensions/pi-subagents/test/unit/host-command.test.ts +156 -0
- package/dist/extensions/pi-subagents/test/unit/host-step-status.test.ts +98 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +178 -26
- package/dist/extensions/pi-subagents/test/unit/index-segment.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/inspect-rpc.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/mcp-direct-tool-grant.test.ts +138 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/model-exclusions.test.ts +91 -0
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +72 -7
- package/dist/extensions/pi-subagents/test/unit/mutation-evidence.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +20 -2
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +129 -1
- package/dist/extensions/pi-subagents/test/unit/orca-progress-tabs.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +4 -0
- package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +358 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +574 -21
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +109 -1
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +71 -1
- package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/pruned-fork.test.ts +214 -0
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +29 -8
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +53 -0
- package/dist/extensions/pi-subagents/test/unit/result-delivery-ownership.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/unit/result-files.test.ts +101 -3
- package/dist/extensions/pi-subagents/test/unit/retained-children.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +10 -8
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +71 -1
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +41 -7
- package/dist/extensions/pi-subagents/test/unit/runtime-agent-registration.test.ts +136 -2
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +137 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +33 -12
- package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +504 -1
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +3 -5
- package/dist/extensions/pi-subagents/test/unit/steering.test.ts +5 -6
- package/dist/extensions/pi-subagents/test/unit/subagent-action-recovery.test.ts +0 -8
- package/dist/extensions/pi-subagents/test/unit/subagent-guide.test.ts +5 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +157 -2
- package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +63 -4
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +27 -23
- package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +44 -0
- package/dist/extensions/pi-subagents/test/unit/wait-subscriptions.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +7 -5
- package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +106 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-detach-reconcile.test.ts +484 -9
- package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +38 -4
- package/dist/extensions/pi-subagents/test/unit/workflow-preflight.test.ts +155 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-receipt.test.ts +318 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-resume-hint.test.ts +365 -0
- package/dist/extensions/pi-subagents/test/unit/worktree-cleanup-plan.test.ts +349 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +2 -7
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/effect/index.ts +13 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts +52 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/index.ts +41 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts +77 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts +49 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-known-value-widening.ts +247 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-module-mocking.ts +91 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-object-parameters.ts +126 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-reflect-apply.ts +28 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-reflect-get.ts +28 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts +67 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts +39 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts +83 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-returns.ts +115 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts +72 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts +134 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts +366 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts +62 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/dictionary-types.ts +502 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts +60 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/reflect-method.ts +35 -0
- package/dist/extensions/pi-tool-display/tests/capabilities.test.ts +1 -1
- package/dist/extensions/pi-tool-display/tests/settings-inspector.test.ts +2 -2
- package/dist/extensions/pi-tool-display/tests/tool-overrides-mcp.test.ts +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -7
- package/dist/skills/workflow/SKILL.md +65 -50
- package/package.json +112 -110
- package/dist/extensions/enable-readonly-tools.test.ts +0 -63
- package/dist/extensions/enable-readonly-tools.ts +0 -24
- package/dist/extensions/model-prompt-injector/config.json +0 -16
- package/dist/extensions/model-prompt-injector/index.test.ts +0 -102
- package/dist/extensions/model-prompt-injector/index.ts +0 -148
- package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +0 -98
- package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +0 -250
|
@@ -62,8 +62,11 @@ import { createNestedRoute, nestedRouteEnv, parseNestedEventRecords } from "../.
|
|
|
62
62
|
import { resolveMissionStoreLocation } from "../../src/missions/store.ts";
|
|
63
63
|
import { missionStatePath } from "../../src/missions/workflow-state.ts";
|
|
64
64
|
import { discardPreservedWorktrees } from "../../src/runs/shared/parallel-handoff.ts";
|
|
65
|
+
import { createWorktrees } from "../../src/runs/shared/worktree.ts";
|
|
65
66
|
import { resolveAsyncResumeTarget } from "../../src/runs/background/async-resume.ts";
|
|
66
67
|
import { clearExclusions } from "../../src/runs/shared/model-exclusions.ts";
|
|
68
|
+
import { createWorkflowChildPermit, workflowChildPermitConsumed } from "../../src/shared/workflow-child-permit.ts";
|
|
69
|
+
import { toSubagentDelegationExecutionParams } from "../../src/slash/delegation-adapters.ts";
|
|
67
70
|
|
|
68
71
|
interface ModelAttempt {
|
|
69
72
|
success?: boolean;
|
|
@@ -406,6 +409,69 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
406
409
|
assert.equal(output, "Hello from mock agent");
|
|
407
410
|
});
|
|
408
411
|
|
|
412
|
+
it("derives a child session name and passes it to the child env", async () => {
|
|
413
|
+
mockPi.onCall({ echoEnv: ["PI_SUBAGENT_SESSION_NAME"] });
|
|
414
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
415
|
+
|
|
416
|
+
const result = await runSync(tempDir, agents, "echo", "Say hello to the world", {});
|
|
417
|
+
|
|
418
|
+
assert.equal(result.exitCode, 0);
|
|
419
|
+
assert.equal(result.sessionName, "echo: Say hello to the world");
|
|
420
|
+
assert.equal(result.progressSummary?.sessionName, "echo: Say hello to the world");
|
|
421
|
+
const echoed = JSON.parse(getFinalOutput(result.messages));
|
|
422
|
+
assert.equal(echoed.PI_SUBAGENT_SESSION_NAME, "echo: Say hello to the world");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("rejects invalid foreground cwd before spawning Pi", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
426
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
427
|
+
const requestedCwd = "missing-local-cwd";
|
|
428
|
+
const effectiveCwd = path.resolve(tempDir, requestedCwd);
|
|
429
|
+
|
|
430
|
+
const missing = await executor.executePublic(
|
|
431
|
+
"invalid-foreground-cwd",
|
|
432
|
+
{ agent: "echo", task: "Do not spawn", async: false, cwd: requestedCwd },
|
|
433
|
+
new AbortController().signal,
|
|
434
|
+
undefined,
|
|
435
|
+
makeMinimalCtx(tempDir),
|
|
436
|
+
);
|
|
437
|
+
assert.equal(missing.isError, true);
|
|
438
|
+
assert.match(missing.content[0]?.text ?? "", new RegExp(`cwd does not exist: ${effectiveCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
439
|
+
assert.match(missing.content[0]?.text ?? "", /resolved from "missing-local-cwd"/);
|
|
440
|
+
|
|
441
|
+
const fileCwd = path.join(tempDir, "not-a-directory");
|
|
442
|
+
fs.writeFileSync(fileCwd, "file");
|
|
443
|
+
const notDirectory = await executor.executePublic(
|
|
444
|
+
"invalid-foreground-file-cwd",
|
|
445
|
+
{ agent: "echo", task: "Do not spawn", async: false, cwd: fileCwd },
|
|
446
|
+
new AbortController().signal,
|
|
447
|
+
undefined,
|
|
448
|
+
makeMinimalCtx(tempDir),
|
|
449
|
+
);
|
|
450
|
+
assert.equal(notDirectory.isError, true);
|
|
451
|
+
assert.match(notDirectory.content[0]?.text ?? "", /cwd is not a directory/);
|
|
452
|
+
assert.match(notDirectory.content[0]?.text ?? "", new RegExp(fileCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
453
|
+
assert.equal(mockPi.callCount(), 0);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("rejects invalid async cwd before spawning the native runner", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
457
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
458
|
+
const requestedCwd = "missing-async-cwd";
|
|
459
|
+
const effectiveCwd = path.resolve(tempDir, requestedCwd);
|
|
460
|
+
|
|
461
|
+
const result = await executor.executePublic(
|
|
462
|
+
"invalid-async-cwd",
|
|
463
|
+
{ agent: "echo", task: "Do not spawn", async: true, cwd: requestedCwd },
|
|
464
|
+
new AbortController().signal,
|
|
465
|
+
undefined,
|
|
466
|
+
makeMinimalCtx(tempDir),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
assert.equal(result.isError, true);
|
|
470
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(`cwd does not exist: ${effectiveCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
471
|
+
assert.match(result.content[0]?.text ?? "", /resolved from "missing-async-cwd"/);
|
|
472
|
+
assert.equal(mockPi.callCount(), 0);
|
|
473
|
+
});
|
|
474
|
+
|
|
409
475
|
it("runs public structured single-child requests directly", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
410
476
|
mockPi.onCall({ output: "Structured child completed" });
|
|
411
477
|
const executor = makeExecutor([makeAgent("echo")]);
|
|
@@ -502,6 +568,45 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
502
568
|
assert.equal(fs.readFileSync(configuredPath, "utf-8"), "Agent report");
|
|
503
569
|
});
|
|
504
570
|
|
|
571
|
+
it("preserves agent output defaults for structured prompt-template delegation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
572
|
+
const configuredPath = path.join(tempDir, "delegated-agent-report.md");
|
|
573
|
+
mockPi.onCall({
|
|
574
|
+
stdoutRaw: [
|
|
575
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
|
|
576
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
577
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
578
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
579
|
+
structuredOutputCapture: { ok: true },
|
|
580
|
+
});
|
|
581
|
+
const executor = makeExecutor([makeAgent("echo", { output: configuredPath, outputMode: "file-only" })]);
|
|
582
|
+
const request: SubagentDelegationRequest = {
|
|
583
|
+
requestId: "delegated-output-default",
|
|
584
|
+
ownerRunId: "owner-1",
|
|
585
|
+
nodeId: "node-1",
|
|
586
|
+
agent: "echo",
|
|
587
|
+
task: "Return structured data",
|
|
588
|
+
context: "fresh",
|
|
589
|
+
cwd: tempDir,
|
|
590
|
+
model: "mock/model",
|
|
591
|
+
result: { kind: "structured", schema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } } },
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const result = await executor.executeDelegated(
|
|
595
|
+
request.requestId,
|
|
596
|
+
toSubagentDelegationExecutionParams(request),
|
|
597
|
+
new AbortController().signal,
|
|
598
|
+
undefined,
|
|
599
|
+
makeMinimalCtx(tempDir),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "delegated execution failed");
|
|
603
|
+
const child = result.details?.results?.[0];
|
|
604
|
+
assert.equal(child?.savedOutputPath, configuredPath);
|
|
605
|
+
assert.equal(child?.outputMode, "file-only");
|
|
606
|
+
assert.deepEqual(child?.structuredOutput, { ok: true });
|
|
607
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(configuredPath, "utf-8")), { ok: true });
|
|
608
|
+
});
|
|
609
|
+
|
|
505
610
|
it("does not inject a workflow child output without an aggregate or explicit output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
506
611
|
mockPi.onCall({ output: "Workflow child completed" });
|
|
507
612
|
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
@@ -516,6 +621,83 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
516
621
|
assert.doesNotMatch(readCallArgs().join("\n"), /This path is authoritative for this run/);
|
|
517
622
|
});
|
|
518
623
|
|
|
624
|
+
it("consumes one exact host-only workflow child permit before spawn", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
625
|
+
const executor = makeExecutor([makeAgent("echo"), makeAgent("other"), makeAgent("external", { runner: { type: "external-cli", command: "external" } })]);
|
|
626
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
627
|
+
const script = `return runs.run("main", { agent: "echo", task: "Exact task", acceptance: false });`;
|
|
628
|
+
mockPi.onCall({ output: "projection probe" });
|
|
629
|
+
const probe = await executor.execute("probe", { async: false, workflowScript: script }, new AbortController().signal, undefined, ctx);
|
|
630
|
+
const launchContractDigest = (probe.details as { results?: Array<{ launchContractDigest?: string }> }).results?.[0]?.launchContractDigest;
|
|
631
|
+
assert.ok(launchContractDigest);
|
|
632
|
+
|
|
633
|
+
const permitFor = (workflowRunId: string, overrides: Partial<Parameters<typeof createWorkflowChildPermit>[0]> = {}) => createWorkflowChildPermit({
|
|
634
|
+
issuerPackage: "permit-secret-package",
|
|
635
|
+
workflowRunId,
|
|
636
|
+
childKey: "main",
|
|
637
|
+
agent: "echo",
|
|
638
|
+
launchContractDigest,
|
|
639
|
+
context: "fresh",
|
|
640
|
+
...overrides,
|
|
641
|
+
});
|
|
642
|
+
const run = (id: string, workflowScript: string, permit: ReturnType<typeof permitFor>, async = false) => executor.executeDelegated(
|
|
643
|
+
id,
|
|
644
|
+
{ async, workflowScript, delegatedWorkflowPermit: permit },
|
|
645
|
+
new AbortController().signal,
|
|
646
|
+
undefined,
|
|
647
|
+
ctx,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
mockPi.onCall({ output: "permitted child" });
|
|
651
|
+
const permit = permitFor("permitted");
|
|
652
|
+
const allowed = await run("permitted", script, permit);
|
|
653
|
+
assert.equal(allowed.isError, undefined, allowed.content[0]?.text ?? "permitted workflow failed");
|
|
654
|
+
assert.equal(workflowChildPermitConsumed(permit), true);
|
|
655
|
+
assert.equal(mockPi.callCount(), 2);
|
|
656
|
+
assert.doesNotMatch(JSON.stringify(allowed), /permit-secret-package|__workflowChildPermit/);
|
|
657
|
+
|
|
658
|
+
const reused = await run("permitted", script, permit);
|
|
659
|
+
assert.equal(reused.isError, true);
|
|
660
|
+
assert.match(reused.content[0]?.text ?? "", /already consumed/);
|
|
661
|
+
assert.equal(mockPi.callCount(), 2);
|
|
662
|
+
|
|
663
|
+
mockPi.onCall({ stderr: "spawned child failed", exitCode: 1 });
|
|
664
|
+
const failedPermit = permitFor("spawn-failure");
|
|
665
|
+
const spawnFailure = await run("spawn-failure", script, failedPermit);
|
|
666
|
+
assert.equal(spawnFailure.isError, true);
|
|
667
|
+
assert.equal(workflowChildPermitConsumed(failedPermit), true);
|
|
668
|
+
assert.equal(mockPi.callCount(), 3, "a consumed permit must not start a retry");
|
|
669
|
+
|
|
670
|
+
const denied = [
|
|
671
|
+
await run("wrong-key", `return runs.run("other", { agent: "echo", task: "Exact task", acceptance: false });`, permitFor("wrong-key")),
|
|
672
|
+
await run("wrong-agent", `return runs.run("main", { agent: "other", task: "Exact task", acceptance: false });`, permitFor("wrong-agent")),
|
|
673
|
+
await run("wrong-task", `return runs.run("main", { agent: "echo", task: "Changed task", acceptance: false });`, permitFor("wrong-task")),
|
|
674
|
+
await run("runs-all", `return runs.all([{ key: "main", agent: "echo", task: "Exact task", acceptance: false }]);`, permitFor("runs-all")),
|
|
675
|
+
await run("resume", `return runs.run("main", { resume: "retained-run", task: "Continue" });`, permitFor("resume")),
|
|
676
|
+
await run("external", `return runs.run("main", { agent: "external", task: "Exact task", async: false });`, permitFor("external")),
|
|
677
|
+
await run("async-root", script, permitFor("async-root"), true),
|
|
678
|
+
];
|
|
679
|
+
const denialText = denied.map((result) => result.content[0]?.text ?? "").join("\n");
|
|
680
|
+
assert.match(denialText, /child key mismatch.*agent mismatch.*final launch projection.*runs\.all.*retained resume.*native Pi children.*foreground workflow roots/s);
|
|
681
|
+
const wrongThenRightPermit = permitFor("wrong-then-right");
|
|
682
|
+
const wrongThenRight = await run("wrong-then-right", `
|
|
683
|
+
try { await runs.run("other", { agent: "echo", task: "Exact task", acceptance: false }); } catch {}
|
|
684
|
+
return runs.run("main", { agent: "echo", task: "Exact task", acceptance: false });
|
|
685
|
+
`, wrongThenRightPermit);
|
|
686
|
+
assert.equal(wrongThenRight.isError, true);
|
|
687
|
+
assert.match(wrongThenRight.content[0]?.text ?? "", /already consumed/);
|
|
688
|
+
assert.equal(workflowChildPermitConsumed(wrongThenRightPermit), true);
|
|
689
|
+
assert.equal(mockPi.callCount(), 3, "wrong-then-right must not spawn");
|
|
690
|
+
const fallback = await makeExecutor([makeAgent("echo", { model: "mock/primary", fallbackModels: ["mock/backup"] })]).executeDelegated(
|
|
691
|
+
"fallback",
|
|
692
|
+
{ async: false, workflowScript: script, delegatedWorkflowPermit: permitFor("fallback") },
|
|
693
|
+
new AbortController().signal,
|
|
694
|
+
undefined,
|
|
695
|
+
ctx,
|
|
696
|
+
);
|
|
697
|
+
assert.match(fallback.content[0]?.text ?? "", /does not support model fallback/);
|
|
698
|
+
assert.equal(mockPi.callCount(), 3);
|
|
699
|
+
});
|
|
700
|
+
|
|
519
701
|
it("resolves workflow child profile context from its agent default", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
520
702
|
mockPi.onCall({ output: "Workflow child completed" });
|
|
521
703
|
const result = await makeExecutor([makeAgent("echo", { defaultContext: "fresh" })], { defaultSubagentContext: "fork" }).execute(
|
|
@@ -630,16 +812,17 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
630
812
|
assert.equal(mockPi.callCount(), 1);
|
|
631
813
|
});
|
|
632
814
|
|
|
633
|
-
it("allows schedule.create to
|
|
815
|
+
it("allows schedule.create to load its workflowScript target from a path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
634
816
|
let forwarded;
|
|
635
817
|
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, async (params) => {
|
|
636
818
|
forwarded = params;
|
|
637
819
|
return { content: [{ type: "text", text: "created" }], details: { mode: "management", results: [] } };
|
|
638
820
|
});
|
|
821
|
+
fs.writeFileSync(path.join(tempDir, "scheduled.js"), "return runs.run('main', { agent: 'echo' })");
|
|
639
822
|
|
|
640
|
-
const result = await executor.
|
|
823
|
+
const result = await executor.executePublic(
|
|
641
824
|
"schedule-create",
|
|
642
|
-
{ action: "schedule.create", id: "nightly", every: "1h",
|
|
825
|
+
{ action: "schedule.create", id: "nightly", every: "1h", workflowScriptPath: "scheduled.js" },
|
|
643
826
|
new AbortController().signal,
|
|
644
827
|
undefined,
|
|
645
828
|
makeMinimalCtx(tempDir),
|
|
@@ -650,6 +833,316 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
650
833
|
assert.equal(forwarded?.workflowScript, "return runs.run('main', { agent: 'echo' })");
|
|
651
834
|
});
|
|
652
835
|
|
|
836
|
+
it("validates workflow scripts without launching children or creating artifacts", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
837
|
+
const before = fs.readdirSync(tempDir).sort();
|
|
838
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
|
|
839
|
+
throw new Error("validate must not discover or launch agents");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const result = await executor.executePublic(
|
|
843
|
+
"offline-validation",
|
|
844
|
+
{ action: "validate", workflowScript: `return runs.run("bad key", { agent: "echo" });` },
|
|
845
|
+
new AbortController().signal,
|
|
846
|
+
undefined,
|
|
847
|
+
makeMinimalCtx(tempDir),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
assert.equal(result.isError, true);
|
|
851
|
+
assert.deepEqual(JSON.parse(result.content[0]?.text ?? "null"), {
|
|
852
|
+
ok: false,
|
|
853
|
+
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 }],
|
|
854
|
+
});
|
|
855
|
+
const invalidPreflight = await executor.executePublic(
|
|
856
|
+
"invalid-preflight",
|
|
857
|
+
{ workflowScript: `return runs.run("child", { agent: "echo" });`, preflight: { version: 1, lanes: [{ key: "bad key" }] } },
|
|
858
|
+
new AbortController().signal,
|
|
859
|
+
undefined,
|
|
860
|
+
makeMinimalCtx(tempDir),
|
|
861
|
+
);
|
|
862
|
+
assert.equal(invalidPreflight.isError, true);
|
|
863
|
+
assert.match(invalidPreflight.content[0]?.text ?? "", /preflight\.lanes\[0\]\.key/);
|
|
864
|
+
const offlinePreflight = await executor.executePublic(
|
|
865
|
+
"offline-preflight",
|
|
866
|
+
{ action: "validate", workflowScript: "return 1;", preflight: { version: 1, lanes: [{ key: "bad key" }] } },
|
|
867
|
+
new AbortController().signal,
|
|
868
|
+
undefined,
|
|
869
|
+
makeMinimalCtx(tempDir),
|
|
870
|
+
);
|
|
871
|
+
assert.equal(offlinePreflight.isError, true);
|
|
872
|
+
const offlinePreflightValidation = JSON.parse(offlinePreflight.content[0]?.text ?? "null") as { ok?: boolean; errors?: unknown[] };
|
|
873
|
+
assert.equal(offlinePreflightValidation.ok, false);
|
|
874
|
+
assert.match(JSON.stringify(offlinePreflightValidation.errors ?? []), /preflight\.lanes\[0\]\.key/);
|
|
875
|
+
assert.equal(mockPi.callCount(), 0);
|
|
876
|
+
assert.deepEqual(fs.readdirSync(tempDir).sort(), before);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("runs a workflow host command without launching a child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
880
|
+
const scriptPath = path.join(tempDir, "host-command.cjs");
|
|
881
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("host command passed\\n");`);
|
|
882
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
883
|
+
const result = await executor.executePublic(
|
|
884
|
+
"host-command",
|
|
885
|
+
{
|
|
886
|
+
async: false,
|
|
887
|
+
output: "reports/host-command.log",
|
|
888
|
+
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" });`,
|
|
889
|
+
},
|
|
890
|
+
new AbortController().signal,
|
|
891
|
+
undefined,
|
|
892
|
+
makeMinimalCtx(tempDir),
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "host command workflow failed");
|
|
896
|
+
assert.equal(mockPi.callCount(), 0);
|
|
897
|
+
const savedOutput = fs.readFileSync(path.join(tempDir, "reports", "host-command.log"), "utf8");
|
|
898
|
+
assert.match(savedOutput, /host command passed/);
|
|
899
|
+
assert.doesNotMatch(savedOutput, /Workflow completed/);
|
|
900
|
+
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 }]);
|
|
901
|
+
|
|
902
|
+
const failedScriptPath = path.join(tempDir, "host-command-failed.cjs");
|
|
903
|
+
fs.writeFileSync(failedScriptPath, `process.stderr.write("host command failed\\n"); process.exit(4);`);
|
|
904
|
+
const failed = await executor.executePublic(
|
|
905
|
+
"host-command-failed",
|
|
906
|
+
{
|
|
907
|
+
async: false,
|
|
908
|
+
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" });`,
|
|
909
|
+
},
|
|
910
|
+
new AbortController().signal,
|
|
911
|
+
undefined,
|
|
912
|
+
makeMinimalCtx(tempDir),
|
|
913
|
+
);
|
|
914
|
+
assert.equal(failed.isError, true);
|
|
915
|
+
assert.deepEqual(failed.details.workflow?.receipt?.hostSteps?.map(({ state, reasonCode, exitCode }) => ({ state, reasonCode, exitCode })), [{ state: "error", reasonCode: "command_failed", exitCode: 4 }]);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("rejects a child output claimed by an earlier host command", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
919
|
+
const scriptPath = path.join(tempDir, "host-output-owner.cjs");
|
|
920
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("host owns output\\n");`);
|
|
921
|
+
const sharedOutput = path.join(tempDir, "reports", "shared.log");
|
|
922
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
923
|
+
const result = await executor.executePublic(
|
|
924
|
+
"host-output-collision",
|
|
925
|
+
{
|
|
926
|
+
async: false,
|
|
927
|
+
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)} });`,
|
|
928
|
+
},
|
|
929
|
+
new AbortController().signal,
|
|
930
|
+
undefined,
|
|
931
|
+
makeMinimalCtx(tempDir),
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
assert.match(result.content[0]?.text ?? "", /output path is already claimed|resolve output to the same path/);
|
|
935
|
+
assert.equal(mockPi.callCount(), 0);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
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 () => {
|
|
939
|
+
const scriptPath = path.join(tempDir, "host-output-alias-owner.cjs");
|
|
940
|
+
const reportsDir = path.join(tempDir, "reports");
|
|
941
|
+
fs.mkdirSync(reportsDir);
|
|
942
|
+
fs.symlinkSync(reportsDir, path.join(tempDir, "linked-reports"), "dir");
|
|
943
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("host owns output alias\\n");`);
|
|
944
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
945
|
+
const result = await executor.executePublic(
|
|
946
|
+
"host-output-alias-collision",
|
|
947
|
+
{
|
|
948
|
+
async: false,
|
|
949
|
+
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"))} });`,
|
|
950
|
+
},
|
|
951
|
+
new AbortController().signal,
|
|
952
|
+
undefined,
|
|
953
|
+
makeMinimalCtx(tempDir),
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
assert.match(result.content[0]?.text ?? "", /output path is already claimed|resolve output to the same path/);
|
|
957
|
+
assert.equal(mockPi.callCount(), 0);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
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 () => {
|
|
961
|
+
const reportsDir = path.join(tempDir, "reports");
|
|
962
|
+
fs.mkdirSync(reportsDir);
|
|
963
|
+
const firstScriptPath = path.join(tempDir, "host-output-first.cjs");
|
|
964
|
+
const aliasScriptPath = path.join(tempDir, "host-output-alias.cjs");
|
|
965
|
+
fs.writeFileSync(firstScriptPath, `process.stdout.write("first evidence\\n");`);
|
|
966
|
+
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");`);
|
|
967
|
+
const result = await makeExecutor([makeAgent("echo")]).executePublic(
|
|
968
|
+
"host-output-late-alias-collision",
|
|
969
|
+
{
|
|
970
|
+
async: false,
|
|
971
|
+
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" });`,
|
|
972
|
+
},
|
|
973
|
+
new AbortController().signal,
|
|
974
|
+
undefined,
|
|
975
|
+
makeMinimalCtx(tempDir),
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
assert.equal(result.isError, true);
|
|
979
|
+
assert.match(result.content[0]?.text ?? "", /output path changed after it was claimed/);
|
|
980
|
+
assert.match(fs.readFileSync(path.join(reportsDir, "shared.log"), "utf-8"), /first evidence/);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
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 () => {
|
|
984
|
+
const reportsDir = path.join(tempDir, "reports");
|
|
985
|
+
fs.mkdirSync(reportsDir);
|
|
986
|
+
const sharedOutput = path.join(reportsDir, "shared.log");
|
|
987
|
+
fs.writeFileSync(sharedOutput, "prior output\n", "utf-8");
|
|
988
|
+
const lateLink = path.join(tempDir, "late-link");
|
|
989
|
+
const claimedOutput = path.join(lateLink, "shared.log");
|
|
990
|
+
const releasePath = path.join(tempDir, "release-child-output");
|
|
991
|
+
mockPi.onCall({ waitForPath: releasePath, output: "child fallback output" });
|
|
992
|
+
|
|
993
|
+
const pending = makeExecutor([makeAgent("echo")]).executePublic(
|
|
994
|
+
"child-output-late-alias-collision",
|
|
995
|
+
{
|
|
996
|
+
async: false,
|
|
997
|
+
workflowScript: `return await runs.run("child", { agent: "echo", task: "unused", output: ${JSON.stringify(claimedOutput)} });`,
|
|
998
|
+
},
|
|
999
|
+
new AbortController().signal,
|
|
1000
|
+
undefined,
|
|
1001
|
+
makeMinimalCtx(tempDir),
|
|
1002
|
+
);
|
|
1003
|
+
for (let attempt = 0; attempt < 100 && mockPi.callCount() === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1004
|
+
if (mockPi.callCount() !== 1) {
|
|
1005
|
+
fs.writeFileSync(releasePath, "go", "utf-8");
|
|
1006
|
+
await pending.catch(() => undefined);
|
|
1007
|
+
}
|
|
1008
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1009
|
+
fs.symlinkSync(reportsDir, lateLink, "dir");
|
|
1010
|
+
fs.writeFileSync(releasePath, "go", "utf-8");
|
|
1011
|
+
|
|
1012
|
+
const result = await pending;
|
|
1013
|
+
const child = (result.details as { results?: Array<{ exitCode?: number; outputSaveError?: string; savedOutputPath?: string }> } | undefined)?.results?.[0];
|
|
1014
|
+
assert.equal(child?.exitCode, 1);
|
|
1015
|
+
assert.match(child?.outputSaveError ?? "", /Output path changed after it was claimed/);
|
|
1016
|
+
assert.equal(child?.savedOutputPath, undefined);
|
|
1017
|
+
assert.equal(fs.readFileSync(sharedOutput, "utf-8"), "prior output\n");
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it("persists async host command status and receipt evidence", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1021
|
+
const scriptPath = path.join(tempDir, "async-host-command.cjs");
|
|
1022
|
+
fs.writeFileSync(scriptPath, `process.stdout.write("async host passed\\n");`);
|
|
1023
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1024
|
+
const result = await executor.execute(
|
|
1025
|
+
"async-host-command",
|
|
1026
|
+
{
|
|
1027
|
+
async: true,
|
|
1028
|
+
mission: false,
|
|
1029
|
+
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" });`,
|
|
1030
|
+
},
|
|
1031
|
+
new AbortController().signal,
|
|
1032
|
+
undefined,
|
|
1033
|
+
makeMinimalCtx(tempDir),
|
|
1034
|
+
);
|
|
1035
|
+
assert.ok(result.details.asyncDir);
|
|
1036
|
+
const statusPath = path.join(result.details.asyncDir!, "status.json");
|
|
1037
|
+
let status: { state?: string; workflowGraph?: { nodes?: Array<{ hostStep?: { monitorKind?: string; state?: string; role?: string; reportPath?: string; exitCode?: number | null; updatedAt?: number; deadlineAt?: number } }> } } = {};
|
|
1038
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1039
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
1040
|
+
if (status.state === "complete" || status.state === "failed") break;
|
|
1041
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1042
|
+
}
|
|
1043
|
+
assert.equal(status.state, "complete");
|
|
1044
|
+
assert.deepEqual(status.workflowGraph?.nodes?.[0]?.hostStep, {
|
|
1045
|
+
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,
|
|
1046
|
+
updatedAt: status.workflowGraph?.nodes?.[0]?.hostStep?.updatedAt,
|
|
1047
|
+
deadlineAt: status.workflowGraph?.nodes?.[0]?.hostStep?.deadlineAt,
|
|
1048
|
+
});
|
|
1049
|
+
const receipt = JSON.parse(fs.readFileSync(path.join(result.details.asyncDir!, "workflow-receipt.json"), "utf8")) as { hostSteps?: Array<{ monitorKind?: string; state?: string; reportPath?: string }> };
|
|
1050
|
+
assert.deepEqual(receipt.hostSteps?.map(({ monitorKind, state, reportPath }) => ({ monitorKind, state, reportPath })), [{ monitorKind: "command", state: "done", reportPath: "reports/async-host.log" }]);
|
|
1051
|
+
assert.match(fs.readFileSync(path.join(tempDir, "reports", "async-host.log"), "utf8"), /async host passed/);
|
|
1052
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
|
|
1053
|
+
if (result.details.asyncId) fs.rmSync(path.join(DIRS.results, `${result.details.asyncId}.json`), { force: true });
|
|
1054
|
+
|
|
1055
|
+
const failedScript = path.join(tempDir, "async-host-command-failed.cjs");
|
|
1056
|
+
fs.writeFileSync(failedScript, `process.stderr.write("async host failed\\n"); process.exit(3);`);
|
|
1057
|
+
const failed = await executor.execute(
|
|
1058
|
+
"async-host-command-failed",
|
|
1059
|
+
{
|
|
1060
|
+
async: true,
|
|
1061
|
+
mission: false,
|
|
1062
|
+
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" });`,
|
|
1063
|
+
},
|
|
1064
|
+
new AbortController().signal,
|
|
1065
|
+
undefined,
|
|
1066
|
+
makeMinimalCtx(tempDir),
|
|
1067
|
+
);
|
|
1068
|
+
assert.ok(failed.details.asyncDir);
|
|
1069
|
+
const failedStatusPath = path.join(failed.details.asyncDir!, "status.json");
|
|
1070
|
+
let failedStatus: { state?: string; workflowGraph?: { nodes?: Array<{ hostStep?: { state?: string; reasonCode?: string; exitCode?: number | null } }> } } = {};
|
|
1071
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
1072
|
+
failedStatus = JSON.parse(fs.readFileSync(failedStatusPath, "utf8"));
|
|
1073
|
+
if (failedStatus.state === "complete" || failedStatus.state === "failed") break;
|
|
1074
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1075
|
+
}
|
|
1076
|
+
assert.equal(failedStatus.state, "failed");
|
|
1077
|
+
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 }]);
|
|
1078
|
+
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 }> };
|
|
1079
|
+
assert.equal(failedReceipt.state, "failed");
|
|
1080
|
+
assert.deepEqual(failedReceipt.hostSteps?.map(({ state, reasonCode, exitCode }) => ({ state, reasonCode, exitCode })), [{ state: "error", reasonCode: "command_failed", exitCode: 3 }]);
|
|
1081
|
+
assert.match(fs.readFileSync(path.join(tempDir, "reports", "async-host-failed.log"), "utf8"), /async host failed/);
|
|
1082
|
+
fs.rmSync(failed.details.asyncDir!, { recursive: true, force: true });
|
|
1083
|
+
if (failed.details.asyncId) fs.rmSync(path.join(DIRS.results, `${failed.details.asyncId}.json`), { force: true });
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("loads workflowScriptPath from the request cwd for validation without launching", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1087
|
+
const requestCwd = path.join(tempDir, "request-cwd");
|
|
1088
|
+
fs.mkdirSync(requestCwd);
|
|
1089
|
+
fs.writeFileSync(path.join(requestCwd, "workflow.js"), `return runs.run("bad key", { agent: "echo" });`);
|
|
1090
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
|
|
1091
|
+
throw new Error("validate must not discover or launch agents");
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const result = await executor.executePublic(
|
|
1095
|
+
"file-validation",
|
|
1096
|
+
{ action: "validate", cwd: "request-cwd", workflowScriptPath: "workflow.js" },
|
|
1097
|
+
new AbortController().signal,
|
|
1098
|
+
undefined,
|
|
1099
|
+
makeMinimalCtx(tempDir),
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
assert.equal(result.isError, true);
|
|
1103
|
+
assert.deepEqual(JSON.parse(result.content[0]?.text ?? "null"), {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
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 }],
|
|
1106
|
+
});
|
|
1107
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it("reports missing and empty workflowScriptPath files before validation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1111
|
+
fs.writeFileSync(path.join(tempDir, "empty.js"), " \n");
|
|
1112
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
|
|
1113
|
+
throw new Error("file input errors must not discover or launch agents");
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
const missing = await executor.executePublic("missing-file", { action: "validate", workflowScriptPath: "missing.js" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
1117
|
+
assert.equal(missing.isError, true);
|
|
1118
|
+
assert.match(missing.content[0]?.text ?? "", /Failed to read workflowScriptPath.*missing\.js/);
|
|
1119
|
+
assert.doesNotMatch(missing.content[0]?.text ?? "", /validation failed|valid JavaScript/);
|
|
1120
|
+
|
|
1121
|
+
const empty = await executor.executePublic("empty-file", { action: "validate", workflowScriptPath: "empty.js" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
1122
|
+
assert.equal(empty.isError, true);
|
|
1123
|
+
assert.match(empty.content[0]?.text ?? "", /workflowScriptPath file .*empty\.js.* is empty/);
|
|
1124
|
+
assert.doesNotMatch(empty.content[0]?.text ?? "", /validation failed|valid JavaScript/);
|
|
1125
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("executes a workflow loaded from workflowScriptPath", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1129
|
+
fs.writeFileSync(path.join(tempDir, "workflow.js"), `return runs.run("main", { agent: "echo", task: "from file" });`);
|
|
1130
|
+
mockPi.onCall({ output: "loaded workflow" });
|
|
1131
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1132
|
+
|
|
1133
|
+
const result = await executor.executePublic(
|
|
1134
|
+
"file-execution",
|
|
1135
|
+
{ async: false, workflowScriptPath: path.join(tempDir, "workflow.js"), preflight: { version: 1, coverage: "complete", lanes: [{ key: "main", mode: "mutation" }] } },
|
|
1136
|
+
new AbortController().signal,
|
|
1137
|
+
undefined,
|
|
1138
|
+
makeMinimalCtx(tempDir),
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1141
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "file workflow failed");
|
|
1142
|
+
assert.deepEqual(result.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "main", mode: "mutation" }] });
|
|
1143
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
653
1146
|
it("starts workflow scripts asynchronously with a portable internal run id", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
654
1147
|
mockPi.onCall({ echoEnv: [SUBAGENT_STEER_INBOX_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_ACK_DIR_ENV] });
|
|
655
1148
|
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
@@ -664,7 +1157,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
664
1157
|
toolCallId,
|
|
665
1158
|
{
|
|
666
1159
|
cwd: workflowCwd,
|
|
667
|
-
workflowScript: `emit("starting"); await runs.run("work", { agent: "helper", task: "Async work" }); return { answer: 42 };`,
|
|
1160
|
+
workflowScript: `emit("starting"); await runs.run("work", { agent: "helper", label: "Run async child", phase: "Execution", task: "Async work" }); return { answer: 42 };`,
|
|
1161
|
+
preflight: { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] },
|
|
668
1162
|
mission: { summary: "Review the active backlog", labels: ["github-backlog", "review"] },
|
|
669
1163
|
},
|
|
670
1164
|
new AbortController().signal,
|
|
@@ -678,17 +1172,20 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
678
1172
|
assert.ok(result.details.asyncId);
|
|
679
1173
|
const workflowRunId = result.details.asyncId;
|
|
680
1174
|
assert.equal(result.details.runId, workflowRunId);
|
|
1175
|
+
assert.deepEqual(result.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
681
1176
|
assert.notEqual(workflowRunId, toolCallId);
|
|
682
1177
|
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}$/);
|
|
683
1178
|
assert.equal(path.basename(result.details.asyncDir!), workflowRunId);
|
|
684
1179
|
assert.equal(asyncJobs.has(workflowRunId), true);
|
|
685
1180
|
assert.equal(asyncJobs.get(workflowRunId)?.cwd, workflowCwd);
|
|
686
1181
|
assert.equal(asyncJobs.get(workflowRunId)?.sessionRoot, path.join(tempDir, ".pi-subagents", "sessions"));
|
|
1182
|
+
assert.deepEqual(asyncJobs.get(workflowRunId)?.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
687
1183
|
assert.equal(asyncJobs.has(toolCallId), false);
|
|
688
1184
|
assert.equal(fs.existsSync(path.join(DIRS.async, toolCallId)), false);
|
|
1185
|
+
assert.match(result.content[0]?.text ?? "", /Preflight: v1 · complete · 1 lane/);
|
|
689
1186
|
assert.match(result.content[0]?.text ?? "", /Async workflow/);
|
|
690
1187
|
const statusPath = path.join(result.details.asyncDir!, "status.json");
|
|
691
|
-
let status: { runId?: string; toolCallId?: string; cwd?: string; sessionRoot?: string; state?: string; steps?: Array<{ agent?: string; label?: string; workflowKey?: string; parentWorkflowRunId?: string }>; workflow?: { value?: unknown; emits?: unknown[]; trace?: Array<{ key?: string; agent?: string; state?: string }> } } = {};
|
|
1188
|
+
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 }>; workflow?: { value?: unknown; emits?: unknown[]; trace?: Array<{ key?: string; agent?: string; label?: string; phase?: string; state?: string }> } } = {};
|
|
692
1189
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
693
1190
|
status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
694
1191
|
if (status.state === "complete" || status.state === "failed") break;
|
|
@@ -699,15 +1196,26 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
699
1196
|
assert.equal(status.toolCallId, toolCallId);
|
|
700
1197
|
assert.equal(status.cwd, workflowCwd);
|
|
701
1198
|
assert.equal(status.sessionRoot, path.join(tempDir, ".pi-subagents", "sessions"));
|
|
1199
|
+
assert.deepEqual(status.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
1200
|
+
const statusResult = await executor.execute(
|
|
1201
|
+
"status-preflight",
|
|
1202
|
+
{ action: "status", id: workflowRunId },
|
|
1203
|
+
new AbortController().signal,
|
|
1204
|
+
undefined,
|
|
1205
|
+
context,
|
|
1206
|
+
);
|
|
1207
|
+
assert.match(statusResult.content[0]?.text ?? "", /Plan: 1 lane · work/);
|
|
1208
|
+
assert.doesNotMatch(statusResult.content[0]?.text ?? "", /key \| mode \| decision \| claims \| expected output \| independence/);
|
|
1209
|
+
assert.deepEqual(statusResult.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
|
|
702
1210
|
assert.equal(status.steps?.length, 1);
|
|
703
|
-
assert.deepEqual(status.steps?.map(({ agent, label, workflowKey }) => ({ agent, label, workflowKey })), [
|
|
704
|
-
{ agent: "echo",
|
|
1211
|
+
assert.deepEqual(status.steps?.map(({ agent, sessionName, label, phase, workflowKey }) => ({ agent, sessionName, label, phase, workflowKey })), [
|
|
1212
|
+
{ agent: "echo", sessionName: "echo: Async work", label: "Run async child", phase: "Execution", workflowKey: "work" },
|
|
705
1213
|
]);
|
|
706
1214
|
assert.ok(status.steps?.every((step) => step.parentWorkflowRunId === workflowRunId));
|
|
707
1215
|
assert.deepEqual(status.workflow?.value, { answer: 42 });
|
|
708
1216
|
assert.deepEqual(status.workflow?.emits, ["starting"]);
|
|
709
1217
|
assert.equal(mockPi.callCount(), 1);
|
|
710
|
-
assert.ok(status.workflow?.trace?.some((entry) => entry.key === "work" && entry.agent === "echo" && entry.state === "completed"));
|
|
1218
|
+
assert.ok(status.workflow?.trace?.some((entry) => entry.key === "work" && entry.agent === "echo" && entry.label === "Run async child" && entry.phase === "Execution" && entry.state === "completed"));
|
|
711
1219
|
const traceEvents = fs.readFileSync(path.join(result.details.asyncDir!, "events.jsonl"), "utf-8")
|
|
712
1220
|
.trim()
|
|
713
1221
|
.split("\n")
|
|
@@ -720,15 +1228,16 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
720
1228
|
{ key: "work", state: "completed" },
|
|
721
1229
|
]);
|
|
722
1230
|
const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
|
|
723
|
-
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 }; continuation?: { runIds?: string[] } }> } }; results?: Array<{ agent?: string; workflowKey?: string; runId?: string; output?: string }> };
|
|
1231
|
+
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 } }> };
|
|
724
1232
|
assert.equal(persistedResult.id, workflowRunId);
|
|
725
1233
|
assert.equal(persistedResult.runId, workflowRunId);
|
|
726
1234
|
assert.equal(persistedResult.toolCallId, toolCallId);
|
|
727
1235
|
assert.equal(persistedResult.agent, "workflow");
|
|
728
1236
|
assert.equal(persistedResult.cwd, workflowCwd);
|
|
729
|
-
assert.deepEqual(persistedResult.results?.map(({ agent, workflowKey }) => ({ agent, workflowKey })), [
|
|
730
|
-
{ agent: "echo", workflowKey: "work" },
|
|
1237
|
+
assert.deepEqual(persistedResult.results?.map(({ agent, sessionName, workflowKey }) => ({ agent, sessionName, workflowKey })), [
|
|
1238
|
+
{ agent: "echo", sessionName: "echo: Async work", workflowKey: "work" },
|
|
731
1239
|
]);
|
|
1240
|
+
assert.deepEqual(persistedResult.results?.[0]?.usage, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.001, turns: 1 });
|
|
732
1241
|
const steeringEnv = JSON.parse(persistedResult.results?.[0]?.output ?? "null") as Record<string, string | null>;
|
|
733
1242
|
assert.match(steeringEnv[SUBAGENT_STEER_INBOX_ENV] ?? "", /control[/\\]workflow-foreground[/\\].+[/\\]control[/\\]steer-targets[/\\]0$/);
|
|
734
1243
|
assert.match(steeringEnv[SUBAGENT_STEER_CAPABILITY_ENV] ?? "", /control[/\\]workflow-foreground[/\\].+[/\\]control[/\\]steer-capabilities[/\\]0\.json$/);
|
|
@@ -744,13 +1253,72 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
744
1253
|
assert.equal(persistedResult.workflowReceipt?.receipt?.entries?.work?.latestRunId, persistedResult.results?.[0]?.runId);
|
|
745
1254
|
assert.equal(fs.existsSync(path.join(DIRS.async, persistedResult.results?.[0]?.runId ?? "missing")), false);
|
|
746
1255
|
assert.deepEqual(persistedResult.workflowReceipt?.receipt?.entries?.work?.continuation?.runIds, [persistedResult.results?.[0]?.runId]);
|
|
747
|
-
assert.
|
|
1256
|
+
assert.deepEqual(persistedResult.workflowReceipt?.receipt?.entries?.work?.resumability, { state: "resumable" });
|
|
748
1257
|
assert.deepEqual(JSON.parse(fs.readFileSync(persistedResult.workflowReceipt!.path!, "utf-8")), persistedResult.workflowReceipt?.receipt);
|
|
749
1258
|
assert.equal(fs.existsSync(path.join(DIRS.results, `${toolCallId}.json`)), false);
|
|
750
1259
|
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
|
|
751
1260
|
fs.rmSync(resultPath, { force: true });
|
|
752
1261
|
});
|
|
753
1262
|
|
|
1263
|
+
it("keeps script workflow phase during async auto-resume", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1264
|
+
mockPi.onCall({
|
|
1265
|
+
jsonl: [
|
|
1266
|
+
events.toolStart("read", { path: "src/index.ts" }),
|
|
1267
|
+
events.toolEnd("read"),
|
|
1268
|
+
events.toolResult("read", "file contents"),
|
|
1269
|
+
{
|
|
1270
|
+
type: "message_end",
|
|
1271
|
+
message: {
|
|
1272
|
+
role: "assistant",
|
|
1273
|
+
content: [],
|
|
1274
|
+
model: "openai-codex/gpt-5.6-luna",
|
|
1275
|
+
stopReason: "error",
|
|
1276
|
+
errorMessage: "This operation was aborted",
|
|
1277
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
],
|
|
1281
|
+
exitCode: 1,
|
|
1282
|
+
});
|
|
1283
|
+
const releasePath = path.join(tempDir, "release-auto-resume-child");
|
|
1284
|
+
mockPi.onCall({ waitForPath: releasePath, output: "Recovered after workflow auto-resume" });
|
|
1285
|
+
const executor = makeExecutor([makeAgent("echo", { aliases: ["helper"] })]);
|
|
1286
|
+
const result = await executor.execute(
|
|
1287
|
+
"workflow-auto-resume-phase-status",
|
|
1288
|
+
{ workflowScript: `await runs.run("work", { agent: "helper", label: "Review current diff", phase: "Review", task: "Review the current diff" }); return { ok: true };` },
|
|
1289
|
+
new AbortController().signal,
|
|
1290
|
+
undefined,
|
|
1291
|
+
makeMinimalCtx(tempDir),
|
|
1292
|
+
);
|
|
1293
|
+
|
|
1294
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
1295
|
+
const statusPath = path.join(result.details.asyncDir!, "status.json");
|
|
1296
|
+
let status: { state?: string; steps?: Array<{ workflowKey?: string; phase?: string }> } = {};
|
|
1297
|
+
for (let attempt = 0; attempt < 100 && mockPi.callCount() < 2; attempt++) {
|
|
1298
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1299
|
+
}
|
|
1300
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1301
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
1302
|
+
if (status.state === "running" && status.steps?.some((step) => step.workflowKey === "work" && step.phase !== undefined)) break;
|
|
1303
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1304
|
+
}
|
|
1305
|
+
assert.equal(status.state, "running");
|
|
1306
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "work")?.phase, "Review");
|
|
1307
|
+
|
|
1308
|
+
fs.writeFileSync(releasePath, "go", "utf-8");
|
|
1309
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1310
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
1311
|
+
if (status.state === "complete" || status.state === "failed") break;
|
|
1312
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1313
|
+
}
|
|
1314
|
+
assert.equal(status.state, "complete");
|
|
1315
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "work")?.phase, "Review");
|
|
1316
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1317
|
+
|
|
1318
|
+
fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
|
|
1319
|
+
fs.rmSync(path.join(DIRS.results, `${result.details.asyncId}.json`), { force: true });
|
|
1320
|
+
});
|
|
1321
|
+
|
|
754
1322
|
it("runs an external CLI workflow child with subagents.defaultModel configured", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
755
1323
|
const markerPath = path.join(tempDir, "external-started");
|
|
756
1324
|
const executor = makeExecutor([
|
|
@@ -780,8 +1348,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
780
1348
|
}
|
|
781
1349
|
assert.equal(workflowResult.state, "complete");
|
|
782
1350
|
assert.match(workflowResult.results?.[0]?.output ?? "", /Async: external/);
|
|
783
|
-
for (let attempt = 0; attempt <
|
|
784
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1351
|
+
for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
|
|
1352
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
785
1353
|
}
|
|
786
1354
|
assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
|
|
787
1355
|
assert.equal(mockPi.callCount(), 0);
|
|
@@ -834,8 +1402,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
834
1402
|
assert.equal(result.isError, undefined, result.content[0]?.text ?? "launch failed");
|
|
835
1403
|
assert.ok(result.details.asyncId);
|
|
836
1404
|
assert.match(result.content[0]?.text ?? "", /Async: external/);
|
|
837
|
-
for (let attempt = 0; attempt <
|
|
838
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1405
|
+
for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
|
|
1406
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
839
1407
|
}
|
|
840
1408
|
assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
|
|
841
1409
|
assert.equal(mockPi.callCount(), 0);
|
|
@@ -883,8 +1451,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
883
1451
|
|
|
884
1452
|
assert.equal(result.isError, undefined, result.content[0]?.text ?? "launch failed");
|
|
885
1453
|
assert.ok(result.details.asyncId);
|
|
886
|
-
for (let attempt = 0; attempt <
|
|
887
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1454
|
+
for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
|
|
1455
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
888
1456
|
}
|
|
889
1457
|
assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
|
|
890
1458
|
assert.equal(mockPi.callCount(), 0);
|
|
@@ -923,8 +1491,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
923
1491
|
|
|
924
1492
|
assert.equal(result.isError, undefined);
|
|
925
1493
|
assert.doesNotMatch(result.content[0]?.text ?? "", /Unknown subagent model/);
|
|
926
|
-
for (let attempt = 0; attempt <
|
|
927
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1494
|
+
for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
|
|
1495
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
928
1496
|
}
|
|
929
1497
|
assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
|
|
930
1498
|
assert.equal(mockPi.callCount(), 0);
|
|
@@ -1329,7 +1897,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1329
1897
|
const toolCallId = `scripted-workflow-parent-${Date.now()}`;
|
|
1330
1898
|
const started = await executor.execute(
|
|
1331
1899
|
toolCallId,
|
|
1332
|
-
{ workflowScript: `const child = await runs.run("background", { agent: "echo", task: "Async child", async: true, worktree: true }); return child.runId;` },
|
|
1900
|
+
{ 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;` },
|
|
1333
1901
|
new AbortController().signal,
|
|
1334
1902
|
undefined,
|
|
1335
1903
|
makeMinimalCtx(tempDir),
|
|
@@ -1349,10 +1917,11 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1349
1917
|
const workflowStatus = JSON.parse(fs.readFileSync(path.join(started.details.asyncDir!, "status.json"), "utf-8")) as AsyncStatus;
|
|
1350
1918
|
const workflowStepSessionFile = workflowStatus.steps?.[0]?.sessionFile ?? "";
|
|
1351
1919
|
assert.equal(workflowStatus.steps?.[0]?.agent, "echo");
|
|
1920
|
+
assert.deepEqual(workflowStatus.steps?.[0]?.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
|
|
1352
1921
|
assert.match(workflowStepSessionFile, /session\.jsonl$/);
|
|
1353
1922
|
const childDir = path.join(DIRS.async, childRunId);
|
|
1354
1923
|
const childStatusPath = path.join(childDir, "status.json");
|
|
1355
|
-
let childStatus: { state?: string; mode?: string; parentWorkflowRunId?: string; workflowKey?: string; parallelHandoff?: { path?: string; changedPatches?: number } } = {};
|
|
1924
|
+
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 } } = {};
|
|
1356
1925
|
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1357
1926
|
if (fs.existsSync(childStatusPath)) childStatus = JSON.parse(fs.readFileSync(childStatusPath, "utf-8"));
|
|
1358
1927
|
if (["complete", "failed", "stopped"].includes(childStatus.state ?? "")) break;
|
|
@@ -1361,10 +1930,17 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1361
1930
|
assert.equal(childStatus.mode, "single");
|
|
1362
1931
|
assert.equal(childStatus.parentWorkflowRunId, workflowRunId);
|
|
1363
1932
|
assert.equal(childStatus.workflowKey, "background");
|
|
1933
|
+
assert.deepEqual(childStatus.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
|
|
1934
|
+
assert.deepEqual(childStatus.steps?.[0]?.lane, childStatus.lane);
|
|
1935
|
+
assert.equal(typeof childStatus.steps?.[0]?.worktreePath, "string");
|
|
1936
|
+
assert.equal(typeof childStatus.steps?.[0]?.branch, "string");
|
|
1364
1937
|
assert.equal(typeof childStatus.parallelHandoff?.path, "string");
|
|
1365
1938
|
assert.equal(childStatus.parallelHandoff?.changedPatches, 1);
|
|
1366
1939
|
assert.equal(fs.existsSync(path.join(tempDir, "feature.txt")), false);
|
|
1367
|
-
const handoff = JSON.parse(fs.readFileSync(childStatus.parallelHandoff!.path!, "utf-8")) as { groups?: Array<{ children?: Array<{ patch?: { changed?: boolean; filesChanged?: number } }>; cleanup?: { state?: string; tasks?: Array<{ path?: string; preserved?: boolean; worktreeRemoved?: boolean; reason?: string }> } }> };
|
|
1940
|
+
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 }> } }> };
|
|
1941
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.workflowKey, "background");
|
|
1942
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.runId, childRunId);
|
|
1943
|
+
assert.equal(handoff.groups?.[0]?.children?.[0]?.lane?.key, "background");
|
|
1368
1944
|
assert.equal(handoff.groups?.[0]?.children?.[0]?.patch?.changed, true);
|
|
1369
1945
|
assert.equal(handoff.groups?.[0]?.children?.[0]?.patch?.filesChanged, 1);
|
|
1370
1946
|
assert.equal(handoff.groups?.[0]?.cleanup?.state, "partial");
|
|
@@ -1379,6 +1955,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1379
1955
|
const childResult = JSON.parse(fs.readFileSync(childResultPath, "utf-8")) as { parentWorkflowRunId?: string; workflowKey?: string };
|
|
1380
1956
|
assert.equal(childResult.parentWorkflowRunId, workflowRunId);
|
|
1381
1957
|
assert.equal(childResult.workflowKey, "background");
|
|
1958
|
+
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 } }> };
|
|
1959
|
+
assert.deepEqual(workflowReceipt.entries?.background?.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
|
|
1382
1960
|
assert.equal(fs.existsSync(workflowStepSessionFile), true);
|
|
1383
1961
|
const retainedCwd = handoff.groups?.[0]?.cleanup?.tasks?.[0]?.path;
|
|
1384
1962
|
assert.ok(retainedCwd);
|
|
@@ -1404,6 +1982,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1404
1982
|
);
|
|
1405
1983
|
assert.equal(result.isError, undefined);
|
|
1406
1984
|
assert.equal(controller.signal.aborted, true);
|
|
1985
|
+
assert.equal(controller.signal.reason instanceof Error ? controller.signal.reason.message : String(controller.signal.reason), "Workflow stopped.");
|
|
1407
1986
|
assert.match(result.content[0]?.text ?? "", /Stop requested for async workflow workflow-stop/);
|
|
1408
1987
|
});
|
|
1409
1988
|
|
|
@@ -1449,10 +2028,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1449
2028
|
status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
1450
2029
|
}
|
|
1451
2030
|
assert.equal(status.state, "stopped");
|
|
1452
|
-
assert.equal(status.error, "Workflow stopped
|
|
2031
|
+
assert.equal(status.error, "Workflow stopped.");
|
|
1453
2032
|
assert.equal(status.steps?.[0]?.status, "stopped");
|
|
1454
2033
|
assert.equal(status.steps?.[0]?.stopped, true);
|
|
1455
|
-
assert.equal(status.steps?.[0]?.error, "Workflow stopped
|
|
2034
|
+
assert.equal(status.steps?.[0]?.error, "Workflow stopped.");
|
|
1456
2035
|
assert.equal(status.workflow?.trace.some((entry) => entry.key === "review" && entry.state === "stopped"), true);
|
|
1457
2036
|
assert.equal(status.workflow?.trace.some((entry) => entry.key === "review" && entry.state === "failed"), false);
|
|
1458
2037
|
|
|
@@ -1474,7 +2053,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1474
2053
|
status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
1475
2054
|
assert.equal(status.steps?.[0]?.status, "stopped");
|
|
1476
2055
|
assert.equal(status.steps?.[0]?.stopped, true);
|
|
1477
|
-
assert.equal(status.steps?.[0]?.error, "Workflow stopped
|
|
2056
|
+
assert.equal(status.steps?.[0]?.error, "Workflow stopped.");
|
|
1478
2057
|
|
|
1479
2058
|
fs.rmSync(started.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1480
2059
|
fs.rmSync(path.join(DIRS.results, `${workflowRunId}.json`), { force: true });
|
|
@@ -1532,6 +2111,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1532
2111
|
assert.equal(status.stopped, undefined);
|
|
1533
2112
|
assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.status, "stopped");
|
|
1534
2113
|
assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.stopped, true);
|
|
2114
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.error, "Workflow child 'slow' stopped.");
|
|
1535
2115
|
assert.equal(status.steps?.find((step) => step.workflowKey === "fast")?.status, "completed");
|
|
1536
2116
|
assert.equal(status.steps?.find((step) => step.workflowKey === "fast")?.stopped, undefined);
|
|
1537
2117
|
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { state?: string; stopped?: boolean; results?: Array<{ workflowKey?: string; success?: boolean; stopped?: boolean }> };
|
|
@@ -1542,9 +2122,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1542
2122
|
const childStatusEvents = fs.readFileSync(path.join(started.details.asyncDir, "events.jsonl"), "utf-8")
|
|
1543
2123
|
.trim()
|
|
1544
2124
|
.split("\n")
|
|
1545
|
-
.map((line) => JSON.parse(line) as { type?: string; childId?: string; status?: string });
|
|
2125
|
+
.map((line) => JSON.parse(line) as { type?: string; childId?: string; status?: string; reason?: string });
|
|
1546
2126
|
assert.ok(childStatusEvents.some((event) => event.type === "subagent.child-status" && event.childId === "slow" && event.status === "stopping"));
|
|
1547
|
-
|
|
2127
|
+
const stoppedChildStatus = childStatusEvents.findLast((event) => event.type === "subagent.child-status" && event.childId === "slow" && event.status === "stopped");
|
|
2128
|
+
assert.equal(stoppedChildStatus?.reason, "subagent-action");
|
|
1548
2129
|
fs.rmSync(started.details.asyncDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1549
2130
|
fs.rmSync(resultPath, { force: true });
|
|
1550
2131
|
});
|
|
@@ -1922,6 +2503,77 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1922
2503
|
assert.equal(fs.readFileSync(path.join(tempDir, "workflow-report.monitor.md"), "utf-8"), "second report");
|
|
1923
2504
|
});
|
|
1924
2505
|
|
|
2506
|
+
it("maps a task-requested report path to the workflow-saved child output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2507
|
+
mockPi.onCall({ output: "review report" });
|
|
2508
|
+
const requestedReport = path.join(tempDir, "requested-review.md");
|
|
2509
|
+
const workflowOutput = path.join(tempDir, "workflow-report.md");
|
|
2510
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
2511
|
+
"scripted-workflow-requested-output-mapping",
|
|
2512
|
+
{
|
|
2513
|
+
async: false,
|
|
2514
|
+
output: workflowOutput,
|
|
2515
|
+
workflowScript: `return await runs.run("review", { agent: "echo", task: ${JSON.stringify(`Review the change.\n\nWrite your findings to exactly this path: ${requestedReport}`)} });`,
|
|
2516
|
+
},
|
|
2517
|
+
new AbortController().signal,
|
|
2518
|
+
undefined,
|
|
2519
|
+
makeMinimalCtx(tempDir),
|
|
2520
|
+
);
|
|
2521
|
+
|
|
2522
|
+
const savedReport = path.join(tempDir, "workflow-report.review.md");
|
|
2523
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
2524
|
+
assert.equal(fs.existsSync(requestedReport), false);
|
|
2525
|
+
assert.equal(fs.readFileSync(savedReport, "utf-8"), "review report");
|
|
2526
|
+
assert.deepEqual((result.details.workflow?.value as { outputPathMapping?: unknown }).outputPathMapping, {
|
|
2527
|
+
requestedPath: requestedReport,
|
|
2528
|
+
savedPath: savedReport,
|
|
2529
|
+
});
|
|
2530
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(`Output path mappings: 'review': requested ${escapeRegExp(requestedReport)} -> saved ${escapeRegExp(savedReport)}`));
|
|
2531
|
+
assert.match(fs.readFileSync(workflowOutput, "utf-8"), /Output path mappings:/);
|
|
2532
|
+
});
|
|
2533
|
+
|
|
2534
|
+
it("preserves output path mappings when an async workflow fails after a completed child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2535
|
+
mockPi.onCall({ output: "review report", matchArgIncludes: "Review first" });
|
|
2536
|
+
mockPi.onCall({ exitCode: 1, stderr: "later child failure", matchArgIncludes: "Fail later" });
|
|
2537
|
+
const requestedReport = path.join(tempDir, "requested-review.md");
|
|
2538
|
+
const workflowOutput = path.join(tempDir, "failed-workflow.md");
|
|
2539
|
+
const started = await makeExecutor([makeAgent("echo")]).execute(
|
|
2540
|
+
"async-workflow-failed-output-mapping",
|
|
2541
|
+
{
|
|
2542
|
+
async: true,
|
|
2543
|
+
output: workflowOutput,
|
|
2544
|
+
workflowScript: `
|
|
2545
|
+
await runs.run("review", { agent: "echo", task: ${JSON.stringify(`Review first.\n\nWrite your findings to exactly this path: ${requestedReport}`)} });
|
|
2546
|
+
await runs.run("fails", { agent: "echo", task: "Fail later" });
|
|
2547
|
+
throw new Error("later workflow failure");
|
|
2548
|
+
`,
|
|
2549
|
+
},
|
|
2550
|
+
new AbortController().signal,
|
|
2551
|
+
undefined,
|
|
2552
|
+
makeMinimalCtx(tempDir),
|
|
2553
|
+
);
|
|
2554
|
+
|
|
2555
|
+
assert.equal(started.isError, undefined);
|
|
2556
|
+
assert.ok(started.details.asyncId);
|
|
2557
|
+
assert.ok(started.details.asyncDir);
|
|
2558
|
+
const resultPath = path.join(DIRS.results, `${started.details.asyncId}.json`);
|
|
2559
|
+
for (let attempt = 0; attempt < 150 && !fs.existsSync(resultPath); attempt++) {
|
|
2560
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2561
|
+
}
|
|
2562
|
+
const persisted = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { state?: string; summary?: string; results?: Array<{ workflowKey?: string; outputReference?: string; output?: string }> };
|
|
2563
|
+
const savedReport = path.join(tempDir, "failed-workflow.review.md");
|
|
2564
|
+
const expectedMapping = `Output path mappings: 'review': requested ${requestedReport} -> saved ${savedReport}`;
|
|
2565
|
+
assert.equal(persisted.state, "failed");
|
|
2566
|
+
assert.equal(persisted.results?.[0]?.workflowKey, "review");
|
|
2567
|
+
assert.equal(persisted.results?.[0]?.outputReference, savedReport);
|
|
2568
|
+
assert.equal(persisted.results?.[0]?.output, "review report");
|
|
2569
|
+
assert.match(persisted.summary ?? "", new RegExp(escapeRegExp(expectedMapping)));
|
|
2570
|
+
assert.match(fs.readFileSync(workflowOutput, "utf-8"), new RegExp(escapeRegExp(expectedMapping)));
|
|
2571
|
+
assert.equal(fs.readFileSync(savedReport, "utf-8"), "review report");
|
|
2572
|
+
|
|
2573
|
+
fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
|
|
2574
|
+
fs.rmSync(resultPath, { force: true });
|
|
2575
|
+
});
|
|
2576
|
+
|
|
1925
2577
|
it("uses child-cwd agent output defaults for omitted workflow child output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1926
2578
|
mockPi.onCall({ output: "app report" });
|
|
1927
2579
|
const appDir = path.join(tempDir, "packages", "app");
|
|
@@ -2238,6 +2890,26 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2238
2890
|
assert.deepEqual(fs.readFileSync(sharedOutput), Buffer.from(usefulReport));
|
|
2239
2891
|
});
|
|
2240
2892
|
|
|
2893
|
+
it("identifies validation failures before any workflow child launches", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2894
|
+
const executor = makeExecutor([makeAgent("worker")]);
|
|
2895
|
+
const workflowId = "scripted-workflow-invalid-nested-async";
|
|
2896
|
+
const result = await executor.execute(
|
|
2897
|
+
workflowId,
|
|
2898
|
+
{
|
|
2899
|
+
async: false,
|
|
2900
|
+
workflowScript: `const lane = async () => runs.run("writer", { agent: "worker", task: "write" }); return lane();`,
|
|
2901
|
+
},
|
|
2902
|
+
new AbortController().signal,
|
|
2903
|
+
undefined,
|
|
2904
|
+
makeMinimalCtx(tempDir),
|
|
2905
|
+
);
|
|
2906
|
+
|
|
2907
|
+
assert.equal(result.isError, true);
|
|
2908
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(`Workflow '${workflowId}' validation failed before child launch; no children launched`));
|
|
2909
|
+
assert.match(result.content[0]?.text ?? "", /Parallel plus sequential rewrite/);
|
|
2910
|
+
assert.deepEqual(result.details.results, []);
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2241
2913
|
it("replaces stale workflow output when a child claims its path but writes no report", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2242
2914
|
const sharedOutput = path.join(tempDir, "failed-review.md");
|
|
2243
2915
|
fs.writeFileSync(sharedOutput, "stale workflow output", "utf-8");
|
|
@@ -2590,8 +3262,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2590
3262
|
async: false,
|
|
2591
3263
|
workflowScript: `
|
|
2592
3264
|
const children = await runs.all([
|
|
2593
|
-
{ key: "feature-a", agent: "worker", task: "Implement A", worktree: true },
|
|
2594
|
-
{ key: "feature-b", agent: "worker", task: "Implement B", worktree: true }
|
|
3265
|
+
{ 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"] } },
|
|
3266
|
+
{ 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"] } }
|
|
2595
3267
|
]);
|
|
2596
3268
|
return children.map(({ key, artifactPaths }) => ({ key, artifactPaths }));
|
|
2597
3269
|
`,
|
|
@@ -2601,8 +3273,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2601
3273
|
makeMinimalCtx(tempDir),
|
|
2602
3274
|
);
|
|
2603
3275
|
|
|
2604
|
-
|
|
2605
|
-
|
|
3276
|
+
assert.equal(result.isError, undefined);
|
|
3277
|
+
assert.equal(mockPi.callCount(), 2, result.content[0]?.text ?? "workflow produced no output");
|
|
3278
|
+
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"] });
|
|
3279
|
+
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"] });
|
|
2606
3280
|
assert.equal(fs.existsSync(path.join(tempDir, "feature-a.txt")), false);
|
|
2607
3281
|
assert.equal(fs.existsSync(path.join(tempDir, "feature-b.txt")), false);
|
|
2608
3282
|
const output = result.content[0]?.text ?? "";
|
|
@@ -2612,12 +3286,14 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2612
3286
|
for (const handoffPath of handoffPaths) {
|
|
2613
3287
|
const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
2614
3288
|
groups: Array<{
|
|
2615
|
-
|
|
3289
|
+
children: Array<{ workflowKey: string; runId: string; lane: { key: string; mode: string; sourceRef: string; claims: string[] }; patch: { changed: boolean; path: string } }>;
|
|
2616
3290
|
cleanup: { state: string; tasks: Array<{ path: string; worktreeRemoved: boolean; branchRemoved: boolean }> };
|
|
2617
3291
|
}>;
|
|
2618
3292
|
};
|
|
2619
3293
|
assert.equal(handoff.groups.length, 1);
|
|
2620
3294
|
assert.equal(handoff.groups[0]?.children.length, 1);
|
|
3295
|
+
assert.equal(handoff.groups[0]?.children[0]?.workflowKey, handoff.groups[0]?.children[0]?.lane.key);
|
|
3296
|
+
assert.equal(handoff.groups[0]?.children[0]?.runId?.length > 0, true);
|
|
2621
3297
|
assert.equal(handoff.groups[0]?.children[0]?.patch.changed, true);
|
|
2622
3298
|
assert.equal(fs.existsSync(handoff.groups[0]!.children[0]!.patch.path), true);
|
|
2623
3299
|
assert.equal(handoff.groups[0]?.cleanup.state, "complete");
|
|
@@ -2630,7 +3306,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2630
3306
|
assert.match(result.content[0]?.text ?? "", /handoffs/);
|
|
2631
3307
|
});
|
|
2632
3308
|
|
|
2633
|
-
it("
|
|
3309
|
+
it("finalizes a workflow worktree when its child detaches for supervisor coordination", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
|
|
2634
3310
|
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
2635
3311
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
2636
3312
|
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
@@ -2638,6 +3314,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2638
3314
|
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
2639
3315
|
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
2640
3316
|
mockPi.onCall({
|
|
3317
|
+
writeFiles: [{ path: "feature.txt", content: "feature\n" }],
|
|
2641
3318
|
steps: [
|
|
2642
3319
|
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
2643
3320
|
{ delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
|
|
@@ -2690,8 +3367,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2690
3367
|
assert.equal(workflowValue[0]?.ok, false);
|
|
2691
3368
|
const handoffPath = workflowValue[0]?.artifactPaths.find((candidate) => candidate.endsWith(".json"));
|
|
2692
3369
|
assert.ok(handoffPath, result.content[0]?.text ?? "missing pending handoff");
|
|
2693
|
-
|
|
3370
|
+
let handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
2694
3371
|
groups: Array<{
|
|
3372
|
+
children: Array<{ status: string; patch: { changed: boolean; filesChanged: number } }>;
|
|
2695
3373
|
cleanup: { state: string; tasks: Array<{ path: string; branch: string; preserved: boolean; worktreeRemoved: boolean; branchRemoved: boolean }> };
|
|
2696
3374
|
}>;
|
|
2697
3375
|
};
|
|
@@ -2706,12 +3384,22 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2706
3384
|
assert.ok(branch);
|
|
2707
3385
|
assert.equal(fs.existsSync(worktreePath), true, "live detached worktree must remain present");
|
|
2708
3386
|
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
3387
|
+
for (let attempt = 0; attempt < 150 && handoff.groups[0]?.cleanup.state !== "complete"; attempt++) {
|
|
3388
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3389
|
+
handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as typeof handoff;
|
|
3390
|
+
}
|
|
3391
|
+
assert.equal(handoff.groups[0]?.children[0]?.status, "completed");
|
|
3392
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.changed, true);
|
|
3393
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.filesChanged, 1);
|
|
3394
|
+
assert.equal(handoff.groups[0]?.cleanup.state, "complete");
|
|
3395
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.preserved, undefined);
|
|
3396
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
|
|
3397
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.branchRemoved, true);
|
|
3398
|
+
assert.equal(fs.existsSync(worktreePath), false);
|
|
3399
|
+
assert.equal(fs.existsSync(path.join(tempDir, "feature.txt")), false);
|
|
2712
3400
|
});
|
|
2713
3401
|
|
|
2714
|
-
it("
|
|
3402
|
+
it("continues an async sequential workflow after supervisor coordination settles", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
|
|
2715
3403
|
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
2716
3404
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
2717
3405
|
execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
|
|
@@ -2719,11 +3407,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2719
3407
|
execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
|
|
2720
3408
|
execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
|
|
2721
3409
|
mockPi.onCall({
|
|
3410
|
+
matchArgIncludes: "Ask then continue",
|
|
2722
3411
|
steps: [
|
|
2723
3412
|
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
2724
3413
|
{ delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
|
|
2725
3414
|
],
|
|
2726
3415
|
});
|
|
3416
|
+
mockPi.onCall({ matchArgIncludes: "Use coordinated output: done after coordination", output: "tail completed" });
|
|
2727
3417
|
const piEvents = createEventBus();
|
|
2728
3418
|
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
2729
3419
|
const executor = makeExecutor(
|
|
@@ -2753,7 +3443,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2753
3443
|
{
|
|
2754
3444
|
workflowScript: `
|
|
2755
3445
|
const child = await runs.run("detaches", { agent: "worker", task: "Ask then continue", worktree: true });
|
|
2756
|
-
|
|
3446
|
+
const tail = await runs.run("tail", { agent: "worker", task: "Use coordinated output: " + child.output });
|
|
3447
|
+
return tail.output;
|
|
2757
3448
|
`,
|
|
2758
3449
|
},
|
|
2759
3450
|
new AbortController().signal,
|
|
@@ -2769,56 +3460,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2769
3460
|
const activeMarkerPath = path.join(DIRS.async, ACTIVE_RUN_INDEX_DIR, workflowRunId);
|
|
2770
3461
|
|
|
2771
3462
|
let status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2772
|
-
for (let attempt = 0; attempt < 150 && status.
|
|
3463
|
+
for (let attempt = 0; attempt < 150 && status.steps?.[0]?.activityState !== "needs_attention"; attempt++) {
|
|
2773
3464
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2774
3465
|
status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2775
3466
|
}
|
|
2776
3467
|
clearInterval(detachTimer);
|
|
2777
3468
|
|
|
2778
3469
|
assert.equal(detachAccepted, true);
|
|
2779
|
-
assert.equal(status.state, "paused", status.error);
|
|
2780
|
-
assert.equal(status.activityState, "needs_attention");
|
|
2781
|
-
assert.match(status.error ?? "", /detached for intercom coordination/i);
|
|
2782
|
-
assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "detached"), true);
|
|
2783
|
-
assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "failed"), false);
|
|
2784
|
-
assert.equal(status.steps?.[0]?.status, "paused");
|
|
2785
|
-
assert.equal(status.steps?.[0]?.activityState, "needs_attention");
|
|
2786
|
-
assert.equal(asyncJobs.get(workflowRunId)?.status, "paused");
|
|
2787
|
-
assert.equal(asyncJobs.get(workflowRunId)?.activityState, "needs_attention");
|
|
2788
|
-
assert.equal(fs.existsSync(activeMarkerPath), false);
|
|
2789
|
-
|
|
2790
|
-
let persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as {
|
|
2791
|
-
state?: string;
|
|
2792
|
-
activityState?: string;
|
|
2793
|
-
error?: string;
|
|
2794
|
-
summary?: string;
|
|
2795
|
-
workflow?: { trace?: Array<{ key?: string; state?: string }> };
|
|
2796
|
-
results?: Array<{ detached?: boolean; artifactPaths?: { outputPath?: string } }>;
|
|
2797
|
-
};
|
|
2798
|
-
assert.equal(persistedResult.state, "paused");
|
|
2799
|
-
assert.equal(persistedResult.activityState, "needs_attention");
|
|
2800
|
-
assert.match(persistedResult.error ?? "", /Reply to the supervisor request first/);
|
|
2801
|
-
assert.match(persistedResult.summary ?? "", /subagent_wait/);
|
|
2802
|
-
assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "detached"), true);
|
|
2803
|
-
assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "failed"), false);
|
|
2804
|
-
assert.equal(persistedResult.results?.[0]?.detached, true);
|
|
2805
|
-
const handoffPath = persistedResult.results?.[0]?.artifactPaths?.outputPath;
|
|
2806
|
-
assert.ok(handoffPath, "missing preserved worktree handoff path");
|
|
2807
|
-
const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
2808
|
-
groups: Array<{
|
|
2809
|
-
cleanup: { state: string; tasks: Array<{ path: string; branch: string; preserved: boolean; worktreeRemoved: boolean; branchRemoved: boolean }> };
|
|
2810
|
-
}>;
|
|
2811
|
-
};
|
|
2812
|
-
const cleanup = handoff.groups[0]?.cleanup;
|
|
2813
|
-
assert.equal(cleanup?.state, "partial");
|
|
2814
|
-
assert.equal(cleanup?.tasks[0]?.preserved, true);
|
|
2815
|
-
assert.equal(cleanup?.tasks[0]?.worktreeRemoved, false);
|
|
2816
|
-
assert.equal(cleanup?.tasks[0]?.branchRemoved, false);
|
|
2817
|
-
const worktreePath = cleanup?.tasks[0]?.path;
|
|
2818
|
-
const branch = cleanup?.tasks[0]?.branch;
|
|
2819
|
-
assert.ok(worktreePath);
|
|
2820
|
-
assert.ok(branch);
|
|
2821
|
-
assert.equal(fs.existsSync(worktreePath), true, "live detached worktree must remain present");
|
|
2822
3470
|
|
|
2823
3471
|
let reconciled: AsyncStatus | undefined;
|
|
2824
3472
|
for (let attempt = 0; attempt < 150; attempt++) {
|
|
@@ -2826,24 +3474,49 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2826
3474
|
if (reconciled.state === "complete" || reconciled.state === "failed") break;
|
|
2827
3475
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2828
3476
|
}
|
|
2829
|
-
assert.equal(reconciled?.state, "
|
|
2830
|
-
assert.match(reconciled?.error ?? "", /unsupported-continuation/);
|
|
3477
|
+
assert.equal(reconciled?.state, "complete", reconciled?.error);
|
|
2831
3478
|
assert.equal(reconciled?.activityState, undefined);
|
|
2832
3479
|
assert.equal(reconciled?.steps?.[0]?.status, "completed");
|
|
2833
3480
|
assert.equal(reconciled?.steps?.[0]?.activityState, undefined);
|
|
2834
|
-
assert.equal(
|
|
3481
|
+
assert.equal(reconciled?.steps?.[1]?.workflowKey, "tail");
|
|
3482
|
+
assert.equal(reconciled?.steps?.[1]?.status, "completed");
|
|
3483
|
+
assert.equal(asyncJobs.get(workflowRunId)?.status, "complete");
|
|
3484
|
+
assert.equal(mockPi.callCount(), 2);
|
|
2835
3485
|
|
|
2836
|
-
persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8"))
|
|
2837
|
-
|
|
3486
|
+
const persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as {
|
|
3487
|
+
state?: string;
|
|
3488
|
+
success?: boolean;
|
|
3489
|
+
activityState?: string;
|
|
3490
|
+
error?: string;
|
|
3491
|
+
results?: Array<{ workflowKey?: string; success?: boolean; output?: string; detached?: boolean; artifactPaths?: { outputPath?: string } }>;
|
|
3492
|
+
};
|
|
3493
|
+
assert.equal(persistedResult.state, "complete");
|
|
3494
|
+
assert.equal(persistedResult.success, true);
|
|
2838
3495
|
assert.equal(persistedResult.activityState, undefined);
|
|
2839
|
-
assert.
|
|
2840
|
-
|
|
2841
|
-
|
|
3496
|
+
assert.equal(persistedResult.error, undefined);
|
|
3497
|
+
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.detached, undefined);
|
|
3498
|
+
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "tail")?.output, "tail completed");
|
|
3499
|
+
const handoffPath = persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.artifactPaths?.outputPath;
|
|
3500
|
+
assert.ok(handoffPath, "missing finalized worktree handoff path");
|
|
3501
|
+
const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
|
|
3502
|
+
groups: Array<{
|
|
3503
|
+
children: Array<{ status: string; patch: { changed: boolean; filesChanged: number } }>;
|
|
3504
|
+
cleanup: { state: string; tasks: Array<{ path: string; worktreeRemoved: boolean; branchRemoved: boolean }> };
|
|
3505
|
+
}>;
|
|
3506
|
+
};
|
|
3507
|
+
assert.equal(handoff.groups[0]?.children[0]?.status, "completed");
|
|
3508
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.changed, false);
|
|
3509
|
+
assert.equal(handoff.groups[0]?.children[0]?.patch.filesChanged, 0);
|
|
3510
|
+
assert.equal(handoff.groups[0]?.cleanup.state, "complete");
|
|
3511
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
|
|
3512
|
+
assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.branchRemoved, true);
|
|
3513
|
+
assert.equal(fs.existsSync(handoff.groups[0]?.cleanup.tasks[0]?.path ?? ""), false);
|
|
3514
|
+
assert.equal(fs.existsSync(activeMarkerPath), false);
|
|
2842
3515
|
fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
|
|
2843
3516
|
fs.rmSync(resultPath, { force: true });
|
|
2844
3517
|
});
|
|
2845
3518
|
|
|
2846
|
-
it("keeps async workflows failed when a
|
|
3519
|
+
it("keeps async workflows failed when a coordinated child is mixed with a real failure", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
|
|
2847
3520
|
mockPi.onCall({
|
|
2848
3521
|
matchArgIncludes: "Ask then continue",
|
|
2849
3522
|
steps: [
|
|
@@ -2909,10 +3582,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2909
3582
|
assert.equal(status.state, "failed");
|
|
2910
3583
|
assert.equal(status.activityState, undefined);
|
|
2911
3584
|
assert.match(status.error ?? "", /manual hard failure/);
|
|
2912
|
-
assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "
|
|
3585
|
+
assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "completed"), true);
|
|
2913
3586
|
assert.equal(status.workflow?.trace.some((entry) => entry.key === "fails" && entry.state === "failed"), true);
|
|
2914
|
-
assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.status, "
|
|
2915
|
-
assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.activityState,
|
|
3587
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.status, "completed");
|
|
3588
|
+
assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.activityState, undefined);
|
|
2916
3589
|
assert.equal(status.steps?.find((step) => step.workflowKey === "fails")?.status, "failed");
|
|
2917
3590
|
assert.equal(asyncJobs.get(workflowRunId)?.status, "failed");
|
|
2918
3591
|
assert.equal(asyncJobs.get(workflowRunId)?.activityState, undefined);
|
|
@@ -2927,9 +3600,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2927
3600
|
assert.equal(persistedResult.state, "failed");
|
|
2928
3601
|
assert.equal(persistedResult.activityState, undefined);
|
|
2929
3602
|
assert.match(persistedResult.error ?? "", /manual hard failure/);
|
|
2930
|
-
assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "
|
|
3603
|
+
assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "completed"), true);
|
|
2931
3604
|
assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "fails" && entry.state === "failed"), true);
|
|
2932
|
-
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.detached,
|
|
3605
|
+
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.detached, undefined);
|
|
3606
|
+
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.success, true);
|
|
2933
3607
|
assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "fails")?.success, false);
|
|
2934
3608
|
|
|
2935
3609
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
@@ -2939,85 +3613,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2939
3613
|
fs.rmSync(resultPath, { force: true });
|
|
2940
3614
|
});
|
|
2941
3615
|
|
|
2942
|
-
it("fails closed after a detached child finishes while a sibling is aborted", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
|
|
2943
|
-
mockPi.onCall({
|
|
2944
|
-
matchArgIncludes: "Ask then continue",
|
|
2945
|
-
steps: [
|
|
2946
|
-
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
2947
|
-
{ delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
|
|
2948
|
-
],
|
|
2949
|
-
});
|
|
2950
|
-
mockPi.onCall({ matchArgIncludes: "Stay running", delay: 10_000 });
|
|
2951
|
-
const piEvents = createEventBus();
|
|
2952
|
-
const asyncJobs: SubagentState["asyncJobs"] = new Map();
|
|
2953
|
-
const executor = makeExecutor(
|
|
2954
|
-
[makeAgent("worker", { systemPrompt: "Intercom orchestration channel:" })],
|
|
2955
|
-
{},
|
|
2956
|
-
false,
|
|
2957
|
-
undefined,
|
|
2958
|
-
true,
|
|
2959
|
-
asyncJobs,
|
|
2960
|
-
undefined,
|
|
2961
|
-
undefined,
|
|
2962
|
-
piEvents,
|
|
2963
|
-
);
|
|
2964
|
-
let detachAccepted = false;
|
|
2965
|
-
piEvents.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => {
|
|
2966
|
-
if ((payload as { requestId?: unknown }).requestId === "async-workflow-detach-with-sibling") {
|
|
2967
|
-
detachAccepted ||= (payload as { accepted?: unknown }).accepted === true;
|
|
2968
|
-
}
|
|
2969
|
-
});
|
|
2970
|
-
const detachTimer = setInterval(() => {
|
|
2971
|
-
if (!detachAccepted) piEvents.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "async-workflow-detach-with-sibling" });
|
|
2972
|
-
}, 10);
|
|
2973
|
-
detachTimer.unref();
|
|
2974
|
-
|
|
2975
|
-
const started = await executor.execute(
|
|
2976
|
-
"async-scripted-workflow-detached-with-sibling",
|
|
2977
|
-
{
|
|
2978
|
-
workflowScript: `
|
|
2979
|
-
await Promise.all([
|
|
2980
|
-
runs.run("detaches", { agent: "worker", task: "Ask then continue" }),
|
|
2981
|
-
runs.run("slow", { agent: "worker", task: "Stay running" })
|
|
2982
|
-
]);
|
|
2983
|
-
return "should not reach";
|
|
2984
|
-
`,
|
|
2985
|
-
},
|
|
2986
|
-
new AbortController().signal,
|
|
2987
|
-
undefined,
|
|
2988
|
-
makeMinimalCtx(tempDir),
|
|
2989
|
-
);
|
|
2990
|
-
assert.equal(started.isError, undefined);
|
|
2991
|
-
assert.ok(started.details.asyncDir);
|
|
2992
|
-
const workflowRunId = started.details.asyncId;
|
|
2993
|
-
const statusPath = path.join(started.details.asyncDir, "status.json");
|
|
2994
|
-
|
|
2995
|
-
let status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2996
|
-
for (let attempt = 0; attempt < 150 && status.state !== "paused" && status.state !== "failed" && status.state !== "complete"; attempt++) {
|
|
2997
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2998
|
-
status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
2999
|
-
}
|
|
3000
|
-
clearInterval(detachTimer);
|
|
3001
|
-
assert.equal(detachAccepted, true);
|
|
3002
|
-
assert.equal(status.state, "paused", status.error);
|
|
3003
|
-
assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.status, "paused");
|
|
3004
|
-
assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.status, "stopped");
|
|
3005
|
-
|
|
3006
|
-
let reconciled: AsyncStatus | undefined;
|
|
3007
|
-
for (let attempt = 0; attempt < 150; attempt++) {
|
|
3008
|
-
reconciled = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
3009
|
-
if (reconciled.state === "complete" || reconciled.state === "failed") break;
|
|
3010
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3011
|
-
}
|
|
3012
|
-
assert.equal(reconciled?.state, "failed", reconciled?.error);
|
|
3013
|
-
assert.match(reconciled?.error ?? "", /unsupported-continuation/);
|
|
3014
|
-
assert.equal(reconciled?.steps?.find((step) => step.workflowKey === "detaches")?.status, "completed");
|
|
3015
|
-
assert.equal(reconciled?.steps?.find((step) => step.workflowKey === "slow")?.status, "stopped");
|
|
3016
|
-
assert.equal(asyncJobs.get(workflowRunId)?.status, "failed");
|
|
3017
|
-
fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
|
|
3018
|
-
fs.rmSync(path.join(DIRS.results, `${workflowRunId}.json`), { force: true });
|
|
3019
|
-
});
|
|
3020
|
-
|
|
3021
3616
|
it("inherits workflow-level worktree isolation and allows a child opt-out", { skip: !createSubagentExecutor || process.platform === "win32" ? "executor unavailable or worktree paths differ on Windows" : undefined }, async () => {
|
|
3022
3617
|
execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
|
|
3023
3618
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
|
|
@@ -3151,6 +3746,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3151
3746
|
assert.equal(result.details.mode, "workflow");
|
|
3152
3747
|
assert.equal(mockPi.callCount(), 1);
|
|
3153
3748
|
assert.equal(result.details.usageBudget?.exhausted, true);
|
|
3749
|
+
assert.deepEqual(result.details.workflow?.receipt?.terminalOutcome, { state: "partial", reason: "budget_exhausted" });
|
|
3750
|
+
assert.equal(result.details.workflow?.receipt?.entries.first?.terminalOutcome, undefined);
|
|
3751
|
+
assert.deepEqual(result.details.workflow?.receipt?.entries.second?.terminalOutcome, { state: "partial", reason: "budget_exhausted" });
|
|
3154
3752
|
});
|
|
3155
3753
|
|
|
3156
3754
|
it("admits a zero run-level tool budget only for marked structured delegated execution", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
@@ -3286,6 +3884,75 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3286
3884
|
assert.match(result.content[0]?.text ?? "", /Valid:/);
|
|
3287
3885
|
});
|
|
3288
3886
|
|
|
3887
|
+
it("records and renders stored lane merge evidence through management actions", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3888
|
+
const manifestPath = path.join(tempDir, "lane-handoff.json");
|
|
3889
|
+
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
3890
|
+
version: 1,
|
|
3891
|
+
runId: "lane-action",
|
|
3892
|
+
mode: "single",
|
|
3893
|
+
source: "async",
|
|
3894
|
+
cwd: tempDir,
|
|
3895
|
+
createdAt: 1,
|
|
3896
|
+
updatedAt: 1,
|
|
3897
|
+
groups: [{
|
|
3898
|
+
stepIndex: 0,
|
|
3899
|
+
baseCommit: "base-commit",
|
|
3900
|
+
repoRoot: tempDir,
|
|
3901
|
+
children: [{
|
|
3902
|
+
index: 0,
|
|
3903
|
+
taskIndex: 0,
|
|
3904
|
+
agent: "worker",
|
|
3905
|
+
status: "completed",
|
|
3906
|
+
summary: "done",
|
|
3907
|
+
patch: { path: path.join(tempDir, "worker.patch"), branch: "lane-action-branch", changed: false, diffStat: "", filesChanged: 0, insertions: 0, deletions: 0 },
|
|
3908
|
+
}],
|
|
3909
|
+
cleanup: { state: "partial", pruned: false, tasks: [{ index: 0, path: path.join(tempDir, "worktree"), branch: "lane-action-branch", worktreeRemoved: false, branchRemoved: false, preserved: true }] },
|
|
3910
|
+
}],
|
|
3911
|
+
}, null, 2), "utf-8");
|
|
3912
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
3913
|
+
const merge = {
|
|
3914
|
+
prNumber: 1623,
|
|
3915
|
+
reviewedHead: "8888888888888888888888888888888888888888",
|
|
3916
|
+
mergeCommit: "9999999999999999999999999999999999999999",
|
|
3917
|
+
treeEquivalent: true,
|
|
3918
|
+
postMergeChecks: "recorded",
|
|
3919
|
+
attestedBy: "nicobailon",
|
|
3920
|
+
attestedAt: "2026-08-27T16:23:00.000Z",
|
|
3921
|
+
};
|
|
3922
|
+
|
|
3923
|
+
const recorded = await executor.execute(
|
|
3924
|
+
"lane-record-action",
|
|
3925
|
+
{ action: "lane.recordMerge", laneId: "lane-action", handoffPath: manifestPath, merge },
|
|
3926
|
+
new AbortController().signal,
|
|
3927
|
+
undefined,
|
|
3928
|
+
makeMinimalCtx(tempDir),
|
|
3929
|
+
);
|
|
3930
|
+
assert.equal(recorded.isError, undefined, recorded.content[0]?.text ?? "");
|
|
3931
|
+
assert.match(recorded.content[0]?.text ?? "", /Cleanup eligibility: terminal-eligible/);
|
|
3932
|
+
assert.equal(recorded.details.parallelHandoff?.cleanupEligibility?.state, "terminal-eligible");
|
|
3933
|
+
|
|
3934
|
+
const rendered = await executor.execute(
|
|
3935
|
+
"lane-status-action",
|
|
3936
|
+
{ action: "lane.status", laneId: "lane-action", handoffPath: manifestPath },
|
|
3937
|
+
new AbortController().signal,
|
|
3938
|
+
undefined,
|
|
3939
|
+
makeMinimalCtx(tempDir),
|
|
3940
|
+
);
|
|
3941
|
+
assert.equal(rendered.isError, undefined, rendered.content[0]?.text ?? "");
|
|
3942
|
+
assert.match(rendered.content[0]?.text ?? "", /Cleanup eligibility: terminal-eligible/);
|
|
3943
|
+
assert.match(rendered.content[0]?.text ?? "", /action: "worktree\.cleanup"/);
|
|
3944
|
+
|
|
3945
|
+
const invalid = await executor.execute(
|
|
3946
|
+
"lane-invalid-action",
|
|
3947
|
+
{ action: "lane.recordMerge", laneId: "lane-action", handoffPath: manifestPath },
|
|
3948
|
+
new AbortController().signal,
|
|
3949
|
+
undefined,
|
|
3950
|
+
makeMinimalCtx(tempDir),
|
|
3951
|
+
);
|
|
3952
|
+
assert.equal(invalid.isError, true);
|
|
3953
|
+
assert.match(invalid.content[0]?.text ?? "", /merge must be an object/);
|
|
3954
|
+
});
|
|
3955
|
+
|
|
3289
3956
|
it("routes watchdog.configure through the management action path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
3290
3957
|
const gpt = { provider: "openai-codex", id: "gpt-5.5", reasoning: true };
|
|
3291
3958
|
const opus = { provider: "anthropic", id: "claude-opus-4-8", reasoning: true };
|
|
@@ -3803,6 +4470,70 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3803
4470
|
assert.equal(mockPi.callCount(), 1);
|
|
3804
4471
|
});
|
|
3805
4472
|
|
|
4473
|
+
it("creates a plan-only worktree cleanup action without removing managed state", { skip: !createSubagentExecutor ? "executor not importable" : process.platform === "win32" ? "worktree paths differ on Windows" : undefined }, async () => {
|
|
4474
|
+
const repo = path.join(tempDir, "cleanup-repo");
|
|
4475
|
+
const baseDir = path.join(tempDir, "cleanup-worktrees");
|
|
4476
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
4477
|
+
execFileSync("git", ["init"], { cwd: repo, stdio: "ignore" });
|
|
4478
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo });
|
|
4479
|
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo });
|
|
4480
|
+
fs.writeFileSync(path.join(repo, "base.txt"), "base\n", "utf-8");
|
|
4481
|
+
execFileSync("git", ["add", "base.txt"], { cwd: repo });
|
|
4482
|
+
execFileSync("git", ["commit", "-m", "base"], { cwd: repo, stdio: "ignore" });
|
|
4483
|
+
const setup = createWorktrees(repo, "action", 1, { baseDir });
|
|
4484
|
+
const worktree = setup.worktrees[0]!;
|
|
4485
|
+
const manifestPath = path.join(repo, ".pi-subagents", "artifacts", "handoff.json");
|
|
4486
|
+
const baseCommit = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf-8" }).trim();
|
|
4487
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
4488
|
+
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
4489
|
+
version: 1,
|
|
4490
|
+
runId: "cleanup-action-run",
|
|
4491
|
+
mode: "parallel",
|
|
4492
|
+
source: "async",
|
|
4493
|
+
cwd: repo,
|
|
4494
|
+
createdAt: 1,
|
|
4495
|
+
updatedAt: 1,
|
|
4496
|
+
groups: [{
|
|
4497
|
+
stepIndex: 0,
|
|
4498
|
+
baseCommit,
|
|
4499
|
+
repoRoot: repo,
|
|
4500
|
+
children: [{
|
|
4501
|
+
index: 0,
|
|
4502
|
+
taskIndex: 0,
|
|
4503
|
+
agent: "worker",
|
|
4504
|
+
status: "completed",
|
|
4505
|
+
summary: "done",
|
|
4506
|
+
patch: { path: path.join(repo, ".pi-subagents", "artifacts", "worktree.patch"), branch: worktree.branch, changed: false, diffStat: "", filesChanged: 0, insertions: 0, deletions: 0 },
|
|
4507
|
+
}],
|
|
4508
|
+
cleanup: { state: "partial", pruned: false, tasks: [{ index: 0, path: worktree.path, branch: worktree.branch, worktreeRemoved: false, branchRemoved: false, preserved: true }] },
|
|
4509
|
+
}],
|
|
4510
|
+
}, null, 2), "utf-8");
|
|
4511
|
+
fs.writeFileSync(path.join(repo, ".pi-subagents", "artifacts", "status.json"), JSON.stringify({ runId: "cleanup-action-run", state: "complete" }), "utf-8");
|
|
4512
|
+
try {
|
|
4513
|
+
const executor = makeExecutor([makeAgent("echo")], { worktreeBaseDir: baseDir });
|
|
4514
|
+
const result = await executor.executePublic("cleanup-plan", { action: "worktree.cleanup", repo: "cleanup-repo", mode: "plan" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
4515
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "cleanup plan failed");
|
|
4516
|
+
const text = result.content[0]?.text ?? "";
|
|
4517
|
+
assert.match(text, /Will remove/);
|
|
4518
|
+
assert.match(text, /Plan-only mode: no worktrees or branches were removed/);
|
|
4519
|
+
const planFiles = fs.readdirSync(path.join(repo, ".pi-subagents", "cleanup-plans"));
|
|
4520
|
+
assert.equal(planFiles.length, 1);
|
|
4521
|
+
const planId = planFiles[0]!.replace(/\.json$/, "");
|
|
4522
|
+
const childSafe = makeExecutor([makeAgent("echo")], { worktreeBaseDir: baseDir }, false, undefined, false);
|
|
4523
|
+
const childSafeResult = await childSafe.executePublic("cleanup-child-safe", { action: "worktree.cleanup", repo: "cleanup-repo", mode: "plan" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
4524
|
+
assert.equal(childSafeResult.isError, true);
|
|
4525
|
+
assert.match(childSafeResult.content[0]?.text ?? "", /child-safe subagent fanout mode/i);
|
|
4526
|
+
const apply = await executor.executePublic("cleanup-apply", { action: "worktree.cleanup", repo: "cleanup-repo", mode: "apply", planId }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
4527
|
+
assert.equal(apply.isError, true);
|
|
4528
|
+
assert.match(apply.content[0]?.text ?? "", /plan.*only|apply\/removal is not available/i);
|
|
4529
|
+
assert.ok(fs.existsSync(worktree.path));
|
|
4530
|
+
assert.notEqual(execFileSync("git", ["-C", repo, "branch", "--list", worktree.branch], { encoding: "utf-8" }).trim(), "");
|
|
4531
|
+
} finally {
|
|
4532
|
+
try { execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktree.path], { stdio: "ignore" }); } catch {}
|
|
4533
|
+
try { execFileSync("git", ["-C", repo, "branch", "-D", worktree.branch], { stdio: "ignore" }); } catch {}
|
|
4534
|
+
}
|
|
4535
|
+
});
|
|
4536
|
+
|
|
3806
4537
|
|
|
3807
4538
|
|
|
3808
4539
|
it("reports total cost for foreground single runs", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
@@ -3819,6 +4550,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3819
4550
|
|
|
3820
4551
|
assert.equal(result.isError, undefined);
|
|
3821
4552
|
assert.deepEqual(result.details?.totalCost, { inputTokens: 100, outputTokens: 50, costUsd: 0.001 });
|
|
4553
|
+
assert.deepEqual(result.usage, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 150, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.001 } });
|
|
3822
4554
|
});
|
|
3823
4555
|
|
|
3824
4556
|
it("ignores stale foreground control notification contexts after reload", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
@@ -3941,6 +4673,71 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3941
4673
|
]);
|
|
3942
4674
|
});
|
|
3943
4675
|
|
|
4676
|
+
it("preserves terminal empty-output diagnostics after useful foreground work", async () => {
|
|
4677
|
+
const partialOutput = "I’ll inspect the retained candidate before changing it.";
|
|
4678
|
+
mockPi.onCall({
|
|
4679
|
+
jsonl: [
|
|
4680
|
+
events.toolStart("read", { path: "src/index.ts" }),
|
|
4681
|
+
events.toolEnd("read"),
|
|
4682
|
+
events.toolResult("read", "file contents"),
|
|
4683
|
+
events.assistantMessage(partialOutput),
|
|
4684
|
+
{
|
|
4685
|
+
type: "message_end",
|
|
4686
|
+
message: {
|
|
4687
|
+
role: "assistant",
|
|
4688
|
+
content: [],
|
|
4689
|
+
model: "mock/test-model",
|
|
4690
|
+
stopReason: "aborted",
|
|
4691
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
4692
|
+
},
|
|
4693
|
+
},
|
|
4694
|
+
],
|
|
4695
|
+
exitCode: 0,
|
|
4696
|
+
});
|
|
4697
|
+
|
|
4698
|
+
const result = await runSync(tempDir, [makeAgent("worker")], "worker", "Implement the approved file changes", {
|
|
4699
|
+
runId: "foreground-aborted-empty-output",
|
|
4700
|
+
});
|
|
4701
|
+
|
|
4702
|
+
assert.equal(result.exitCode, 1);
|
|
4703
|
+
assert.match(result.error ?? "", /^Subagent produced no output after terminal assistant stopReason "aborted"\./);
|
|
4704
|
+
assert.doesNotMatch(result.error ?? "", /completed without making edits/);
|
|
4705
|
+
assert.equal(result.finalOutput, partialOutput);
|
|
4706
|
+
});
|
|
4707
|
+
|
|
4708
|
+
it("reports why an unsafe foreground compaction abort cannot resume without falling back", async () => {
|
|
4709
|
+
mockPi.onCall({
|
|
4710
|
+
jsonl: [
|
|
4711
|
+
events.toolStart("read", { path: "src/index.ts" }),
|
|
4712
|
+
events.toolEnd("read"),
|
|
4713
|
+
events.toolResult("read", "file contents"),
|
|
4714
|
+
{ type: "compaction_start" },
|
|
4715
|
+
{
|
|
4716
|
+
type: "message_end",
|
|
4717
|
+
message: {
|
|
4718
|
+
role: "assistant",
|
|
4719
|
+
content: [],
|
|
4720
|
+
model: "mock/test-model",
|
|
4721
|
+
stopReason: "aborted",
|
|
4722
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
4723
|
+
},
|
|
4724
|
+
},
|
|
4725
|
+
{ type: "agent_settled" },
|
|
4726
|
+
],
|
|
4727
|
+
exitCode: 0,
|
|
4728
|
+
});
|
|
4729
|
+
mockPi.onCall({ output: "Fallback must not run" });
|
|
4730
|
+
|
|
4731
|
+
const result = await runSync(tempDir, [makeAgent("worker", { model: "mock/test-model", fallbackModels: ["mock/fallback-model"] })], "worker", "Inspect the current source", {
|
|
4732
|
+
runId: "foreground-compaction-abort-no-session",
|
|
4733
|
+
});
|
|
4734
|
+
|
|
4735
|
+
assert.equal(result.exitCode, 1);
|
|
4736
|
+
assert.match(result.error ?? "", /^Subagent produced no output after terminal assistant stopReason "aborted"\./);
|
|
4737
|
+
assert.match(result.error ?? "", /Compaction-induced child abort could not be resumed safely: retained session unavailable\./);
|
|
4738
|
+
assert.equal(mockPi.callCount(), 1);
|
|
4739
|
+
});
|
|
4740
|
+
|
|
3944
4741
|
it("agent contract v1 reports omitted acceptance separately without injecting a prompt", async () => {
|
|
3945
4742
|
mockPi.onCall({ output: "Plan only" });
|
|
3946
4743
|
const agents = [makeAgent("worker", { tools: ["read", "write"] })];
|
|
@@ -3970,54 +4767,261 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3970
4767
|
acceptance: { level: "checked", criteria: ["Return required proof"] },
|
|
3971
4768
|
});
|
|
3972
4769
|
|
|
3973
|
-
assert.equal(result.exitCode, 0);
|
|
3974
|
-
assert.equal(result.error, undefined);
|
|
3975
|
-
assert.equal(result.execution?.status, "completed");
|
|
3976
|
-
assert.equal(result.execution?.success, true);
|
|
3977
|
-
assert.equal(result.acceptance?.status, "rejected");
|
|
3978
|
-
assert.match(result.acceptance.runtimeChecks?.[0]?.message ?? "", /not-satisfied/);
|
|
4770
|
+
assert.equal(result.exitCode, 0);
|
|
4771
|
+
assert.equal(result.error, undefined);
|
|
4772
|
+
assert.equal(result.execution?.status, "completed");
|
|
4773
|
+
assert.equal(result.execution?.success, true);
|
|
4774
|
+
assert.equal(result.acceptance?.status, "rejected");
|
|
4775
|
+
assert.match(result.acceptance.runtimeChecks?.[0]?.message ?? "", /not-satisfied/);
|
|
4776
|
+
});
|
|
4777
|
+
|
|
4778
|
+
it("agent contract v1 records explicit completion guard as an effect", async () => {
|
|
4779
|
+
mockPi.onCall({ output: "Plan only" });
|
|
4780
|
+
const agents = [makeAgent("worker", { tools: ["read", "write"], completionGuard: true })];
|
|
4781
|
+
|
|
4782
|
+
const result = await runSync(tempDir, agents, "worker", "Implement the approved file changes", {
|
|
4783
|
+
runId: "v1-completion-effect",
|
|
4784
|
+
agentContract: { version: 1 },
|
|
4785
|
+
});
|
|
4786
|
+
|
|
4787
|
+
assert.equal(result.exitCode, 0);
|
|
4788
|
+
assert.equal(result.execution?.status, "completed");
|
|
4789
|
+
assert.equal(result.effects?.fileMutation?.status, "missing");
|
|
4790
|
+
assert.equal(result.effects?.fileMutation?.expected, true);
|
|
4791
|
+
assert.equal(result.effects?.fileMutation?.attempted, false);
|
|
4792
|
+
});
|
|
4793
|
+
|
|
4794
|
+
it("direct single tool calls support outputSchema", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
4795
|
+
mockPi.onCall({
|
|
4796
|
+
stdoutRaw: [
|
|
4797
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true, note: "captured" } } },
|
|
4798
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
4799
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
4800
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
4801
|
+
structuredOutputCapture: { ok: true, note: "captured" },
|
|
4802
|
+
});
|
|
4803
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
4804
|
+
|
|
4805
|
+
const result = await executor.execute(
|
|
4806
|
+
"single-schema",
|
|
4807
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } }, acceptance: false },
|
|
4808
|
+
new AbortController().signal,
|
|
4809
|
+
undefined,
|
|
4810
|
+
makeMinimalCtx(tempDir),
|
|
4811
|
+
);
|
|
4812
|
+
|
|
4813
|
+
assert.equal(result.isError, undefined);
|
|
4814
|
+
const child = result.details?.results?.[0];
|
|
4815
|
+
assert.deepEqual(child?.structuredOutput, { ok: true, note: "captured" });
|
|
4816
|
+
assert.match(child?.finalOutput ?? "", /"ok": true/);
|
|
4817
|
+
if (child?.artifactPaths?.outputPath) assert.match(fs.readFileSync(child.artifactPaths.outputPath, "utf-8"), /"note": "captured"/);
|
|
4818
|
+
});
|
|
4819
|
+
|
|
4820
|
+
it("applies explicit structured-output contract fields when resuming a foreground workflow child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
4821
|
+
const schema = { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } };
|
|
4822
|
+
const firstSchema = { type: "object", required: ["first"], properties: { first: { type: "boolean" } } };
|
|
4823
|
+
const firstEvents = [
|
|
4824
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { first: true } } },
|
|
4825
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
4826
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
4827
|
+
];
|
|
4828
|
+
const resumedEvents = [
|
|
4829
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
|
|
4830
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
4831
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
4832
|
+
];
|
|
4833
|
+
mockPi.onCall({ stdoutRaw: firstEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { first: true } });
|
|
4834
|
+
mockPi.onCall({ stdoutRaw: resumedEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { ok: true } });
|
|
4835
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
4836
|
+
"workflow-explicit-resume-schema",
|
|
4837
|
+
{
|
|
4838
|
+
async: false,
|
|
4839
|
+
workflowScript: `
|
|
4840
|
+
const first = await runs.run("first", { agent: "echo", task: "First", outputSchema: ${JSON.stringify(firstSchema)}, agentContract: { version: 1 }, acceptance: false, output: true });
|
|
4841
|
+
return runs.run("resumed", { resume: first.runId, task: "Resume", outputSchema: ${JSON.stringify(schema)}, agentContract: { version: 1 }, acceptance: false, output: false });
|
|
4842
|
+
`,
|
|
4843
|
+
},
|
|
4844
|
+
new AbortController().signal,
|
|
4845
|
+
undefined,
|
|
4846
|
+
makeMinimalCtx(tempDir),
|
|
4847
|
+
);
|
|
4848
|
+
|
|
4849
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
4850
|
+
const resumed = result.details.workflow?.value as { ok?: boolean; structuredOutput?: unknown; savedOutputPath?: string };
|
|
4851
|
+
assert.equal(resumed.ok, true);
|
|
4852
|
+
assert.deepEqual(resumed.structuredOutput, { ok: true });
|
|
4853
|
+
assert.equal(resumed.savedOutputPath, undefined);
|
|
4854
|
+
});
|
|
4855
|
+
|
|
4856
|
+
it("preserves the structured-output contract when resume fields are omitted", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
4857
|
+
const schema = { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } };
|
|
4858
|
+
const structuredEvents = [
|
|
4859
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
|
|
4860
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
4861
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
4862
|
+
];
|
|
4863
|
+
mockPi.onCall({ stdoutRaw: structuredEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { ok: true } });
|
|
4864
|
+
mockPi.onCall({ stdoutRaw: structuredEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { ok: true } });
|
|
4865
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
4866
|
+
"workflow-inherited-resume-schema",
|
|
4867
|
+
{
|
|
4868
|
+
async: false,
|
|
4869
|
+
workflowScript: `
|
|
4870
|
+
const first = await runs.run("first", { agent: "echo", task: "First", outputSchema: ${JSON.stringify(schema)}, agentContract: { version: 1 }, acceptance: false, output: false });
|
|
4871
|
+
return runs.run("resumed", { resume: first.runId, task: "Resume" });
|
|
4872
|
+
`,
|
|
4873
|
+
},
|
|
4874
|
+
new AbortController().signal,
|
|
4875
|
+
undefined,
|
|
4876
|
+
makeMinimalCtx(tempDir),
|
|
4877
|
+
);
|
|
4878
|
+
|
|
4879
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
4880
|
+
const resumed = result.details.workflow?.value as { ok?: boolean; structuredOutput?: unknown };
|
|
4881
|
+
assert.equal(resumed.ok, true);
|
|
4882
|
+
assert.deepEqual(resumed.structuredOutput, { ok: true });
|
|
4883
|
+
});
|
|
4884
|
+
|
|
4885
|
+
it("auto-resumes a workflow child after a setup abort", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
4886
|
+
mockPi.onCall({
|
|
4887
|
+
jsonl: [
|
|
4888
|
+
events.toolStart("read", { path: "src/index.ts" }),
|
|
4889
|
+
events.toolEnd("read"),
|
|
4890
|
+
events.toolResult("read", "file contents"),
|
|
4891
|
+
{
|
|
4892
|
+
type: "message_end",
|
|
4893
|
+
message: {
|
|
4894
|
+
role: "assistant",
|
|
4895
|
+
content: [],
|
|
4896
|
+
model: "openai-codex/gpt-5.6-luna",
|
|
4897
|
+
stopReason: "error",
|
|
4898
|
+
errorMessage: "This operation was aborted",
|
|
4899
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
4900
|
+
},
|
|
4901
|
+
},
|
|
4902
|
+
],
|
|
4903
|
+
exitCode: 1,
|
|
4904
|
+
});
|
|
4905
|
+
mockPi.onCall({ output: "Recovered after workflow auto-resume" });
|
|
4906
|
+
|
|
4907
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
4908
|
+
"workflow-auto-resume-setup-abort",
|
|
4909
|
+
{
|
|
4910
|
+
async: false,
|
|
4911
|
+
workflowScript: `return runs.run("review", { agent: "echo", task: "Review the current diff", acceptance: false });`,
|
|
4912
|
+
},
|
|
4913
|
+
new AbortController().signal,
|
|
4914
|
+
undefined,
|
|
4915
|
+
makeMinimalCtx(tempDir),
|
|
4916
|
+
);
|
|
4917
|
+
|
|
4918
|
+
assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
|
|
4919
|
+
const child = result.details.workflow?.value as { ok?: boolean; runId?: string; output?: string; continuation?: { runIds?: string[] } };
|
|
4920
|
+
assert.equal(child.ok, true);
|
|
4921
|
+
assert.match(child.output ?? "", /Recovered after workflow auto-resume/u);
|
|
4922
|
+
assert.deepEqual(result.details.results[0]?.usage, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.001, turns: 1 });
|
|
4923
|
+
// The workflow-level setup recovery is a distinct launch, so its receipt
|
|
4924
|
+
// retains both the failed source run and the resumed child run. The
|
|
4925
|
+
// compaction planner no longer hides this source by resuming it first.
|
|
4926
|
+
assert.equal(child.continuation?.runIds?.length, 2);
|
|
4927
|
+
assert.notEqual(child.continuation?.runIds?.[0], child.runId);
|
|
4928
|
+
assert.equal(child.continuation?.runIds?.at(-1), child.runId);
|
|
4929
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3979
4930
|
});
|
|
3980
4931
|
|
|
3981
|
-
it("agent contract
|
|
3982
|
-
|
|
3983
|
-
const
|
|
4932
|
+
it("preserves an agent default output contract when foreground workflow resume omits output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
4933
|
+
const configuredOutput = path.join(tempDir, "configured-resume-output.md");
|
|
4934
|
+
const agent = makeAgent("echo", { output: configuredOutput, outputMode: "file-only" });
|
|
4935
|
+
const executor = makeExecutor([agent]);
|
|
4936
|
+
mockPi.onCall({ output: "first report" });
|
|
4937
|
+
mockPi.onCall({ output: "resumed report" });
|
|
3984
4938
|
|
|
3985
|
-
const
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
4939
|
+
const firstResult = await executor.execute(
|
|
4940
|
+
"workflow-agent-output-first",
|
|
4941
|
+
{ async: false, workflowScript: `return runs.run("first", { agent: "echo", task: "First", acceptance: false });` },
|
|
4942
|
+
new AbortController().signal,
|
|
4943
|
+
undefined,
|
|
4944
|
+
makeMinimalCtx(tempDir),
|
|
4945
|
+
);
|
|
4946
|
+
assert.equal(firstResult.isError, undefined, firstResult.content[0]?.text ?? "workflow failed");
|
|
4947
|
+
const first = firstResult.details.workflow?.value as { runId?: string };
|
|
4948
|
+
const firstChild = firstResult.details.results[0];
|
|
4949
|
+
assert.ok(first.runId);
|
|
4950
|
+
assert.equal(firstChild?.savedOutputPath, configuredOutput);
|
|
4951
|
+
assert.equal(firstChild?.outputMode, "file-only");
|
|
4952
|
+
|
|
4953
|
+
agent.output = undefined;
|
|
4954
|
+
agent.outputMode = undefined;
|
|
4955
|
+
const resumedResult = await executor.execute(
|
|
4956
|
+
"workflow-agent-output-resumed",
|
|
4957
|
+
{ async: false, workflowScript: `return runs.run("resumed", { resume: ${JSON.stringify(first.runId)}, task: "Resume" });` },
|
|
4958
|
+
new AbortController().signal,
|
|
4959
|
+
undefined,
|
|
4960
|
+
makeMinimalCtx(tempDir),
|
|
4961
|
+
);
|
|
3989
4962
|
|
|
3990
|
-
assert.equal(
|
|
3991
|
-
|
|
3992
|
-
assert.
|
|
3993
|
-
assert.equal(
|
|
3994
|
-
assert.equal(result.effects?.fileMutation?.attempted, false);
|
|
4963
|
+
assert.equal(resumedResult.isError, undefined, resumedResult.content[0]?.text ?? "workflow failed");
|
|
4964
|
+
const resumed = resumedResult.details.results[0];
|
|
4965
|
+
assert.match(resumed?.finalOutput ?? "", new RegExp(`Output saved to: ${escapeRegExp(configuredOutput)}`));
|
|
4966
|
+
assert.equal(fs.readFileSync(configuredOutput, "utf-8"), "resumed report");
|
|
3995
4967
|
});
|
|
3996
4968
|
|
|
3997
|
-
it("
|
|
3998
|
-
mockPi.onCall({
|
|
3999
|
-
stdoutRaw: [
|
|
4000
|
-
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true, note: "captured" } } },
|
|
4001
|
-
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
4002
|
-
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
4003
|
-
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
4004
|
-
structuredOutputCapture: { ok: true, note: "captured" },
|
|
4005
|
-
});
|
|
4969
|
+
it("preserves failed foreground resume errors and transcript metadata", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
4006
4970
|
const executor = makeExecutor([makeAgent("echo")]);
|
|
4971
|
+
mockPi.onCall({ output: "first report" });
|
|
4972
|
+
const firstResult = await executor.execute(
|
|
4973
|
+
"workflow-resume-failure-first",
|
|
4974
|
+
{ async: false, workflowScript: `return runs.run("first", { agent: "echo", task: "First" });` },
|
|
4975
|
+
new AbortController().signal,
|
|
4976
|
+
undefined,
|
|
4977
|
+
makeMinimalCtx(tempDir),
|
|
4978
|
+
);
|
|
4979
|
+
assert.equal(firstResult.isError, undefined, firstResult.content[0]?.text ?? "workflow failed");
|
|
4980
|
+
const first = firstResult.details.workflow?.value as { runId?: string };
|
|
4981
|
+
assert.ok(first.runId);
|
|
4007
4982
|
|
|
4008
|
-
const
|
|
4009
|
-
|
|
4010
|
-
|
|
4983
|
+
const partialOutput = "I’ll re-read the current implementation before changing it.";
|
|
4984
|
+
mockPi.onCall({ output: partialOutput });
|
|
4985
|
+
const resumedResult = await executor.execute(
|
|
4986
|
+
"workflow-resume-failure-resumed",
|
|
4987
|
+
{ async: false, workflowScript: `return runs.run("resumed", { resume: ${JSON.stringify(first.runId)}, task: "Implement the approved file changes" });` },
|
|
4011
4988
|
new AbortController().signal,
|
|
4012
4989
|
undefined,
|
|
4013
4990
|
makeMinimalCtx(tempDir),
|
|
4014
4991
|
);
|
|
4015
4992
|
|
|
4016
|
-
assert.equal(
|
|
4017
|
-
const
|
|
4018
|
-
assert.
|
|
4019
|
-
assert.
|
|
4020
|
-
|
|
4993
|
+
assert.equal(resumedResult.isError, true);
|
|
4994
|
+
const resumedText = resumedResult.content.map((part) => part.type === "text" ? part.text : "").join("\n");
|
|
4995
|
+
assert.match(resumedText, /Subagent completed without making edits for an implementation task/);
|
|
4996
|
+
assert.doesNotMatch(resumedText, new RegExp(`^${escapeRegExp(partialOutput)}`));
|
|
4997
|
+
const child = resumedResult.details.results[0];
|
|
4998
|
+
assert.equal(child?.finalOutput, partialOutput);
|
|
4999
|
+
assert.match(child?.error ?? "", /Subagent completed without making edits for an implementation task/);
|
|
5000
|
+
assert.ok(child?.transcriptPath);
|
|
5001
|
+
assert.equal(child?.transcriptPath, child?.artifactPaths?.transcriptPath);
|
|
5002
|
+
assert.ok(child?.artifactPaths?.outputPath);
|
|
5003
|
+
assert.match(fs.readFileSync(child.transcriptPath, "utf-8"), /first|re-read|implementation/i);
|
|
5004
|
+
});
|
|
5005
|
+
|
|
5006
|
+
it("fails closed on an invalid explicit foreground resume output schema", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
5007
|
+
mockPi.onCall({ output: "first result" });
|
|
5008
|
+
const result = await makeExecutor([makeAgent("echo")]).execute(
|
|
5009
|
+
"workflow-invalid-explicit-resume-schema",
|
|
5010
|
+
{
|
|
5011
|
+
async: false,
|
|
5012
|
+
workflowScript: `
|
|
5013
|
+
const first = await runs.run("first", { agent: "echo", task: "First" });
|
|
5014
|
+
return runs.run("resumed", { resume: first.runId, task: "Resume", outputSchema: null });
|
|
5015
|
+
`,
|
|
5016
|
+
},
|
|
5017
|
+
new AbortController().signal,
|
|
5018
|
+
undefined,
|
|
5019
|
+
makeMinimalCtx(tempDir),
|
|
5020
|
+
);
|
|
5021
|
+
|
|
5022
|
+
assert.equal(result.isError, true);
|
|
5023
|
+
assert.match(result.content[0]?.text ?? "", /outputSchema must be a JSON Schema object/);
|
|
5024
|
+
assert.equal(mockPi.callCount(), 1);
|
|
4021
5025
|
});
|
|
4022
5026
|
|
|
4023
5027
|
it("workflow children with outputSchema can satisfy inherited checked acceptance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
@@ -4244,7 +5248,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
4244
5248
|
|
|
4245
5249
|
const result = await executor.execute(
|
|
4246
5250
|
"single-schema-strict-boundary",
|
|
4247
|
-
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } },
|
|
5251
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false },
|
|
4248
5252
|
new AbortController().signal,
|
|
4249
5253
|
undefined,
|
|
4250
5254
|
makeMinimalCtx(tempDir),
|
|
@@ -4252,7 +5256,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
4252
5256
|
|
|
4253
5257
|
const child = result.details?.results?.[0];
|
|
4254
5258
|
assert.equal(result.isError, undefined);
|
|
4255
|
-
assert.equal(child?.turnBudgetExceeded, undefined);
|
|
4256
5259
|
assert.deepEqual(child?.structuredOutput, { ok: true });
|
|
4257
5260
|
});
|
|
4258
5261
|
|
|
@@ -4365,7 +5368,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
4365
5368
|
const result = await runSync(tempDir, agents, "nonexistent", sentinel, {});
|
|
4366
5369
|
|
|
4367
5370
|
assert.equal(result.exitCode, 1);
|
|
4368
|
-
assert.
|
|
5371
|
+
assert.match(result.error ?? "", /^Unknown agent: nonexistent\nEffective cwd: /);
|
|
5372
|
+
assert.match(result.error ?? "", /Consulted agent-definition directories:[\s\S]*Discovered agents:/);
|
|
5373
|
+
assert.doesNotMatch(result.error ?? "", /echo \(project\)/);
|
|
4369
5374
|
assert.equal(result.task, "[prompt redacted]");
|
|
4370
5375
|
assert.doesNotMatch(JSON.stringify(result), new RegExp(sentinel));
|
|
4371
5376
|
});
|
|
@@ -4460,6 +5465,36 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
4460
5465
|
assert.ok(result.error?.includes("Something went wrong"));
|
|
4461
5466
|
});
|
|
4462
5467
|
|
|
5468
|
+
it("surfaces a non-retryable provider failure when the child produced no output", async () => {
|
|
5469
|
+
mockPi.onCall({
|
|
5470
|
+
jsonl: [{
|
|
5471
|
+
type: "message_end",
|
|
5472
|
+
message: {
|
|
5473
|
+
role: "assistant",
|
|
5474
|
+
content: [],
|
|
5475
|
+
model: "openai/gpt-5-mini",
|
|
5476
|
+
errorMessage: "Invalid request: malformed payload",
|
|
5477
|
+
usage: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
5478
|
+
},
|
|
5479
|
+
}],
|
|
5480
|
+
exitCode: 1,
|
|
5481
|
+
});
|
|
5482
|
+
const executor = makeExecutor([makeAgent("echo", { model: "openai/gpt-5-mini" })]);
|
|
5483
|
+
|
|
5484
|
+
const result = await executor.execute(
|
|
5485
|
+
"non-retryable-provider-failure",
|
|
5486
|
+
{ agent: "echo", task: "Task", async: false, acceptance: false },
|
|
5487
|
+
new AbortController().signal,
|
|
5488
|
+
undefined,
|
|
5489
|
+
makeMinimalCtx(tempDir),
|
|
5490
|
+
);
|
|
5491
|
+
|
|
5492
|
+
assert.equal(result.isError, true);
|
|
5493
|
+
assert.match(result.content[0]?.text ?? "", /Invalid request: malformed payload/u);
|
|
5494
|
+
assert.match(result.details.results[0]?.error ?? "", /Invalid request: malformed payload/u);
|
|
5495
|
+
assert.equal(mockPi.callCount(), 1);
|
|
5496
|
+
});
|
|
5497
|
+
|
|
4463
5498
|
it("retries a zero-activity startup exit on the same model", async () => {
|
|
4464
5499
|
mockPi.onCall({ exitCode: 1 });
|
|
4465
5500
|
mockPi.onCall({ output: "Recovered after startup race" });
|
|
@@ -4757,6 +5792,18 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
4757
5792
|
assert.ok(Date.now() - startedAt >= 1200, "foreground runner must not terminate during the retry delay");
|
|
4758
5793
|
});
|
|
4759
5794
|
|
|
5795
|
+
it("does not drain on settlement from a compaction attempt that will retry", async () => {
|
|
5796
|
+
mockPi.onCall({ steps: [
|
|
5797
|
+
{ jsonl: [{ type: "compaction_end", willRetry: true }, { type: "agent_settled" }] },
|
|
5798
|
+
{ delay: 1400, jsonl: [events.assistantMessage("settled after compaction retry"), { type: "agent_start" }, { type: "agent_end", willRetry: false }, { type: "agent_settled" }] },
|
|
5799
|
+
] });
|
|
5800
|
+
const startedAt = Date.now();
|
|
5801
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Retry after compaction", { acceptance: false });
|
|
5802
|
+
assert.equal(result.exitCode, 0);
|
|
5803
|
+
assert.equal(getFinalOutput(result.messages), "settled after compaction retry");
|
|
5804
|
+
assert.ok(Date.now() - startedAt >= 1200, "foreground runner must not terminate during compaction retry");
|
|
5805
|
+
});
|
|
5806
|
+
|
|
4760
5807
|
it("treats agent_settled as a clean terminal watermark", async () => {
|
|
4761
5808
|
const nonTerminalMessage = events.assistantMessage("settled without a terminal assistant stop") as { message: { stopReason: string } };
|
|
4762
5809
|
nonTerminalMessage.message.stopReason = "length";
|
|
@@ -5134,6 +6181,179 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5134
6181
|
assert.equal(mockPi.callCount(), 1);
|
|
5135
6182
|
});
|
|
5136
6183
|
|
|
6184
|
+
it("does not retry raw connection stderr after child activity", async () => {
|
|
6185
|
+
mockPi.onCall({
|
|
6186
|
+
jsonl: [events.assistantMessage("completed side effect", "openai/gpt-5-mini")],
|
|
6187
|
+
stderr: "APIConnectionError: Connection closed.",
|
|
6188
|
+
exitCode: 1,
|
|
6189
|
+
});
|
|
6190
|
+
mockPi.onCall({ output: "fallback must not run" });
|
|
6191
|
+
const agents = [makeAgent("echo", {
|
|
6192
|
+
model: "openai/gpt-5-mini",
|
|
6193
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
6194
|
+
})];
|
|
6195
|
+
|
|
6196
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
6197
|
+
runId: "no-fallback-raw-stderr",
|
|
6198
|
+
});
|
|
6199
|
+
|
|
6200
|
+
assert.equal(result.exitCode, 1);
|
|
6201
|
+
assert.match(result.error ?? "", /Connection closed/u);
|
|
6202
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
6203
|
+
assert.equal(mockPi.callCount(), 1);
|
|
6204
|
+
});
|
|
6205
|
+
|
|
6206
|
+
it("does not use compaction recovery for a generic empty assistant abort after a compaction retry", async () => {
|
|
6207
|
+
const sessionFile = path.join(tempDir, "generic-empty-after-compaction-retry-session.jsonl");
|
|
6208
|
+
mockPi.onCall({
|
|
6209
|
+
jsonl: [
|
|
6210
|
+
{ type: "compaction_start" },
|
|
6211
|
+
{ type: "compaction_end", willRetry: true },
|
|
6212
|
+
{ type: "agent_settled" },
|
|
6213
|
+
{ type: "agent_start" },
|
|
6214
|
+
events.assistantMessage("The compaction retry produced useful output.", "openai/gpt-5-mini"),
|
|
6215
|
+
{ type: "agent_settled" },
|
|
6216
|
+
{
|
|
6217
|
+
type: "message_end",
|
|
6218
|
+
message: {
|
|
6219
|
+
role: "assistant",
|
|
6220
|
+
content: [],
|
|
6221
|
+
model: "openai/gpt-5-mini",
|
|
6222
|
+
stopReason: "aborted",
|
|
6223
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
6224
|
+
},
|
|
6225
|
+
},
|
|
6226
|
+
{ type: "agent_settled" },
|
|
6227
|
+
],
|
|
6228
|
+
writeFiles: [{ path: sessionFile, content: "{}\n" }],
|
|
6229
|
+
exitCode: 0,
|
|
6230
|
+
});
|
|
6231
|
+
mockPi.onCall({ output: "Compaction recovery must not run" });
|
|
6232
|
+
const agents = [makeAgent("echo", { model: "openai/gpt-5-mini" })];
|
|
6233
|
+
|
|
6234
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
6235
|
+
runId: "no-compaction-recovery-after-compaction-retry",
|
|
6236
|
+
sessionFile,
|
|
6237
|
+
});
|
|
6238
|
+
|
|
6239
|
+
assert.equal(result.exitCode, 1);
|
|
6240
|
+
assert.match(result.error ?? "", /Subagent produced no output after terminal assistant stopReason "aborted"\./u);
|
|
6241
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
6242
|
+
assert.equal(mockPi.callCount(), 1);
|
|
6243
|
+
});
|
|
6244
|
+
|
|
6245
|
+
it("does not use compaction recovery after compaction_end willRetry false and a continued agent turn", async () => {
|
|
6246
|
+
const sessionFile = path.join(tempDir, "generic-empty-after-successful-compaction-session.jsonl");
|
|
6247
|
+
mockPi.onCall({
|
|
6248
|
+
jsonl: [
|
|
6249
|
+
{ type: "compaction_start" },
|
|
6250
|
+
{ type: "compaction_end", willRetry: false },
|
|
6251
|
+
{ type: "agent_start" },
|
|
6252
|
+
{
|
|
6253
|
+
type: "message_end",
|
|
6254
|
+
message: {
|
|
6255
|
+
role: "assistant",
|
|
6256
|
+
content: [],
|
|
6257
|
+
model: "openai/gpt-5-mini",
|
|
6258
|
+
stopReason: "aborted",
|
|
6259
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
6260
|
+
},
|
|
6261
|
+
},
|
|
6262
|
+
{ type: "agent_settled" },
|
|
6263
|
+
],
|
|
6264
|
+
writeFiles: [{ path: sessionFile, content: "{}\n" }],
|
|
6265
|
+
exitCode: 0,
|
|
6266
|
+
});
|
|
6267
|
+
mockPi.onCall({ output: "Compaction recovery must not run" });
|
|
6268
|
+
const agents = [makeAgent("echo", { model: "openai/gpt-5-mini" })];
|
|
6269
|
+
|
|
6270
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
6271
|
+
runId: "no-compaction-recovery-after-successful-compaction",
|
|
6272
|
+
sessionFile,
|
|
6273
|
+
});
|
|
6274
|
+
|
|
6275
|
+
assert.equal(result.exitCode, 1);
|
|
6276
|
+
assert.match(result.error ?? "", /Subagent produced no output after terminal assistant stopReason "aborted"\./u);
|
|
6277
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
6278
|
+
assert.equal(mockPi.callCount(), 1);
|
|
6279
|
+
});
|
|
6280
|
+
|
|
6281
|
+
it("does not use compaction recovery for a generic provider abort after normal settlement", async () => {
|
|
6282
|
+
const sessionFile = path.join(tempDir, "generic-provider-after-compaction-session.jsonl");
|
|
6283
|
+
mockPi.onCall({
|
|
6284
|
+
jsonl: [
|
|
6285
|
+
{ type: "compaction_start" },
|
|
6286
|
+
events.assistantMessage("Compaction completed and the child settled normally.", "openai/gpt-5-mini"),
|
|
6287
|
+
{ type: "agent_settled" },
|
|
6288
|
+
],
|
|
6289
|
+
writeFiles: [{ path: sessionFile, content: "{}\n" }],
|
|
6290
|
+
stderr: "APIConnectionError: Connection reset by provider transport.",
|
|
6291
|
+
exitCode: 1,
|
|
6292
|
+
});
|
|
6293
|
+
mockPi.onCall({ output: "Compaction recovery must not run" });
|
|
6294
|
+
const agents = [makeAgent("echo", {
|
|
6295
|
+
model: "openai/gpt-5-mini",
|
|
6296
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
6297
|
+
})];
|
|
6298
|
+
|
|
6299
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
6300
|
+
runId: "no-compaction-recovery-after-normal-settlement",
|
|
6301
|
+
sessionFile,
|
|
6302
|
+
});
|
|
6303
|
+
|
|
6304
|
+
assert.equal(result.exitCode, 1);
|
|
6305
|
+
assert.match(result.error ?? "", /Connection reset by provider transport/u);
|
|
6306
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
6307
|
+
assert.equal(mockPi.callCount(), 1);
|
|
6308
|
+
});
|
|
6309
|
+
|
|
6310
|
+
it("resumes the retained session once after a compaction-induced abort following completed tool work", async () => {
|
|
6311
|
+
const sessionFile = path.join(tempDir, "abort-recovery-session.jsonl");
|
|
6312
|
+
mockPi.onCall({
|
|
6313
|
+
jsonl: [
|
|
6314
|
+
events.toolStart("write", { path: "side-effect.txt", content: "done" }),
|
|
6315
|
+
events.toolEnd("write"),
|
|
6316
|
+
events.toolResult("write", "Wrote side-effect.txt"),
|
|
6317
|
+
{ type: "compaction_start" },
|
|
6318
|
+
{
|
|
6319
|
+
type: "message_end",
|
|
6320
|
+
message: {
|
|
6321
|
+
role: "assistant",
|
|
6322
|
+
content: [],
|
|
6323
|
+
model: "openai/gpt-5-mini",
|
|
6324
|
+
stopReason: "aborted",
|
|
6325
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
6326
|
+
},
|
|
6327
|
+
},
|
|
6328
|
+
{ type: "agent_settled" },
|
|
6329
|
+
],
|
|
6330
|
+
writeFiles: [{ path: "side-effect.txt", content: "done" }, { path: sessionFile, content: "{}\n" }],
|
|
6331
|
+
keepAliveAfterFinalMessageMs: 5_000,
|
|
6332
|
+
exitCode: 0,
|
|
6333
|
+
});
|
|
6334
|
+
mockPi.onCall({ output: "Recovered from retained session" });
|
|
6335
|
+
const agents = [makeAgent("echo", {
|
|
6336
|
+
model: "openai/gpt-5-mini",
|
|
6337
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
6338
|
+
})];
|
|
6339
|
+
|
|
6340
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
6341
|
+
runId: "resume-provider-after-tool",
|
|
6342
|
+
sessionFile,
|
|
6343
|
+
});
|
|
6344
|
+
|
|
6345
|
+
assert.equal(result.exitCode, 0);
|
|
6346
|
+
assert.equal(result.finalOutput, "Recovered from retained session");
|
|
6347
|
+
assert.deepEqual(result.attemptedModels, ["openai/gpt-5-mini"]);
|
|
6348
|
+
assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, true]);
|
|
6349
|
+
assert.equal(mockPi.callCount(), 2);
|
|
6350
|
+
const [firstArgs, resumedArgs] = readAllCallArgs();
|
|
6351
|
+
assert.equal(firstArgs?.[firstArgs.indexOf("--session") + 1], sessionFile);
|
|
6352
|
+
assert.equal(resumedArgs?.[resumedArgs.indexOf("--session") + 1], sessionFile);
|
|
6353
|
+
assert.match(resumedArgs?.at(-1) ?? "", /Continue from the current files and transcript/);
|
|
6354
|
+
assert.equal(fs.readFileSync(path.join(tempDir, "side-effect.txt"), "utf-8"), "done");
|
|
6355
|
+
});
|
|
6356
|
+
|
|
5137
6357
|
it("tracks progress during execution", async () => {
|
|
5138
6358
|
mockPi.onCall({ output: "Done" });
|
|
5139
6359
|
const agents = makeAgentConfigs(["echo"]);
|
|
@@ -5173,6 +6393,27 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5173
6393
|
assert.ok((runningUpdates.at(-1)?.durationMs ?? 0) > (runningUpdates[0]?.durationMs ?? 0), "expected heartbeat duration to advance");
|
|
5174
6394
|
});
|
|
5175
6395
|
|
|
6396
|
+
it("reports foreground context window usage without changing cumulative spend", async () => {
|
|
6397
|
+
mockPi.onCall({
|
|
6398
|
+
jsonl: [{
|
|
6399
|
+
type: "message_end",
|
|
6400
|
+
message: {
|
|
6401
|
+
role: "assistant",
|
|
6402
|
+
content: [{ type: "text", text: "Done" }],
|
|
6403
|
+
model: "mock/test-model",
|
|
6404
|
+
stopReason: "stop",
|
|
6405
|
+
usage: { input: 11, output: 7, cacheRead: 30, cacheWrite: 0, cost: { total: 0.001 } },
|
|
6406
|
+
},
|
|
6407
|
+
}],
|
|
6408
|
+
});
|
|
6409
|
+
|
|
6410
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {});
|
|
6411
|
+
|
|
6412
|
+
assert.equal(result.progress.tokens, 18);
|
|
6413
|
+
assert.equal(result.progress.window, 41);
|
|
6414
|
+
assert.equal(result.progress.windowPeak, 41);
|
|
6415
|
+
});
|
|
6416
|
+
|
|
5176
6417
|
it("tracks live activity updates and exposes artifact paths while running", async () => {
|
|
5177
6418
|
const updates: Array<{ details?: { results?: Array<{ artifactPaths?: ArtifactPaths }>; progress?: ProgressSummary[] } }> = [];
|
|
5178
6419
|
mockPi.onCall({
|
|
@@ -5795,6 +7036,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5795
7036
|
);
|
|
5796
7037
|
assert.equal(configResult.isError, true);
|
|
5797
7038
|
assert.match(configResult.content[0]?.text ?? "", /Workflow script timed out after 250ms/);
|
|
7039
|
+
assert.deepEqual(configResult.details.workflow?.receipt?.terminalOutcome, { state: "partial", reason: "timeout" });
|
|
5798
7040
|
|
|
5799
7041
|
const explicitResult = await executor.execute(
|
|
5800
7042
|
"workflow-config-timeout-explicit",
|
|
@@ -5805,6 +7047,31 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5805
7047
|
);
|
|
5806
7048
|
assert.equal(explicitResult.isError, true);
|
|
5807
7049
|
assert.match(explicitResult.content[0]?.text ?? "", /Workflow script timed out after 150ms/);
|
|
7050
|
+
assert.deepEqual(explicitResult.details.workflow?.receipt?.terminalOutcome, { state: "partial", reason: "timeout" });
|
|
7051
|
+
|
|
7052
|
+
const childLocalExecutor = makeExecutor([makeAgent("echo")], { timeoutMs: 10_000 });
|
|
7053
|
+
mockPi.onCall({ matchArgIncludes: "Fail normally", stderr: "upstream request timed out", exitCode: 1 });
|
|
7054
|
+
const ordinaryFailure = await childLocalExecutor.execute(
|
|
7055
|
+
"workflow-child-timeout-prose",
|
|
7056
|
+
{ async: false, workflowScript: `return await runs.run("failed", { agent: "echo", task: "Fail normally" });` },
|
|
7057
|
+
new AbortController().signal,
|
|
7058
|
+
undefined,
|
|
7059
|
+
makeMinimalCtx(tempDir),
|
|
7060
|
+
);
|
|
7061
|
+
assert.equal(ordinaryFailure.isError, true);
|
|
7062
|
+
assert.equal(ordinaryFailure.details.workflow?.receipt?.terminalOutcome, undefined);
|
|
7063
|
+
|
|
7064
|
+
mockPi.onCall({ matchArgIncludes: "Child local timeout", delay: 5_000, output: "too late" });
|
|
7065
|
+
const childTimeout = await childLocalExecutor.execute(
|
|
7066
|
+
"workflow-child-local-timeout",
|
|
7067
|
+
{ async: false, workflowScript: `return await runs.run("slow-child", { agent: "echo", task: "Child local timeout", timeoutMs: 150 });` },
|
|
7068
|
+
new AbortController().signal,
|
|
7069
|
+
undefined,
|
|
7070
|
+
makeMinimalCtx(tempDir),
|
|
7071
|
+
);
|
|
7072
|
+
assert.equal(childTimeout.isError, true);
|
|
7073
|
+
assert.equal(childTimeout.details.workflow?.receipt?.terminalOutcome, undefined);
|
|
7074
|
+
assert.deepEqual(childTimeout.details.workflow?.receipt?.entries["slow-child"]?.terminalOutcome, { state: "partial", reason: "timeout" });
|
|
5808
7075
|
});
|
|
5809
7076
|
|
|
5810
7077
|
it("runs omitted async launches in the background when the global default is enabled", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
@@ -5845,7 +7112,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5845
7112
|
makeAgent("echo", {
|
|
5846
7113
|
defaultAsync: true,
|
|
5847
7114
|
defaultTimeoutMs: 2_000,
|
|
5848
|
-
defaultTurnBudget: { maxTurns: 4, graceTurns: 2 },
|
|
5849
7115
|
}),
|
|
5850
7116
|
]);
|
|
5851
7117
|
|
|
@@ -5861,7 +7127,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5861
7127
|
assert.match(result.content[0]?.text ?? "", /Async:/);
|
|
5862
7128
|
assert.equal(typeof result.details?.asyncId, "string");
|
|
5863
7129
|
assert.equal(result.details?.timeoutMs, 2_000);
|
|
5864
|
-
assert.deepEqual(result.details?.turnBudget, { maxTurns: 4, graceTurns: 2 });
|
|
5865
7130
|
});
|
|
5866
7131
|
|
|
5867
7132
|
it("applies agent acceptance defaults and lets explicit calls override them", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
@@ -5920,7 +7185,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5920
7185
|
makeAgent("echo", {
|
|
5921
7186
|
defaultAsync: true,
|
|
5922
7187
|
defaultTimeoutMs: 1,
|
|
5923
|
-
defaultTurnBudget: { maxTurns: 1, graceTurns: 0 },
|
|
5924
7188
|
}),
|
|
5925
7189
|
]);
|
|
5926
7190
|
|
|
@@ -5931,7 +7195,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
5931
7195
|
task: "Task",
|
|
5932
7196
|
async: false,
|
|
5933
7197
|
timeoutMs: 2_000,
|
|
5934
|
-
turnBudget: { maxTurns: 4, graceTurns: 2 },
|
|
5935
7198
|
},
|
|
5936
7199
|
new AbortController().signal,
|
|
5937
7200
|
undefined,
|
|
@@ -6055,7 +7318,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
6055
7318
|
});
|
|
6056
7319
|
});
|
|
6057
7320
|
|
|
6058
|
-
it("passes fanout routing env only when
|
|
7321
|
+
it("passes fanout routing env only when nested fanout is explicitly authorized", async () => {
|
|
6059
7322
|
const envKeys = [
|
|
6060
7323
|
SUBAGENT_FANOUT_CHILD_ENV,
|
|
6061
7324
|
SUBAGENT_PARENT_EVENT_SINK_ENV,
|
|
@@ -6082,6 +7345,18 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
6082
7345
|
SELESAI_SUBAGENT_PARENT_CHILD_INDEX: "2",
|
|
6083
7346
|
});
|
|
6084
7347
|
|
|
7348
|
+
mockPi.onCall({ echoEnv: envKeys });
|
|
7349
|
+
const inheritedToolAgents = [makeAgent("inherited-delegator", { allowNestedSubagents: true })];
|
|
7350
|
+
const inheritedToolFanout = await runSync(tempDir, inheritedToolAgents, "inherited-delegator", "Task", { runId: "inherited-tool-fanout", index: 3 });
|
|
7351
|
+
assert.equal(inheritedToolFanout.exitCode, 0);
|
|
7352
|
+
assert.deepEqual(JSON.parse(inheritedToolFanout.finalOutput ?? "{}"), {
|
|
7353
|
+
SELESAI_SUBAGENT_FANOUT_CHILD: "1",
|
|
7354
|
+
SELESAI_SUBAGENT_PARENT_EVENT_SINK: "/tmp/inherited/events.jsonl",
|
|
7355
|
+
SELESAI_SUBAGENT_PARENT_CONTROL_INBOX: "/tmp/inherited/control",
|
|
7356
|
+
SELESAI_SUBAGENT_PARENT_RUN_ID: "inherited-tool-fanout",
|
|
7357
|
+
SELESAI_SUBAGENT_PARENT_CHILD_INDEX: "3",
|
|
7358
|
+
});
|
|
7359
|
+
|
|
6085
7360
|
mockPi.onCall({ echoEnv: envKeys });
|
|
6086
7361
|
const nonFanoutAgents = [makeAgent("worker", { tools: ["read"] })];
|
|
6087
7362
|
const nonFanout = await runSync(tempDir, nonFanoutAgents, "worker", "Task", { runId: "non-fanout-run" });
|
|
@@ -6366,85 +7641,27 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
6366
7641
|
assert.equal(result.progress.status, "failed");
|
|
6367
7642
|
});
|
|
6368
7643
|
|
|
6369
|
-
it("
|
|
7644
|
+
it("ignores legacy turn-budget options without prompt injection or termination", async () => {
|
|
6370
7645
|
mockPi.onCall({
|
|
6371
7646
|
jsonl: [
|
|
6372
|
-
mockAssistantMessage("
|
|
6373
|
-
mockAssistantMessage("
|
|
6374
|
-
|
|
6375
|
-
});
|
|
6376
|
-
const agents = makeAgentConfigs(["worker"]);
|
|
6377
|
-
|
|
6378
|
-
const result = await runSync(tempDir, agents, "worker", "Use the final grace turn to wrap up.", {
|
|
6379
|
-
turnBudget: { maxTurns: 1, graceTurns: 1 },
|
|
6380
|
-
runId: "foreground-turn-budget-soft",
|
|
6381
|
-
});
|
|
6382
|
-
|
|
6383
|
-
assert.equal(result.exitCode, 0);
|
|
6384
|
-
assert.equal(result.turnBudgetExceeded, undefined);
|
|
6385
|
-
assert.equal(result.wrapUpRequested, true);
|
|
6386
|
-
assert.equal(result.turnBudget?.outcome, "wrap-up-requested");
|
|
6387
|
-
assert.equal(result.turnBudget?.turnCount, 2);
|
|
6388
|
-
assert.match(result.finalOutput ?? "", /Turn budget wrap-up was requested after 1 assistant turn/);
|
|
6389
|
-
assert.match(result.finalOutput ?? "", /final wrapped output/);
|
|
6390
|
-
});
|
|
6391
|
-
|
|
6392
|
-
it("preserves a clean foreground completion after turn-budget work defers", async () => {
|
|
6393
|
-
mockPi.onCall({
|
|
6394
|
-
steps: [
|
|
6395
|
-
{
|
|
6396
|
-
jsonl: [
|
|
6397
|
-
mockAssistantMessage("starting required tool work", "tool_use"),
|
|
6398
|
-
events.toolStart("bash", { command: "node build.mjs" }),
|
|
6399
|
-
],
|
|
6400
|
-
},
|
|
6401
|
-
{
|
|
6402
|
-
delay: 500,
|
|
6403
|
-
jsonl: [
|
|
6404
|
-
events.toolResult("bash", "build completed"),
|
|
6405
|
-
events.toolEnd("bash"),
|
|
6406
|
-
mockAssistantMessage("safe assistant boundary reached", "stop"),
|
|
6407
|
-
],
|
|
6408
|
-
},
|
|
7647
|
+
mockAssistantMessage("first turn", "tool_use"),
|
|
7648
|
+
mockAssistantMessage("second turn", "tool_use"),
|
|
7649
|
+
mockAssistantMessage("completed normally", "stop"),
|
|
6409
7650
|
],
|
|
6410
7651
|
});
|
|
6411
|
-
const
|
|
6412
|
-
|
|
6413
|
-
turnBudget?: { outcome?: string; terminationDeferredAtTurn?: number };
|
|
6414
|
-
turnBudgetExceeded?: boolean;
|
|
6415
|
-
error?: string;
|
|
6416
|
-
currentTool?: string;
|
|
6417
|
-
status?: string;
|
|
6418
|
-
}> = [];
|
|
6419
|
-
|
|
6420
|
-
const result = await runSync(tempDir, agents, "worker", "Finish active tool work before enforcing the hard limit.", {
|
|
7652
|
+
const legacyOptions = {
|
|
7653
|
+
runId: "foreground-legacy-turn-budget",
|
|
6421
7654
|
turnBudget: { maxTurns: 1, graceTurns: 0 },
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
snapshots.push({
|
|
6427
|
-
turnBudget: current?.turnBudget,
|
|
6428
|
-
turnBudgetExceeded: current?.turnBudgetExceeded,
|
|
6429
|
-
error: current?.error,
|
|
6430
|
-
currentTool: progress?.currentTool,
|
|
6431
|
-
status: progress?.status,
|
|
6432
|
-
});
|
|
6433
|
-
},
|
|
6434
|
-
});
|
|
7655
|
+
enforceHardTurnLimit: true,
|
|
7656
|
+
} as Parameters<typeof runSync>[4] & { turnBudget: { maxTurns: number; graceTurns: number }; enforceHardTurnLimit: boolean };
|
|
7657
|
+
|
|
7658
|
+
const result = await runSync(tempDir, makeAgentConfigs(["worker"]), "worker", "Complete normally.", legacyOptions);
|
|
6435
7659
|
|
|
6436
|
-
const duringTool = snapshots.find((snapshot) => snapshot.turnBudget?.outcome === "termination-deferred" && snapshot.currentTool === "bash");
|
|
6437
|
-
assert.ok(duringTool, "expected a running snapshot with deferred termination and the active tool");
|
|
6438
|
-
assert.equal(duringTool.turnBudget?.terminationDeferredAtTurn, 1);
|
|
6439
|
-
assert.equal(duringTool.turnBudgetExceeded, undefined);
|
|
6440
|
-
assert.equal(duringTool.error, undefined);
|
|
6441
|
-
assert.equal(duringTool.status, "running");
|
|
6442
7660
|
assert.equal(result.exitCode, 0);
|
|
6443
7661
|
assert.equal(result.turnBudgetExceeded, undefined);
|
|
6444
|
-
assert.equal(result.
|
|
6445
|
-
assert.
|
|
6446
|
-
assert.
|
|
6447
|
-
assert.ok(result.messages?.some((message) => message.role === "toolResult" && JSON.stringify(message.content).includes("build completed")));
|
|
7662
|
+
assert.equal(result.wrapUpRequested, undefined);
|
|
7663
|
+
assert.match(result.finalOutput ?? "", /completed normally/);
|
|
7664
|
+
assert.doesNotMatch(readCall().systemPrompts.map((record) => record.text ?? "").join("\n"), /turn budget|wrap up by this budget/i);
|
|
6448
7665
|
});
|
|
6449
7666
|
|
|
6450
7667
|
it("does not run acceptance verification after a foreground timeout", async () => {
|