@wjarka/cezarion 0.10.0-pr30.54
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/LICENSE +21 -0
- package/README.md +838 -0
- package/dist/agent-config/account-identity.d.ts +44 -0
- package/dist/agent-config/account-identity.js +128 -0
- package/dist/agent-config/account-identity.js.map +1 -0
- package/dist/agent-config/catalog.d.ts +72 -0
- package/dist/agent-config/catalog.js +223 -0
- package/dist/agent-config/catalog.js.map +1 -0
- package/dist/agent-config/files.d.ts +46 -0
- package/dist/agent-config/files.js +119 -0
- package/dist/agent-config/files.js.map +1 -0
- package/dist/agent-config/model-settings/claude.d.ts +2 -0
- package/dist/agent-config/model-settings/claude.js +11 -0
- package/dist/agent-config/model-settings/claude.js.map +1 -0
- package/dist/agent-config/model-settings/codex.d.ts +2 -0
- package/dist/agent-config/model-settings/codex.js +16 -0
- package/dist/agent-config/model-settings/codex.js.map +1 -0
- package/dist/agent-config/model-settings/opencode.d.ts +2 -0
- package/dist/agent-config/model-settings/opencode.js +8 -0
- package/dist/agent-config/model-settings/opencode.js.map +1 -0
- package/dist/agent-config/model-settings/pi.d.ts +12 -0
- package/dist/agent-config/model-settings/pi.js +18 -0
- package/dist/agent-config/model-settings/pi.js.map +1 -0
- package/dist/agent-config/model-settings/shared.d.ts +9 -0
- package/dist/agent-config/model-settings/shared.js +93 -0
- package/dist/agent-config/model-settings/shared.js.map +1 -0
- package/dist/agent-config/model-settings/types.d.ts +9 -0
- package/dist/agent-config/model-settings/types.js +2 -0
- package/dist/agent-config/model-settings/types.js.map +1 -0
- package/dist/agent-config/models.d.ts +9 -0
- package/dist/agent-config/models.js +32 -0
- package/dist/agent-config/models.js.map +1 -0
- package/dist/agent-config/seed.d.ts +7 -0
- package/dist/agent-config/seed.js +90 -0
- package/dist/agent-config/seed.js.map +1 -0
- package/dist/agent-config/service.d.ts +44 -0
- package/dist/agent-config/service.js +71 -0
- package/dist/agent-config/service.js.map +1 -0
- package/dist/agent-config/validate.d.ts +21 -0
- package/dist/agent-config/validate.js +76 -0
- package/dist/agent-config/validate.js.map +1 -0
- package/dist/automations/coordinator.d.ts +26 -0
- package/dist/automations/coordinator.js +66 -0
- package/dist/automations/coordinator.js.map +1 -0
- package/dist/automations/github-poller.d.ts +74 -0
- package/dist/automations/github-poller.js +234 -0
- package/dist/automations/github-poller.js.map +1 -0
- package/dist/automations/scheduler.d.ts +42 -0
- package/dist/automations/scheduler.js +192 -0
- package/dist/automations/scheduler.js.map +1 -0
- package/dist/automations/store.d.ts +65 -0
- package/dist/automations/store.js +298 -0
- package/dist/automations/store.js.map +1 -0
- package/dist/automations/task-template.d.ts +18 -0
- package/dist/automations/task-template.js +95 -0
- package/dist/automations/task-template.js.map +1 -0
- package/dist/automations/types.d.ts +255 -0
- package/dist/automations/types.js +159 -0
- package/dist/automations/types.js.map +1 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +242 -0
- package/dist/config.js.map +1 -0
- package/dist/contract/agent-config.d.ts +153 -0
- package/dist/contract/agent-profiles.d.ts +347 -0
- package/dist/contract/automations.d.ts +935 -0
- package/dist/contract/events.d.ts +104 -0
- package/dist/contract/github.d.ts +556 -0
- package/dist/contract/health.d.ts +102 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.js +1895 -0
- package/dist/contract/projects.d.ts +210 -0
- package/dist/contract/repo.d.ts +234 -0
- package/dist/contract/runs.d.ts +1473 -0
- package/dist/contract/skills.d.ts +232 -0
- package/dist/contract/workflows.d.ts +218 -0
- package/dist/contract/workspace.d.ts +579 -0
- package/dist/core/agent-env.d.ts +31 -0
- package/dist/core/agent-env.js +371 -0
- package/dist/core/agent-env.js.map +1 -0
- package/dist/core/agent-model-policy.d.ts +10 -0
- package/dist/core/agent-model-policy.js +36 -0
- package/dist/core/agent-model-policy.js.map +1 -0
- package/dist/core/agent-profiles.d.ts +62 -0
- package/dist/core/agent-profiles.js +90 -0
- package/dist/core/agent-profiles.js.map +1 -0
- package/dist/core/agent-runner.d.ts +211 -0
- package/dist/core/agent-runner.js +68 -0
- package/dist/core/agent-runner.js.map +1 -0
- package/dist/core/ask.d.ts +105 -0
- package/dist/core/ask.js +187 -0
- package/dist/core/ask.js.map +1 -0
- package/dist/core/backend-detect.d.ts +16 -0
- package/dist/core/backend-detect.js +155 -0
- package/dist/core/backend-detect.js.map +1 -0
- package/dist/core/claude-cli-runner.d.ts +59 -0
- package/dist/core/claude-cli-runner.js +483 -0
- package/dist/core/claude-cli-runner.js.map +1 -0
- package/dist/core/claude-ui-mapper.d.ts +80 -0
- package/dist/core/claude-ui-mapper.js +598 -0
- package/dist/core/claude-ui-mapper.js.map +1 -0
- package/dist/core/codex-app-server-runner.d.ts +41 -0
- package/dist/core/codex-app-server-runner.js +584 -0
- package/dist/core/codex-app-server-runner.js.map +1 -0
- package/dist/core/codex-app-server-transport.d.ts +35 -0
- package/dist/core/codex-app-server-transport.js +150 -0
- package/dist/core/codex-app-server-transport.js.map +1 -0
- package/dist/core/codex-model-catalog.d.ts +10 -0
- package/dist/core/codex-model-catalog.js +110 -0
- package/dist/core/codex-model-catalog.js.map +1 -0
- package/dist/core/codex-ui-mapper.d.ts +112 -0
- package/dist/core/codex-ui-mapper.js +766 -0
- package/dist/core/codex-ui-mapper.js.map +1 -0
- package/dist/core/model-identity.d.ts +125 -0
- package/dist/core/model-identity.js +132 -0
- package/dist/core/model-identity.js.map +1 -0
- package/dist/core/model-presets.d.ts +21 -0
- package/dist/core/model-presets.js +81 -0
- package/dist/core/model-presets.js.map +1 -0
- package/dist/core/ndjson.d.ts +6 -0
- package/dist/core/ndjson.js +23 -0
- package/dist/core/ndjson.js.map +1 -0
- package/dist/core/opencode-model-catalog.d.ts +32 -0
- package/dist/core/opencode-model-catalog.js +153 -0
- package/dist/core/opencode-model-catalog.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +32 -0
- package/dist/core/opencode-server-runner.js +882 -0
- package/dist/core/opencode-server-runner.js.map +1 -0
- package/dist/core/opencode-ui-mapper.d.ts +137 -0
- package/dist/core/opencode-ui-mapper.js +800 -0
- package/dist/core/opencode-ui-mapper.js.map +1 -0
- package/dist/core/pi-model-catalog.d.ts +31 -0
- package/dist/core/pi-model-catalog.js +152 -0
- package/dist/core/pi-model-catalog.js.map +1 -0
- package/dist/core/pi-runner.d.ts +24 -0
- package/dist/core/pi-runner.js +403 -0
- package/dist/core/pi-runner.js.map +1 -0
- package/dist/core/pi-ui-mapper.d.ts +36 -0
- package/dist/core/pi-ui-mapper.js +304 -0
- package/dist/core/pi-ui-mapper.js.map +1 -0
- package/dist/core/process-usage.d.ts +66 -0
- package/dist/core/process-usage.js +193 -0
- package/dist/core/process-usage.js.map +1 -0
- package/dist/core/provider-auth.d.ts +154 -0
- package/dist/core/provider-auth.js +509 -0
- package/dist/core/provider-auth.js.map +1 -0
- package/dist/core/provider-availability.d.ts +3 -0
- package/dist/core/provider-availability.js +14 -0
- package/dist/core/provider-availability.js.map +1 -0
- package/dist/core/runner-factory.d.ts +8 -0
- package/dist/core/runner-factory.js +25 -0
- package/dist/core/runner-factory.js.map +1 -0
- package/dist/core/runner-model-catalog.d.ts +29 -0
- package/dist/core/runner-model-catalog.js +78 -0
- package/dist/core/runner-model-catalog.js.map +1 -0
- package/dist/core/secret-redaction.d.ts +37 -0
- package/dist/core/secret-redaction.js +115 -0
- package/dist/core/secret-redaction.js.map +1 -0
- package/dist/core/shell-env.d.ts +28 -0
- package/dist/core/shell-env.js +56 -0
- package/dist/core/shell-env.js.map +1 -0
- package/dist/core/tool-display.d.ts +31 -0
- package/dist/core/tool-display.js +179 -0
- package/dist/core/tool-display.js.map +1 -0
- package/dist/core/ui-events.d.ts +335 -0
- package/dist/core/ui-events.js +25 -0
- package/dist/core/ui-events.js.map +1 -0
- package/dist/core/usage-limit.d.ts +42 -0
- package/dist/core/usage-limit.js +209 -0
- package/dist/core/usage-limit.js.map +1 -0
- package/dist/core/usage.d.ts +11 -0
- package/dist/core/usage.js +15 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/core/v1-text-coalescer.d.ts +36 -0
- package/dist/core/v1-text-coalescer.js +72 -0
- package/dist/core/v1-text-coalescer.js.map +1 -0
- package/dist/git-diff-base.d.ts +85 -0
- package/dist/git-diff-base.js +180 -0
- package/dist/git-diff-base.js.map +1 -0
- package/dist/git-refs.d.ts +11 -0
- package/dist/git-refs.js +14 -0
- package/dist/git-refs.js.map +1 -0
- package/dist/git-worktree.d.ts +161 -0
- package/dist/git-worktree.js +508 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +62 -0
- package/dist/handoff.js +154 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +680 -0
- package/dist/index.js.map +1 -0
- package/dist/install-as-command.d.ts +40 -0
- package/dist/install-as-command.js +52 -0
- package/dist/install-as-command.js.map +1 -0
- package/dist/pack-check.d.ts +17 -0
- package/dist/pack-check.js +27 -0
- package/dist/pack-check.js.map +1 -0
- package/dist/paths.d.ts +140 -0
- package/dist/paths.js +192 -0
- package/dist/paths.js.map +1 -0
- package/dist/planner.d.ts +27 -0
- package/dist/planner.js +268 -0
- package/dist/planner.js.map +1 -0
- package/dist/release/manifests.d.ts +81 -0
- package/dist/release/manifests.js +74 -0
- package/dist/release/manifests.js.map +1 -0
- package/dist/release/snapshot.d.ts +67 -0
- package/dist/release/snapshot.js +97 -0
- package/dist/release/snapshot.js.map +1 -0
- package/dist/release/stable.d.ts +38 -0
- package/dist/release/stable.js +55 -0
- package/dist/release/stable.js.map +1 -0
- package/dist/runs/agent-tmpdir.d.ts +41 -0
- package/dist/runs/agent-tmpdir.js +183 -0
- package/dist/runs/agent-tmpdir.js.map +1 -0
- package/dist/runs/auto-name.d.ts +75 -0
- package/dist/runs/auto-name.js +179 -0
- package/dist/runs/auto-name.js.map +1 -0
- package/dist/runs/event-history.d.ts +50 -0
- package/dist/runs/event-history.js +613 -0
- package/dist/runs/event-history.js.map +1 -0
- package/dist/runs/retention.d.ts +60 -0
- package/dist/runs/retention.js +86 -0
- package/dist/runs/retention.js.map +1 -0
- package/dist/runs/review-gate.d.ts +18 -0
- package/dist/runs/review-gate.js +21 -0
- package/dist/runs/review-gate.js.map +1 -0
- package/dist/runs/run-index.d.ts +19 -0
- package/dist/runs/run-index.js +43 -0
- package/dist/runs/run-index.js.map +1 -0
- package/dist/runs/store.d.ts +446 -0
- package/dist/runs/store.js +1118 -0
- package/dist/runs/store.js.map +1 -0
- package/dist/runs/task-markers.d.ts +28 -0
- package/dist/runs/task-markers.js +74 -0
- package/dist/runs/task-markers.js.map +1 -0
- package/dist/runs/task-refs.d.ts +25 -0
- package/dist/runs/task-refs.js +74 -0
- package/dist/runs/task-refs.js.map +1 -0
- package/dist/runs/ui-event-sink.d.ts +81 -0
- package/dist/runs/ui-event-sink.js +210 -0
- package/dist/runs/ui-event-sink.js.map +1 -0
- package/dist/server/app-type.d.ts +21 -0
- package/dist/server/app-type.js +2 -0
- package/dist/server/app-type.js.map +1 -0
- package/dist/server/capabilities.d.ts +70 -0
- package/dist/server/capabilities.js +152 -0
- package/dist/server/capabilities.js.map +1 -0
- package/dist/server/checkout.d.ts +128 -0
- package/dist/server/checkout.js +296 -0
- package/dist/server/checkout.js.map +1 -0
- package/dist/server/forge/github.d.ts +513 -0
- package/dist/server/forge/github.js +2364 -0
- package/dist/server/forge/github.js.map +1 -0
- package/dist/server/forge/index.d.ts +40 -0
- package/dist/server/forge/index.js +72 -0
- package/dist/server/forge/index.js.map +1 -0
- package/dist/server/forge/types.d.ts +242 -0
- package/dist/server/forge/types.js +2 -0
- package/dist/server/forge/types.js.map +1 -0
- package/dist/server/fs-browse.d.ts +105 -0
- package/dist/server/fs-browse.js +182 -0
- package/dist/server/fs-browse.js.map +1 -0
- package/dist/server/git-changes.d.ts +213 -0
- package/dist/server/git-changes.js +555 -0
- package/dist/server/git-changes.js.map +1 -0
- package/dist/server/git.d.ts +28 -0
- package/dist/server/git.js +112 -0
- package/dist/server/git.js.map +1 -0
- package/dist/server/github.d.ts +9 -0
- package/dist/server/github.js +8 -0
- package/dist/server/github.js.map +1 -0
- package/dist/server/launch-key.d.ts +7 -0
- package/dist/server/launch-key.js +33 -0
- package/dist/server/launch-key.js.map +1 -0
- package/dist/server/open-in-app.d.ts +66 -0
- package/dist/server/open-in-app.js +211 -0
- package/dist/server/open-in-app.js.map +1 -0
- package/dist/server/open-in-terminal.d.ts +70 -0
- package/dist/server/open-in-terminal.js +179 -0
- package/dist/server/open-in-terminal.js.map +1 -0
- package/dist/server/pr.d.ts +7 -0
- package/dist/server/pr.js +7 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/project-context.d.ts +120 -0
- package/dist/server/project-context.js +182 -0
- package/dist/server/project-context.js.map +1 -0
- package/dist/server/provider-action-gate.d.ts +8 -0
- package/dist/server/provider-action-gate.js +56 -0
- package/dist/server/provider-action-gate.js.map +1 -0
- package/dist/server/provider-auth-runtime.d.ts +21 -0
- package/dist/server/provider-auth-runtime.js +66 -0
- package/dist/server/provider-auth-runtime.js.map +1 -0
- package/dist/server/server.d.ts +13138 -0
- package/dist/server/server.js +5263 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/static-ui.d.ts +49 -0
- package/dist/server/static-ui.js +92 -0
- package/dist/server/static-ui.js.map +1 -0
- package/dist/server/validators.d.ts +97 -0
- package/dist/server/validators.js +86 -0
- package/dist/server/validators.js.map +1 -0
- package/dist/server/ws.d.ts +85 -0
- package/dist/server/ws.js +218 -0
- package/dist/server/ws.js.map +1 -0
- package/dist/server/wsl.d.ts +25 -0
- package/dist/server/wsl.js +92 -0
- package/dist/server/wsl.js.map +1 -0
- package/dist/server-install/engine.d.ts +50 -0
- package/dist/server-install/engine.js +324 -0
- package/dist/server-install/engine.js.map +1 -0
- package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
- package/dist/server-install/platforms/macosx-ngrok.js +345 -0
- package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
- package/dist/server-install/platforms/ubuntu-vps.d.ts +78 -0
- package/dist/server-install/platforms/ubuntu-vps.js +1016 -0
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
- package/dist/server-install/state.d.ts +52 -0
- package/dist/server-install/state.js +182 -0
- package/dist/server-install/state.js.map +1 -0
- package/dist/server-install/steps.d.ts +109 -0
- package/dist/server-install/steps.js +334 -0
- package/dist/server-install/steps.js.map +1 -0
- package/dist/server-install/strategies.d.ts +4 -0
- package/dist/server-install/strategies.js +20 -0
- package/dist/server-install/strategies.js.map +1 -0
- package/dist/server-install/types.d.ts +287 -0
- package/dist/server-install/types.js +121 -0
- package/dist/server-install/types.js.map +1 -0
- package/dist/server-install/ui.d.ts +42 -0
- package/dist/server-install/ui.js +130 -0
- package/dist/server-install/ui.js.map +1 -0
- package/dist/skills-banner.d.ts +22 -0
- package/dist/skills-banner.js +41 -0
- package/dist/skills-banner.js.map +1 -0
- package/dist/skills-remote.d.ts +84 -0
- package/dist/skills-remote.js +468 -0
- package/dist/skills-remote.js.map +1 -0
- package/dist/skills-update.d.ts +88 -0
- package/dist/skills-update.js +392 -0
- package/dist/skills-update.js.map +1 -0
- package/dist/skills.d.ts +82 -0
- package/dist/skills.js +251 -0
- package/dist/skills.js.map +1 -0
- package/dist/todos.d.ts +48 -0
- package/dist/todos.js +201 -0
- package/dist/todos.js.map +1 -0
- package/dist/ui-state.d.ts +26 -0
- package/dist/ui-state.js +38 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/update-check.d.ts +12 -0
- package/dist/update-check.js +41 -0
- package/dist/update-check.js.map +1 -0
- package/dist/workflows/load.d.ts +15 -0
- package/dist/workflows/load.js +56 -0
- package/dist/workflows/load.js.map +1 -0
- package/dist/workflows/run.d.ts +758 -0
- package/dist/workflows/run.js +3389 -0
- package/dist/workflows/run.js.map +1 -0
- package/dist/workflows/types.d.ts +153 -0
- package/dist/workflows/types.js +212 -0
- package/dist/workflows/types.js.map +1 -0
- package/dist/workspace/agent-accounts.d.ts +153 -0
- package/dist/workspace/agent-accounts.js +304 -0
- package/dist/workspace/agent-accounts.js.map +1 -0
- package/dist/workspace/agent-profiles.d.ts +78 -0
- package/dist/workspace/agent-profiles.js +115 -0
- package/dist/workspace/agent-profiles.js.map +1 -0
- package/dist/workspace/config.d.ts +169 -0
- package/dist/workspace/config.js +379 -0
- package/dist/workspace/config.js.map +1 -0
- package/dist/workspace/migrations.d.ts +45 -0
- package/dist/workspace/migrations.js +147 -0
- package/dist/workspace/migrations.js.map +1 -0
- package/dist/workspace/projects-cli.d.ts +25 -0
- package/dist/workspace/projects-cli.js +172 -0
- package/dist/workspace/projects-cli.js.map +1 -0
- package/dist/workspace/projects.d.ts +109 -0
- package/dist/workspace/projects.js +250 -0
- package/dist/workspace/projects.js.map +1 -0
- package/dist/workspace/semaphore.d.ts +179 -0
- package/dist/workspace/semaphore.js +195 -0
- package/dist/workspace/semaphore.js.map +1 -0
- package/dist/workspace/ui-state.d.ts +34 -0
- package/dist/workspace/ui-state.js +53 -0
- package/dist/workspace/ui-state.js.map +1 -0
- package/package.json +70 -0
- package/scripts/check-pack.mjs +46 -0
- package/scripts/inline-contract.mjs +112 -0
- package/scripts/install-as-command.mjs +120 -0
- package/scripts/mock-claude.mjs +520 -0
- package/scripts/mock-pi-rpc.mjs +83 -0
- package/scripts/sync-readme.mjs +20 -0
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/alert-dialog-B4Ofsgv7.js +1 -0
- package/web/dist/assets/arrow-down-Dy8dup7j.js +1 -0
- package/web/dist/assets/arrow-left-BJaucbhn.js +1 -0
- package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
- package/web/dist/assets/centered-state-duPW0phQ.js +43 -0
- package/web/dist/assets/chevron-right-Be4A5TCS.js +1 -0
- package/web/dist/assets/chunk-BO2N2NFS-DE6qKn3r.js +131 -0
- package/web/dist/assets/collapsible-DdcNBdSk.js +1 -0
- package/web/dist/assets/commit-list-CgvN9vec.js +1 -0
- package/web/dist/assets/compare-variants-CQA2nd65.js +1 -0
- package/web/dist/assets/core-BCsw8oQw.js +12 -0
- package/web/dist/assets/css-BsVw1vtW.js +1 -0
- package/web/dist/assets/diff-B6Agp5Hl.js +3 -0
- package/web/dist/assets/diff-stat-BoXuGkgq.js +1 -0
- package/web/dist/assets/diff-view-CD8XI-fj.js +6 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/dropdown-menu-B3SYw-k5.js +1 -0
- package/web/dist/assets/editable-title-B-hjp5wl.js +1 -0
- package/web/dist/assets/ellipsis-vertical-BYwzw0xl.js +1 -0
- package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
- package/web/dist/assets/file-BRj5eISx.js +1 -0
- package/web/dist/assets/folder-z4oT5981.js +1 -0
- package/web/dist/assets/git-toolbar-owSEkYld.js +1 -0
- package/web/dist/assets/github-Bz2VgRX3.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
- package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
- package/web/dist/assets/html-BY76lXLH.js +1 -0
- package/web/dist/assets/image-preview-h3WFluuy.js +1 -0
- package/web/dist/assets/index-BbcQNT92.css +2 -0
- package/web/dist/assets/index-EtQQQ6Kj.js +7 -0
- package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/json-qhed-kSA.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
- package/web/dist/assets/lib-BQXq3kEf.js +1 -0
- package/web/dist/assets/lib-BxQXEXDF.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/markdown-DqBKYPiB.js +2 -0
- package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
- package/web/dist/assets/new-task-form-BdJKRBJX.js +1 -0
- package/web/dist/assets/pill-BQfmfpYp.js +1 -0
- package/web/dist/assets/project-router-DxgFZY1l.js +1 -0
- package/web/dist/assets/prompt-templates-B6m16gq-.js +15 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
- package/web/dist/assets/refresh-cw-mhSpxf2a.js +1 -0
- package/web/dist/assets/repo-git-CUNRxR3_.js +1 -0
- package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
- package/web/dist/assets/run-diff-DsmmDVfx.js +3 -0
- package/web/dist/assets/run-header-BkhrseLJ.js +1 -0
- package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
- package/web/dist/assets/search-x-BqDMB2g8.js +1 -0
- package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
- package/web/dist/assets/skill-empty-hint-BWEvTggc.js +1 -0
- package/web/dist/assets/skills-9oR_oHse.js +1 -0
- package/web/dist/assets/skills-DwIxnnWO.js +1 -0
- package/web/dist/assets/sparkles-DIoTQpyx.js +1 -0
- package/web/dist/assets/sql-BsFa4tDR.js +1 -0
- package/web/dist/assets/square-terminal-Cc7CuQxc.js +1 -0
- package/web/dist/assets/tab-link-D1fQgpmO.js +1 -0
- package/web/dist/assets/task-changes-BKeLlDpK.js +1 -0
- package/web/dist/assets/task-commits-gcEFZIa5.js +1 -0
- package/web/dist/assets/task-files-BGAqoXTW.js +2 -0
- package/web/dist/assets/task-thread-DT8aUYTS.js +9 -0
- package/web/dist/assets/textarea-BOVbfy5C.js +1 -0
- package/web/dist/assets/thread-loading-CypW73KD.js +1 -0
- package/web/dist/assets/toml-CcmNWLt0.js +1 -0
- package/web/dist/assets/trash-2-CJq6LHWn.js +1 -0
- package/web/dist/assets/triangle-alert-CjDFvU27.js +1 -0
- package/web/dist/assets/tsx-udAQXfEw.js +1 -0
- package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
- package/web/dist/assets/upload-CPDZlJeK.js +1 -0
- package/web/dist/assets/use-desktop-5JsLmi0a.js +1 -0
- package/web/dist/assets/use-submit-shortcut-CHVFevty.js +1 -0
- package/web/dist/assets/utils-DiO6zyJd.js +64 -0
- package/web/dist/assets/workflows-rQDFzWRt.js +11 -0
- package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
- package/web/dist/assets/zoomable-image-COWqb11v.js +1 -0
- package/web/dist/index.html +80 -0
- package/web/dist/open-mercato.svg +11 -0
|
@@ -0,0 +1,3389 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { parseAskMarkerResult, stripAskMarker, } from '../core/ask.js';
|
|
6
|
+
import { onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
|
|
7
|
+
import { parseUsageLimit } from '../core/usage-limit.js';
|
|
8
|
+
import { createRunner } from '../core/runner-factory.js';
|
|
9
|
+
import { modelConflictsWithRunner } from '../core/model-presets.js';
|
|
10
|
+
import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
|
|
11
|
+
import { ModelIdentityError, formatModelIdentity, normalizeModelForBackend, } from '../core/model-identity.js';
|
|
12
|
+
import { HANDOFF_ONLY_INSTRUCTIONS, HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, followupsEnabled, handoffPath, seedHandoffFile, } from '../handoff.js';
|
|
13
|
+
import { todosPath } from '../todos.js';
|
|
14
|
+
import { discoverSkills } from '../skills.js';
|
|
15
|
+
import { materializeSkillDir } from '../skills-remote.js';
|
|
16
|
+
import { seedAgentConfigLocalLayer } from '../agent-config/seed.js';
|
|
17
|
+
import { readAgentModelProvider } from '../agent-config/models.js';
|
|
18
|
+
import { loadConfig, resolveWorktreeRetention } from '../config.js';
|
|
19
|
+
import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeShortstat } from '../git-worktree.js';
|
|
20
|
+
import { getHeadCommit, getRepoInfo } from '../server/git.js';
|
|
21
|
+
import { loadWorkflows } from './load.js';
|
|
22
|
+
import { reclaimWorktrees, rematerializeReclaimedWorktree } from '../runs/retention.js';
|
|
23
|
+
import { AgentTempDirError, agentTmpEnv, removeAgentTmpDir, sweepAgentTmpDirs, } from '../runs/agent-tmpdir.js';
|
|
24
|
+
import { extractTaskRefs, refineTaskRefs, titleRefNumber } from '../runs/task-refs.js';
|
|
25
|
+
import { parseTaskMarkers, stripTaskMarkers } from '../runs/task-markers.js';
|
|
26
|
+
import { autoNamingActive, generateRunName, liveTitleUpdatesEnabled, postValidateTitle } from '../runs/auto-name.js';
|
|
27
|
+
import { reviewGateEnabled } from '../runs/review-gate.js';
|
|
28
|
+
import { resolveProfileEnvForRoot } from '../workspace/agent-profiles.js';
|
|
29
|
+
import { DEFAULT_AGENT_ACCOUNT_ID } from '../workspace/agent-accounts.js';
|
|
30
|
+
import { WorkspaceSemaphore } from '../workspace/semaphore.js';
|
|
31
|
+
import { UiEventSink } from '../runs/ui-event-sink.js';
|
|
32
|
+
import { allowedToolsForStep, chainStepNote, stepKind, } from './types.js';
|
|
33
|
+
const CHECK_OUTPUT_CAP = 20_000;
|
|
34
|
+
async function configuredModelProvider(backend, repoRoot) {
|
|
35
|
+
return readAgentModelProvider(backend, repoRoot).catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
/** An interactive session that hears nothing from the user closes itself. */
|
|
38
|
+
export const IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
39
|
+
/**
|
|
40
|
+
* Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS): a
|
|
41
|
+
* turn whose text ends with `CEZ:DONE` means "goal achieved, nothing to ask" —
|
|
42
|
+
* the session is closed right away instead of parking at `waiting` (#347).
|
|
43
|
+
* Detection runs on the accumulated turn text so delta-streaming backends
|
|
44
|
+
* (codex, opencode) can't split the marker across text events.
|
|
45
|
+
*/
|
|
46
|
+
const DONE_MARKER_RE = /CEZ:DONE\s*$/;
|
|
47
|
+
/**
|
|
48
|
+
* Still-working marker from the agent contract (spec
|
|
49
|
+
* 2026-07-18-subagent-monitoring-status, #490): a turn whose text ends with
|
|
50
|
+
* `CEZ:MONITORING` means "I ended this turn but I'm still working on my own
|
|
51
|
+
* downstream work (a sub-agent / a command I'm monitoring), not waiting on the
|
|
52
|
+
* user" — cezar parks it as `running`/`activity:'monitoring'` instead of
|
|
53
|
+
* `waiting`, so the cockpit shows a non-attention state. `CEZ:DONE` wins if both
|
|
54
|
+
* appear. Detected on accumulated turn text (like `CEZ:DONE`) so delta-streaming
|
|
55
|
+
* backends can't split the marker across text events.
|
|
56
|
+
*/
|
|
57
|
+
const MONITORING_MARKER_RE = /CEZ:MONITORING\s*$/;
|
|
58
|
+
/**
|
|
59
|
+
* Preserve boundaries between complete assistant text blocks while a turn is
|
|
60
|
+
* accumulated for marker parsing. The runners join these same v1 blocks with
|
|
61
|
+
* newlines in `AgentRunResult`; matching that contract here prevents a
|
|
62
|
+
* trailing `CEZ:TITLE=` block from absorbing later commentary (#623).
|
|
63
|
+
*/
|
|
64
|
+
export function appendTurnText(current, next) {
|
|
65
|
+
if (!current)
|
|
66
|
+
return next;
|
|
67
|
+
if (!next)
|
|
68
|
+
return current;
|
|
69
|
+
return `${current}\n${next}`;
|
|
70
|
+
}
|
|
71
|
+
/** Strip a trailing marker from one text event so transcripts stay free of
|
|
72
|
+
* protocol noise. Delta backends may split the marker across events — then
|
|
73
|
+
* it stays visible; detection above is unaffected. */
|
|
74
|
+
function stripDoneMarker(text) {
|
|
75
|
+
return text.replace(/\s*CEZ:DONE\s*$/, '');
|
|
76
|
+
}
|
|
77
|
+
/** Strip a trailing `CEZ:MONITORING` marker from one text event (see
|
|
78
|
+
* `stripDoneMarker`; same delta-backend caveat). */
|
|
79
|
+
function stripMonitoringMarker(text) {
|
|
80
|
+
return text.replace(/\s*CEZ:MONITORING\s*$/, '');
|
|
81
|
+
}
|
|
82
|
+
/** Emit the v2 `ask.requested` event for a parsed marker (the cockpit renders
|
|
83
|
+
* it as an ask card, #473). Returns the minted request id. */
|
|
84
|
+
function emitAskRequested(sink, ask) {
|
|
85
|
+
const requestId = randomUUID();
|
|
86
|
+
sink.handle({ type: 'ask.requested', requestId, questions: ask.questions });
|
|
87
|
+
return requestId;
|
|
88
|
+
}
|
|
89
|
+
/** A persisted, non-fatal explanation for protocol-shaped text that could not
|
|
90
|
+
* become an ask card. Never include the raw payload in this diagnostic. */
|
|
91
|
+
function askMarkerRejection(result) {
|
|
92
|
+
if (result.kind === 'invalid-json') {
|
|
93
|
+
return 'structured question ignored — CEZ:ASK payload is not valid JSON';
|
|
94
|
+
}
|
|
95
|
+
if (result.kind !== 'invalid-structure')
|
|
96
|
+
return undefined;
|
|
97
|
+
const issue = result.issues[0];
|
|
98
|
+
const location = issue?.path.length ? ` at ${issue.path.join('.')}` : '';
|
|
99
|
+
return `structured question ignored — CEZ:ASK payload failed validation${location}${issue ? `: ${issue.message}` : ''}`;
|
|
100
|
+
}
|
|
101
|
+
/** Periodic "cezar autosave" commit in the task worktree (spec 006). */
|
|
102
|
+
export const AUTOSAVE_INTERVAL_MS = 90_000;
|
|
103
|
+
/** The periodic autosave timer is opt-in (#471): off, a task branch carries only the
|
|
104
|
+
* agent's own commits plus the turn-end/pre-PR flushes — no mid-run "cezar autosave"
|
|
105
|
+
* noise interleaving PR history. The flushes (`autosaveCommit` at turn end and before
|
|
106
|
+
* a draft PR) are NOT gated: the branch must still end holding the finished state. */
|
|
107
|
+
export function periodicAutosaveEnabled(env = process.env) {
|
|
108
|
+
return env.CEZ_AUTOSAVE === '1';
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Explicitly opt out of the repository-root lease for runs that execute in the
|
|
112
|
+
* current checkout. This covers explicit worktree opt-out, non-Git degradation,
|
|
113
|
+
* and continuations whose worktree cannot be restored (spec 006 hardening, #438).
|
|
114
|
+
* This is intentionally unsafe: concurrent agents may overwrite each other's
|
|
115
|
+
* files or Git state. Isolated worktree runs are unaffected.
|
|
116
|
+
*/
|
|
117
|
+
export function repositoryRootLockDisabled(env = process.env) {
|
|
118
|
+
return env.CEZ_DISABLE_REPO_LOCK === '1';
|
|
119
|
+
}
|
|
120
|
+
const REPOSITORY_ROOT_LOCK_DISABLED_NOTE = 'repository-root lock disabled by CEZ_DISABLE_REPO_LOCK=1 (shared checkout is unsafe)';
|
|
121
|
+
/** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
|
|
122
|
+
const MAX_AUTO_CONTINUES = 40;
|
|
123
|
+
const AUTONOMOUS_NUDGE = 'Continue working autonomously until the task is fully complete. Do not ask me for confirmation or clarification — make reasonable assumptions and proceed. When everything is done, end the session with your done signal.';
|
|
124
|
+
const MONITORING_WAKE_NUDGE = 'Re-check the downstream work you were monitoring. Continue toward the task goal; emit CEZ:MONITORING again only if it is still pending.';
|
|
125
|
+
/**
|
|
126
|
+
* Auto-resume after a provider usage limit (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
127
|
+
*
|
|
128
|
+
* The wait is the provider's own reset instant plus this grace: resuming AT the boundary races the
|
|
129
|
+
* provider's clock (and its rounding), and one failed resume costs the whole window over again.
|
|
130
|
+
* Thirty seconds is cheap next to five hours and long enough to be past any sane skew.
|
|
131
|
+
*/
|
|
132
|
+
export const AUTO_RESUME_GRACE_MS = 30_000;
|
|
133
|
+
/**
|
|
134
|
+
* Consecutive automatic resumes allowed without a human turn. A resume can only fire after a real
|
|
135
|
+
* reset instant, so this is not a throttle — it is the backstop for the pathological case (a
|
|
136
|
+
* provider that answers "limit reached, retry now" in a loop), and it is deliberately generous
|
|
137
|
+
* enough to sit through a couple of days of five-hour windows.
|
|
138
|
+
*/
|
|
139
|
+
export const MAX_AUTO_RESUMES = 12;
|
|
140
|
+
/**
|
|
141
|
+
* How long a missed deadline stays worth acting on. The promise is "we pick this up when the
|
|
142
|
+
* window reopens" — kept across a restart or an overnight close, which is the case the feature
|
|
143
|
+
* exists for. A day later it is no longer that promise: the user has moved on, and a task
|
|
144
|
+
* springing back to life is a surprise rather than a service. Such a deadline is retired with a
|
|
145
|
+
* note instead of fired, so the only tasks a sweep can revive are ones someone is still waiting on.
|
|
146
|
+
*/
|
|
147
|
+
export const AUTO_RESUME_MISSED_WINDOW_MS = 24 * 60 * 60_000;
|
|
148
|
+
/**
|
|
149
|
+
* How often the queue checks that it is not wedged.
|
|
150
|
+
*
|
|
151
|
+
* A hold is the only thing in the engine that can make an idle queue CORRECT, so it is also the
|
|
152
|
+
* only thing that can make a wedged one look correct. This tick is the way out: cheap (a few
|
|
153
|
+
* in-memory checks), unref'd, and it only ever acts when idling has no justification left.
|
|
154
|
+
*/
|
|
155
|
+
export const QUEUE_WATCHDOG_MS = 60_000;
|
|
156
|
+
/** Shared empty holds for the common "nothing is held" pump — avoids allocating per sweep. */
|
|
157
|
+
const NO_HOLDS = { deadline: new Set(), inFlight: new Set() };
|
|
158
|
+
/**
|
|
159
|
+
* May this run start, given what its account is holding?
|
|
160
|
+
*
|
|
161
|
+
* The two kinds of hold bind different work, and getting that wrong has produced a bug in each
|
|
162
|
+
* direction (spec 2026-08-03-auto-resume-after-usage-limit):
|
|
163
|
+
*
|
|
164
|
+
* - a `deadline` hold means the window is KNOWN shut until an instant, so it blocks everything
|
|
165
|
+
* on that account — resumes included. Exempting them let four resumes fire at once and
|
|
166
|
+
* re-limit one after another, which is the stampede wearing a different hat.
|
|
167
|
+
* - an `inFlight` hold means a resume is testing the window right now and nothing is proven, so
|
|
168
|
+
* it blocks fresh work but not other resumes. Blocking those deadlocked a live workspace.
|
|
169
|
+
*/
|
|
170
|
+
function accountHeldFor(run, holds, fallbackRunner) {
|
|
171
|
+
const key = runAccountKey(run, fallbackRunner);
|
|
172
|
+
if (holds.deadline.has(key))
|
|
173
|
+
return true;
|
|
174
|
+
return holds.inFlight.has(key) && !resumeInFlight(run);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Which agent ACCOUNT a run's work runs on — the thing a provider usage limit actually closes
|
|
178
|
+
* (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
179
|
+
*
|
|
180
|
+
* Backend plus agent account, because those are the two axes a limit is scoped to: a Claude
|
|
181
|
+
* limit must never stall a Codex task, and a second Claude login is a second budget. A record
|
|
182
|
+
* that names no runner has not started yet and will take the configured default, which is what
|
|
183
|
+
* `fallbackRunner` carries; a run that HAS started always carries its resolved runner (execute
|
|
184
|
+
* persists it), and only started runs can be holding.
|
|
185
|
+
*/
|
|
186
|
+
export function runAccountKey(run, fallbackRunner) {
|
|
187
|
+
return `${run.runner ?? fallbackRunner}:${run.agentProfile ?? 'default'}`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Is this run an automatic resume that has not completed a turn yet?
|
|
191
|
+
*
|
|
192
|
+
* Such a run is the work the reopened window is FOR, so the hold must never apply to it — not
|
|
193
|
+
* its own, and not another resume's. Two resumes that hold each other is a deadlock the queue
|
|
194
|
+
* cannot recover from: both sit `queued` with a counter and no deadline, each waiting for the
|
|
195
|
+
* other to prove a window neither will ever get to test. That is the shape a live run produced
|
|
196
|
+
* — two scheduled tasks fired, both went `queued`, and nothing in the workspace moved again.
|
|
197
|
+
*
|
|
198
|
+
* The hold exists to stop NEW work walking into a closed window. A resume is not new work.
|
|
199
|
+
*/
|
|
200
|
+
function resumeInFlight(run) {
|
|
201
|
+
return (run.autoResumeAttempts !== undefined && (run.status === 'queued' || run.status === 'running'));
|
|
202
|
+
}
|
|
203
|
+
const AUTO_RESUME_PROMPT = 'The provider usage limit that interrupted this task has reset. Read the handoff file (CEZ_HANDOFF_FILE) to recover context, then continue the task from where you left off.';
|
|
204
|
+
/**
|
|
205
|
+
* The wake instant as a human reads it — local, to the SECOND, with the zone named. The
|
|
206
|
+
* transcript line is what someone scanning a stalled task actually reads, and "18:41" is not
|
|
207
|
+
* enough to tell a wait that is nearly over from one that just started; the machine-readable ISO
|
|
208
|
+
* copy lives on `RunRecord.autoResumeAt`. Server-side formatting is honest here because cezar is
|
|
209
|
+
* local-first: the process and the browser reading it are the same machine.
|
|
210
|
+
*/
|
|
211
|
+
function formatWakeInstant(at) {
|
|
212
|
+
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long' }).format(at);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The effective "extra" system prompt for a run (spec §protocol v2, R2 2.3):
|
|
216
|
+
* the per-run override (`POST /api/runs` `systemPrompt`) REPLACES the
|
|
217
|
+
* `config.json` default — they are the same knob at two scopes, so the more
|
|
218
|
+
* specific one wins outright; they never concatenate. Whichever wins is
|
|
219
|
+
* ADDITIVE to the skill body and the handoff contract, which always ride
|
|
220
|
+
* along (see `composeSystemPrompt`). Blank strings count as unset.
|
|
221
|
+
*/
|
|
222
|
+
export function resolveExtraSystemPrompt(override, configDefault) {
|
|
223
|
+
return override?.trim() || configDefault?.trim() || undefined;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Joins the parts of one agent step's system prompt in fixed order — skill
|
|
227
|
+
* body (most task-specific), then the run's extra prompt (user guidance, can
|
|
228
|
+
* amend the skill), then the handoff contract (always last, never optional in
|
|
229
|
+
* practice). Blank parts drop out; survivors join with the same `\n\n---\n\n`
|
|
230
|
+
* divider the skill+handoff composition has always used.
|
|
231
|
+
*/
|
|
232
|
+
export function composeSystemPrompt(...parts) {
|
|
233
|
+
return parts
|
|
234
|
+
.map((p) => p?.trim())
|
|
235
|
+
.filter((p) => Boolean(p))
|
|
236
|
+
.join('\n\n---\n\n');
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The directories a spawned agent may reach outside its worktree: the run-state
|
|
240
|
+
* folder that holds its handoff file, plus its own temp directory when this run
|
|
241
|
+
* got one (#785). Handing an agent a `TMPDIR` its file tools are not allowed to
|
|
242
|
+
* write would trade one silent failure for another, so the two travel together;
|
|
243
|
+
* under `CEZ_AGENT_TMPDIR=0` there is no per-run directory and the list is
|
|
244
|
+
* exactly what it always was.
|
|
245
|
+
*/
|
|
246
|
+
export function agentDirectories(runsDir, env) {
|
|
247
|
+
return env.TMPDIR ? [runsDir, env.TMPDIR] : [runsDir];
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Materialized pasted attachment: the on-disk name/serving-URL pair the
|
|
251
|
+
* transcript already used, plus the absolute path that lets the agent
|
|
252
|
+
* operate on the file itself — save it, `cp` it, attach it to a GitHub
|
|
253
|
+
* issue/PR (#357). `path` is only ever an absolute path under
|
|
254
|
+
* `.ai/cezar/runs/<runId>-images/` (see `RunManager.persistImage`).
|
|
255
|
+
*/
|
|
256
|
+
/** Inverse of `persistImage`'s extension mapping (#472) — a persisted attachment
|
|
257
|
+
* is re-encoded from disk at dequeue and needs its media type back. */
|
|
258
|
+
export function mediaTypeFor(name) {
|
|
259
|
+
const ext = name.split('.').pop()?.toLowerCase();
|
|
260
|
+
return ext === 'jpg' ? 'image/jpeg'
|
|
261
|
+
: ext === 'webp' ? 'image/webp'
|
|
262
|
+
: ext === 'gif' ? 'image/gif'
|
|
263
|
+
: 'image/png';
|
|
264
|
+
}
|
|
265
|
+
/** Highest `<prefix>-<n>.<ext>` suffix already present in a run's image dir (#472).
|
|
266
|
+
* `screenshot-*` and `pasted-*` share one numbering space, so this scans both and
|
|
267
|
+
* returns 0 for a missing/empty directory. */
|
|
268
|
+
export function highestImageSeq(dir) {
|
|
269
|
+
try {
|
|
270
|
+
return readdirSync(dir).reduce((max, name) => {
|
|
271
|
+
const m = /^(?:screenshot|pasted)-(\d+)\./.exec(name);
|
|
272
|
+
return m ? Math.max(max, Number(m[1])) : max;
|
|
273
|
+
}, 0);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Plain-text note listing the absolute paths of pasted attachments, appended
|
|
281
|
+
* to the message that carries them (#357). The base64 image blocks stay in
|
|
282
|
+
* the message for the model to *view*; this note is what lets it *use* the
|
|
283
|
+
* files as files — and the only usable reference on backends (codex,
|
|
284
|
+
* opencode) whose `textOf()` drops image blocks before reaching the model.
|
|
285
|
+
*/
|
|
286
|
+
export function pastedAttachmentsText(attachments) {
|
|
287
|
+
const list = attachments.map((a) => `- ${a.path}`).join('\n');
|
|
288
|
+
return (`The user attached ${attachments.length} pasted file${attachments.length > 1 ? 's' : ''}, ` +
|
|
289
|
+
`also saved on disk at:\n${list}\n` +
|
|
290
|
+
`When the task involves saving, uploading, attaching, or transforming the pasted content ` +
|
|
291
|
+
`(e.g. attaching to a GitHub issue/PR, copying into the repo), operate on these files — do ` +
|
|
292
|
+
`not attempt to reconstruct them from the conversation.`);
|
|
293
|
+
}
|
|
294
|
+
/** Same note as `pastedAttachmentsText`, wrapped as a trailing `ContentBlock`
|
|
295
|
+
* ready to append to a message's content array. */
|
|
296
|
+
export function pastedAttachmentsNote(attachments) {
|
|
297
|
+
return { type: 'text', text: pastedAttachmentsText(attachments) };
|
|
298
|
+
}
|
|
299
|
+
/** Variant letters + the fixed diversification hints (spec 010). A runs the
|
|
300
|
+
* task verbatim; B/C get one constant sentence each — zero configuration. */
|
|
301
|
+
export const VARIANT_LETTERS = ['A', 'B', 'C'];
|
|
302
|
+
const VARIANT_HINTS = {
|
|
303
|
+
A: undefined,
|
|
304
|
+
B: 'Approach hint: prefer the minimal, surgical change.',
|
|
305
|
+
C: 'Approach hint: prefer a thorough, structural approach.',
|
|
306
|
+
};
|
|
307
|
+
const RESTART_CONTINUATION_PROMPT = 'The cezar process restarted while you were working on this task. Read the handoff file (CEZ_HANDOFF_FILE) to recover context, then continue the task from where you left off.';
|
|
308
|
+
/**
|
|
309
|
+
* The mini workflow engine: executes a `WorkflowDef` against a repo, one step
|
|
310
|
+
* at a time, persisting every event to the RunStore (which the SSE endpoints
|
|
311
|
+
* relay live to the GUI). No GitHub choreography — agent steps and shell
|
|
312
|
+
* checks with bounded retry loops, plus live sessions: the last agent step
|
|
313
|
+
* stays open for follow-ups (`waiting`) until "finish", idle timeout, or
|
|
314
|
+
* cancel. Runs queue behind the workspace-wide `maxParallel` slots (the shared
|
|
315
|
+
* `WorkspaceSemaphore`, spec 2026-07-20 step 2.5) and each run executes in its
|
|
316
|
+
* own git worktree on a `cez/<id8>` branch (spec 006), autosave-committed at
|
|
317
|
+
* turn end and before a draft PR — plus every 90 s when opted in via
|
|
318
|
+
* CEZ_AUTOSAVE=1 (#471). Each autosave records its trigger in the commit
|
|
319
|
+
* subject, so the always-on flushes are not mistaken for the opt-in timer.
|
|
320
|
+
* The user's working tree is never touched.
|
|
321
|
+
*/
|
|
322
|
+
export class RunManager {
|
|
323
|
+
store;
|
|
324
|
+
repoRoot;
|
|
325
|
+
active = new Map();
|
|
326
|
+
// Queue + `starting` set (spec 006, janitor's pump() pattern): `starting`
|
|
327
|
+
// covers the window between shifting a run off the queue and the run
|
|
328
|
+
// registering in `active`, so parallel-slot counting is never racy.
|
|
329
|
+
queue = [];
|
|
330
|
+
starting = new Set();
|
|
331
|
+
// Runs parked at `waiting` (open session, ball in the user's court). They
|
|
332
|
+
// don't consume a `maxParallel` slot (#347) — an idle claude process costs
|
|
333
|
+
// memory but no tokens, queued work progressing matters more, and the idle
|
|
334
|
+
// timeout already bounds how long a session can sit open. Invariant:
|
|
335
|
+
// `waiting ⊆ active` — always cleared together via dropActive().
|
|
336
|
+
waiting = new Set();
|
|
337
|
+
/** Durable monitoring subset. Only the configured number receives the waiting-slot exemption. */
|
|
338
|
+
monitoring = new Set();
|
|
339
|
+
pendingJobs = new Map();
|
|
340
|
+
/** Interrupted agent turns recovered after a process restart. Unlike an
|
|
341
|
+
* explicit user Continue, these are bulk scheduler work and must re-enter
|
|
342
|
+
* through `pump()` so both workspace and per-project caps are honored. */
|
|
343
|
+
pendingContinuations = new Map();
|
|
344
|
+
/** Per-run image counter behind `pasted-<n>` / `screenshot-<n>` (#472). Lives on
|
|
345
|
+
* the manager rather than the `ActiveRun` so a *queued* run — which has no
|
|
346
|
+
* `ActiveRun` at all — can persist attachments. Seeded lazily from disk. */
|
|
347
|
+
queuedImageSeq = new Map();
|
|
348
|
+
/** Messages that landed in the dequeue → session-open gap (#472), flushed as
|
|
349
|
+
* ordinary follow-up turns the moment the session opens. In-memory only. */
|
|
350
|
+
deferredMessages = new Map();
|
|
351
|
+
/** Armed usage-limit resumes, keyed by run id (spec
|
|
352
|
+
* 2026-08-03-auto-resume-after-usage-limit). The DEADLINE itself lives on the record
|
|
353
|
+
* (`autoResumeAt`) — this map holds only the process-local timer, so a restart rebuilds it
|
|
354
|
+
* from the record rather than losing the wait. Runs here are `failed` and therefore NOT in
|
|
355
|
+
* `active`, which is why the timer cannot live on an `ActiveRun` like the monitoring one. */
|
|
356
|
+
autoResumeTimers = new Map();
|
|
357
|
+
pumping = false;
|
|
358
|
+
/** A pump that arrived while one was in flight — replayed by `pump()`'s own
|
|
359
|
+
* loop so a slot freed mid-sweep is never a lost wakeup. */
|
|
360
|
+
pumpAgain = false;
|
|
361
|
+
/**
|
|
362
|
+
* Runs normally isolate in worktrees and may execute in parallel. When that
|
|
363
|
+
* isolation is unavailable (or explicitly disabled), access to `repoRoot` is
|
|
364
|
+
* serialized by default so two agents cannot edit/revert the same files
|
|
365
|
+
* (#438). `CEZ_DISABLE_REPO_LOCK=1` deliberately bypasses this safety lease.
|
|
366
|
+
*/
|
|
367
|
+
repoRootTail = Promise.resolve();
|
|
368
|
+
/** `.ai/cezar` — where the per-task handoff files and todos.json live. */
|
|
369
|
+
dataDir;
|
|
370
|
+
/** Runs currently being paused by the memory guard — dedupes the ~2 s samples so one breach
|
|
371
|
+
* triggers one pause, not a burst. Cleared in dropActive when the run leaves the registry. */
|
|
372
|
+
memoryPausing = new Set();
|
|
373
|
+
/** Unsubscribe handle for the constructor's `onUsage` subscription — released
|
|
374
|
+
* by dispose() so a torn-down manager stops receiving sampler ticks. */
|
|
375
|
+
offUsage;
|
|
376
|
+
/** The stalled-queue watchdog (see `rescueStalledQueue`). */
|
|
377
|
+
queueWatchdog;
|
|
378
|
+
/** Set by the watchdog for exactly one sweep: ignore the usage-limit hold and make progress. */
|
|
379
|
+
forceNextPump = false;
|
|
380
|
+
/** Runs the watchdog started despite the hold. The spawn-time gate (`requeueWhileHeld`) would
|
|
381
|
+
* otherwise hand them straight back and the rescue would undo itself in a millisecond. */
|
|
382
|
+
forceStarted = new Set();
|
|
383
|
+
/** The workspace-wide parallel-cap semaphore + cached resource config
|
|
384
|
+
* (spec 2026-07-20, step 2.5). Boot constructs ONE and every manager shares
|
|
385
|
+
* it; the private fallback keeps single-manager callers and tests working. */
|
|
386
|
+
semaphore;
|
|
387
|
+
/** Unregister handle for this manager's semaphore membership — released by
|
|
388
|
+
* dispose() so a torn-down project stops counting against the cap. */
|
|
389
|
+
offSemaphore;
|
|
390
|
+
constructor(store, repoRoot, options = {}) {
|
|
391
|
+
this.store = store;
|
|
392
|
+
this.repoRoot = repoRoot;
|
|
393
|
+
this.dataDir = join(repoRoot, '.ai/cezar');
|
|
394
|
+
this.semaphore = options.semaphore ?? new WorkspaceSemaphore();
|
|
395
|
+
this.offSemaphore = this.semaphore.register({
|
|
396
|
+
busySlots: () => this.busySlots(),
|
|
397
|
+
pump: () => this.pump(),
|
|
398
|
+
oldestQueuedAt: () => this.oldestQueuedAt(),
|
|
399
|
+
accountHolds: () => this.accountHolds(),
|
|
400
|
+
});
|
|
401
|
+
// Memory guard (#memory-guard): the shared process-tree sampler already ticks ~every 2 s for
|
|
402
|
+
// the runs table; piggyback on it to enforce the per-task memory ceiling.
|
|
403
|
+
this.offUsage = onUsage((snapshot) => void this.enforceMemoryLimit(snapshot));
|
|
404
|
+
this.queueWatchdog = setInterval(() => void this.rescueStalledQueue(), QUEUE_WATCHDOG_MS);
|
|
405
|
+
this.queueWatchdog.unref?.();
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Release everything this manager owns without touching run records
|
|
409
|
+
* (multi-project workspace, spec 2026-07-20: a removed project's context is
|
|
410
|
+
* torn down while the process lives on). Unsubscribes the shared usage
|
|
411
|
+
* sampler — before dispose() existed that subscription lived for the whole
|
|
412
|
+
* process — clears every per-run idle/autosave timer, releases any held
|
|
413
|
+
* repo-root locks, and empties the queued state so nothing fires later.
|
|
414
|
+
* Live sessions are NOT ended here: run lifecycle stays the caller's policy;
|
|
415
|
+
* dispose only guarantees the manager makes no further moves on its own.
|
|
416
|
+
*/
|
|
417
|
+
dispose() {
|
|
418
|
+
this.offUsage();
|
|
419
|
+
this.offSemaphore();
|
|
420
|
+
clearInterval(this.queueWatchdog);
|
|
421
|
+
for (const [runId, state] of this.active) {
|
|
422
|
+
this.clearIdleTimer(state);
|
|
423
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
424
|
+
this.clearAutosaveTimer(state);
|
|
425
|
+
state.releaseRepoRoot?.();
|
|
426
|
+
state.releaseRepoRoot = undefined;
|
|
427
|
+
}
|
|
428
|
+
for (const timer of this.autoResumeTimers.values())
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
this.autoResumeTimers.clear();
|
|
431
|
+
this.active.clear();
|
|
432
|
+
this.waiting.clear();
|
|
433
|
+
this.starting.clear();
|
|
434
|
+
this.queue.length = 0;
|
|
435
|
+
this.pendingJobs.clear();
|
|
436
|
+
this.pendingContinuations.clear();
|
|
437
|
+
this.memoryPausing.clear();
|
|
438
|
+
this.lastNamerKey.clear();
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Pause any active run whose whole process tree exceeds the WORKSPACE
|
|
442
|
+
* `resources.memoryLimitMb`, freeing its slot so the queue advances
|
|
443
|
+
* (#memory-guard). "Pause" closes the session — freeing the tree's
|
|
444
|
+
* memory — and leaves the run resumable via Continue; a loud warning explains why. No-op when
|
|
445
|
+
* no limit is set or the sampler has no data (e.g. `ps`/PowerShell unavailable).
|
|
446
|
+
*/
|
|
447
|
+
async enforceMemoryLimit(snapshot) {
|
|
448
|
+
// The sampler is module-global (one `ps` for the whole process), so with
|
|
449
|
+
// multiple projects a snapshot carries EVERY project's runs. Act only on
|
|
450
|
+
// rows this manager owns (multi-project spec, step 2.4).
|
|
451
|
+
const runIds = Object.keys(snapshot).filter((runId) => this.active.has(runId));
|
|
452
|
+
if (runIds.length === 0)
|
|
453
|
+
return;
|
|
454
|
+
// Workspace limit from the shared semaphore's in-memory cache (step 2.5:
|
|
455
|
+
// refreshed at boot and on PUT /api/workspace/config — never N per-tick
|
|
456
|
+
// file reads across N projects). Legacy per-repo `memoryLimitMb` keys are
|
|
457
|
+
// ignored post-migration.
|
|
458
|
+
const limitMb = this.semaphore.memoryLimitMb();
|
|
459
|
+
if (!limitMb || limitMb <= 0)
|
|
460
|
+
return;
|
|
461
|
+
const limitBytes = limitMb * 1024 * 1024;
|
|
462
|
+
for (const runId of runIds) {
|
|
463
|
+
const usage = snapshot[runId];
|
|
464
|
+
if (!usage || usage.rssBytes <= limitBytes)
|
|
465
|
+
continue;
|
|
466
|
+
if (this.memoryPausing.has(runId))
|
|
467
|
+
continue;
|
|
468
|
+
const state = this.active.get(runId);
|
|
469
|
+
if (!state?.session?.open || state.cancelled)
|
|
470
|
+
continue;
|
|
471
|
+
this.memoryPausing.add(runId);
|
|
472
|
+
const usedMb = Math.round(usage.rssBytes / (1024 * 1024));
|
|
473
|
+
this.store.appendEvent(runId, {
|
|
474
|
+
type: 'note',
|
|
475
|
+
message: `⚠ memory limit exceeded — this task's process tree is using ${usedMb} MiB (limit ${limitMb} MiB). Pausing it and letting the next queued task run; resume it with Continue.`,
|
|
476
|
+
});
|
|
477
|
+
this.store.appendEvent(runId, {
|
|
478
|
+
type: 'lifecycle',
|
|
479
|
+
message: `paused — memory limit exceeded (${usedMb} MiB > ${limitMb} MiB)`,
|
|
480
|
+
});
|
|
481
|
+
// Closing the session frees the tree and lets the normal exit path settle the run and
|
|
482
|
+
// pump the queue. Suppress autonomous auto-continue so the pause actually holds.
|
|
483
|
+
state.autonomous = false;
|
|
484
|
+
this.clearIdleTimer(state);
|
|
485
|
+
state.session.end();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** Env the spawned claude gets so the agent can find its handoff file and
|
|
489
|
+
* the global inbox (spec 007; the inbox only when the run opted in).
|
|
490
|
+
*
|
|
491
|
+
* `CEZ_TODOS_FILE` is set to `''` rather than omitted when follow-ups are
|
|
492
|
+
* off: runners spawn with `{ ...process.env, ...spec.env }`, so omitting the
|
|
493
|
+
* key would let a value inherited from *this* process through — a nested
|
|
494
|
+
* cezar (an agent running `cez serve`/`cez run`/the test suite) would then
|
|
495
|
+
* write follow-ups into the parent's inbox despite the opt-out. Empty is the
|
|
496
|
+
* established "absent" spelling — consumers guard with `if (todosFile)`.
|
|
497
|
+
*
|
|
498
|
+
* `TMPDIR`/`TEMP`/`TMP` (#785) point at this run's own scratch directory
|
|
499
|
+
* instead of the machine-wide one every agent used to share. Created and
|
|
500
|
+
* write-probed here, on the last common path before a spawn, so an unusable
|
|
501
|
+
* temp directory throws `AgentTempDirError` at the caller rather than
|
|
502
|
+
* turning into empty command output inside a running agent. */
|
|
503
|
+
agentEnv(runId, generateFollowups = true) {
|
|
504
|
+
return {
|
|
505
|
+
CEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
|
|
506
|
+
CEZ_TASK_ID: runId,
|
|
507
|
+
CEZ_TODOS_FILE: generateFollowups ? todosPath(this.dataDir) : '',
|
|
508
|
+
...agentTmpEnv(this.dataDir, runId),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* `agentEnv` plus the agent-account variable for the profile this STEP runs under (spec
|
|
513
|
+
* 2026-07-29-agent-profiles), and the id it resolved to so the caller can record it.
|
|
514
|
+
*
|
|
515
|
+
* Resolved per step, not per run, because a workflow can mix backends: an override naming a
|
|
516
|
+
* Claude account says nothing about which Codex account a codex step should use. Resolution
|
|
517
|
+
* order, most specific first:
|
|
518
|
+
*
|
|
519
|
+
* 1. the step's ALREADY-RECORDED `profileId` — a resume or Continue must reattach to the
|
|
520
|
+
* account that created the session, whatever the project has since been switched to;
|
|
521
|
+
* 2. the run's composer override, but only for steps on the run's own runner;
|
|
522
|
+
* 3. the project's stored selection, and failing that the discovered default.
|
|
523
|
+
*
|
|
524
|
+
* Read fresh every time. `~/.cezar/config.json` is shared by every cezar process on this
|
|
525
|
+
* machine, so a cached snapshot is a staleness bug, and one small JSON read is free next to
|
|
526
|
+
* spawning a CLI. Never throws: an unreadable home degrades to the default profile, which is
|
|
527
|
+
* exactly the behaviour that predates profiles.
|
|
528
|
+
*/
|
|
529
|
+
async agentEnvForStep(runId, backend, options = {}) {
|
|
530
|
+
const run = this.store.getRun(runId);
|
|
531
|
+
const profileId = options.recordedProfileId
|
|
532
|
+
?? (backend === (run?.runner ?? 'claude') ? run?.agentProfile : undefined);
|
|
533
|
+
const resolved = await resolveProfileEnvForRoot(this.repoRoot, backend, profileId);
|
|
534
|
+
return {
|
|
535
|
+
env: { ...this.agentEnv(runId, options.generateFollowups), ...resolved.env },
|
|
536
|
+
profileId: resolved.profile.id,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
startRun(workflow, input, group) {
|
|
540
|
+
// Sanitize at the manager boundary so CLI runs, workflows, variants, and
|
|
541
|
+
// direct callers cannot bypass the HTTP policy.
|
|
542
|
+
const effectiveInput = agentModelsLocked(this.repoRoot)
|
|
543
|
+
? { ...input, model: undefined }
|
|
544
|
+
: input;
|
|
545
|
+
const run = this.store.createRun({
|
|
546
|
+
title: makeRunTitle(input.task, workflow) + (group ? ` (${group.variant})` : ''),
|
|
547
|
+
workflow: workflow.name,
|
|
548
|
+
task: input.task,
|
|
549
|
+
model: effectiveInput.model,
|
|
550
|
+
runner: input.runner,
|
|
551
|
+
// The composer's per-task account (spec 2026-07-29-agent-profiles). Persisted at creation
|
|
552
|
+
// so a queued run picks it up at dequeue and every later resume reads the same answer.
|
|
553
|
+
agentProfile: input.agentProfile,
|
|
554
|
+
// The global inbox is the ceiling on the per-run flag (#471). Enforced here rather than
|
|
555
|
+
// at the HTTP route because `cezar run`, the inbox's own "▶ Run" and variants all reach
|
|
556
|
+
// startRun directly — a route-level gate would leave those writing todos.json.
|
|
557
|
+
generateFollowups: followupsEnabled() ? input.generateFollowups : false,
|
|
558
|
+
// Persist autonomy on the record (#489) so the terminal review gate
|
|
559
|
+
// (`settleSuccess`) and the group-pick winner-park can honor it — mid-run
|
|
560
|
+
// auto-nudge reads `input.autonomous` (`execute`), but the record is the
|
|
561
|
+
// only source those after-the-fact consumers have.
|
|
562
|
+
autonomous: input.autonomous === true,
|
|
563
|
+
// Persist the explicit opt-out so queued-run restart recovery and the
|
|
564
|
+
// session Git routes can distinguish it from a removed isolated worktree.
|
|
565
|
+
worktree: !group && input.worktree === false ? false : undefined,
|
|
566
|
+
groupId: group?.groupId,
|
|
567
|
+
variant: group?.variant,
|
|
568
|
+
steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
|
|
569
|
+
});
|
|
570
|
+
// Persist the full definition so a queued run survives a restart (#367) —
|
|
571
|
+
// ad-hoc "(planned)" chains exist nowhere else to re-resolve from.
|
|
572
|
+
this.store.updateRun(run.id, { workflowDef: workflow });
|
|
573
|
+
// Initial pasted images must be visible while the run is still queued (#612),
|
|
574
|
+
// and must survive a restart before a slot opens. Persist them before the job
|
|
575
|
+
// enters `pendingJobs`; `hydrateQueuedInput` reconstructs their content blocks
|
|
576
|
+
// from these URLs when a recovered run eventually starts.
|
|
577
|
+
if (input.images?.length) {
|
|
578
|
+
const persisted = input.images
|
|
579
|
+
.filter((b) => b.type === 'image')
|
|
580
|
+
.map((b) => this.persistImage(run.id, b.source.media_type, b.source.data, 'pasted'))
|
|
581
|
+
.filter((saved) => saved !== null);
|
|
582
|
+
if (persisted.length) {
|
|
583
|
+
this.store.updateRun(run.id, { taskImages: persisted.map((saved) => saved.url) });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Step-0 reference extraction (task auto-naming spec): the regex layer's
|
|
587
|
+
// numbers persist immediately; the namer may add the kind it verified later.
|
|
588
|
+
const skillHint = workflow.steps.find((s) => stepKind(s) === 'agent' && s.skill)?.skill?.trim();
|
|
589
|
+
const refs = refineTaskRefs(extractTaskRefs(input.task), skillHint);
|
|
590
|
+
if (refs.prNumber !== undefined || refs.issueNumber !== undefined) {
|
|
591
|
+
this.store.updateRun(run.id, {
|
|
592
|
+
...(refs.prNumber !== undefined ? { prNumber: refs.prNumber } : {}),
|
|
593
|
+
...(refs.issueNumber !== undefined ? { issueNumber: refs.issueNumber } : {}),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
// Fire-and-forget LLM naming (task auto-naming spec): the heuristic title
|
|
597
|
+
// above shows instantly; the namer's short title replaces it when (and if)
|
|
598
|
+
// the model answers. Never awaited, never fails the run.
|
|
599
|
+
void this.autoNameRun(run.id, skillHint, input.task);
|
|
600
|
+
this.pendingJobs.set(run.id, { workflow, input: effectiveInput });
|
|
601
|
+
this.queue.push(run.id);
|
|
602
|
+
void this.pump();
|
|
603
|
+
return run;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Parallel variants (spec 010): N runs of the same workflow on the same
|
|
607
|
+
* task, sharing a groupId. Variant A gets the task verbatim; B and C get a
|
|
608
|
+
* fixed one-line approach hint appended to the *task input* (not the step
|
|
609
|
+
* template), so diversification works with any workflow. The normal queue
|
|
610
|
+
* applies — with maxParallel=2 a third variant simply waits.
|
|
611
|
+
*/
|
|
612
|
+
startVariants(workflow, input, count) {
|
|
613
|
+
const groupId = randomUUID();
|
|
614
|
+
return VARIANT_LETTERS.slice(0, Math.min(Math.max(count, 1), VARIANT_LETTERS.length)).map((variant) => {
|
|
615
|
+
const hint = VARIANT_HINTS[variant];
|
|
616
|
+
const task = hint ? `${input.task}\n\n${hint}` : input.task;
|
|
617
|
+
return this.startRun(workflow, { ...input, task, worktree: undefined }, { groupId, variant });
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Slots this manager holds against the workspace-wide cap. `waiting` runs
|
|
622
|
+
* don't hold a slot (#347): an idle claude process costs memory but no
|
|
623
|
+
* tokens, queued work progressing matters more, and the idle timeout already
|
|
624
|
+
* bounds how long a session can sit open. Because the exemption lives HERE —
|
|
625
|
+
* in the count, not in any acquire path — a message into a `waiting` run
|
|
626
|
+
* (sendMessage) resumes it immediately even when that momentarily exceeds
|
|
627
|
+
* `maxParallel`, including when other projects saturate the cap.
|
|
628
|
+
*/
|
|
629
|
+
busySlots() {
|
|
630
|
+
const ordinaryWaiting = this.waiting.size - this.monitoring.size;
|
|
631
|
+
const exemptMonitoring = Math.min(this.monitoring.size, this.semaphore.maxMonitoringSessions());
|
|
632
|
+
return this.active.size + this.starting.size - ordinaryWaiting - exemptMonitoring;
|
|
633
|
+
}
|
|
634
|
+
/** Epoch ms of this manager's oldest queued run (the semaphore's fairness
|
|
635
|
+
* key when a freed slot is broadcast), or null when nothing is queued.
|
|
636
|
+
* `queue` is FIFO — `startRun` pushes and `recover()` re-queues by
|
|
637
|
+
* `createdAt` — so the head is the oldest. */
|
|
638
|
+
oldestQueuedAt() {
|
|
639
|
+
const head = this.queue[0];
|
|
640
|
+
if (!head)
|
|
641
|
+
return null;
|
|
642
|
+
const createdAt = this.store.getRun(head)?.createdAt;
|
|
643
|
+
const ms = createdAt ? Date.parse(createdAt) : Number.NaN;
|
|
644
|
+
return Number.isNaN(ms) ? null : ms;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* A slot this manager held just came free. Pump the whole WORKSPACE, not
|
|
648
|
+
* just this manager: `maxParallel` is counted across every project, so the
|
|
649
|
+
* run that should take the slot is the workspace's oldest queued one — which
|
|
650
|
+
* usually sits in another project's queue. Pumping only `this` is what left
|
|
651
|
+
* a queued run in project B stuck at `queued` while project A's runs came
|
|
652
|
+
* and went. `release()` pumps this manager too, so it replaces the local
|
|
653
|
+
* `pump()` at every slot-freeing transition.
|
|
654
|
+
*/
|
|
655
|
+
releaseSlot() {
|
|
656
|
+
void this.semaphore.release();
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Start queued runs while parallel slots are free. A run starts only under
|
|
660
|
+
* BOTH ceilings: the WORKSPACE `resources.maxParallel` (default 2, counted
|
|
661
|
+
* across every manager — spec 2026-07-20, step 2.5) AND this project's own
|
|
662
|
+
* per-project `maxParallel` when the registry sets one (spec 2026-07-22,
|
|
663
|
+
* inherits the workspace cap when unset). Legacy per-repo `maxParallel` keys
|
|
664
|
+
* are ignored. A non-git directory degrades to 1 sequential run in the repo
|
|
665
|
+
* root (spec 006 degradation rule), which is always the tighter bound.
|
|
666
|
+
*/
|
|
667
|
+
async pump() {
|
|
668
|
+
this.reconcileMonitoringWakeTimers();
|
|
669
|
+
this.reconcileAutoResumes();
|
|
670
|
+
// A pump requested while one is in flight can't just be dropped: the
|
|
671
|
+
// in-flight pass may already have read capacity (it awaits `getRepoInfo`
|
|
672
|
+
// before the first check), so a slot freed in that window would be lost
|
|
673
|
+
// until the next unrelated event. Re-run the sweep instead.
|
|
674
|
+
if (this.pumping) {
|
|
675
|
+
this.pumpAgain = true;
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
this.pumping = true;
|
|
679
|
+
try {
|
|
680
|
+
do {
|
|
681
|
+
this.pumpAgain = false;
|
|
682
|
+
const repo = await getRepoInfo(this.repoRoot);
|
|
683
|
+
const maxParallel = this.semaphore.maxParallel();
|
|
684
|
+
// Per-project ceiling (spec 2026-07-22-per-project-concurrency): this
|
|
685
|
+
// project never runs more than its own configured `maxParallel`; absent
|
|
686
|
+
// an override it equals the workspace cap, so behavior is unchanged.
|
|
687
|
+
const projectMax = this.semaphore.projectMaxParallel(this.repoRoot);
|
|
688
|
+
// `waiting` runs don't hold a slot (#347) — see busySlots(). The check
|
|
689
|
+
// below is the only slot gate: resumes never pass through it. A run
|
|
690
|
+
// starts only under BOTH the workspace cap and this project's ceiling.
|
|
691
|
+
const capacity = () => this.semaphore.busy() < maxParallel &&
|
|
692
|
+
this.busySlots() < projectMax &&
|
|
693
|
+
(repo !== null || this.busySlots() < 1);
|
|
694
|
+
// The usage-limit hold (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
695
|
+
//
|
|
696
|
+
// A limit closes an ACCOUNT, not a run — so starting the next queued task walks it into
|
|
697
|
+
// the same wall. Measured before this gate existed: eight tasks under `maxParallel: 2`
|
|
698
|
+
// all failed within 517 ms, each spawning a CLI (and, outside worktree-opt-out mode, a
|
|
699
|
+
// worktree and a branch) only to be marked `scheduled`. The cap was respected at every
|
|
700
|
+
// instant and was no brake at all, because a doomed run lives ~200 ms.
|
|
701
|
+
//
|
|
702
|
+
// So: while any run on an account is waiting out a limit, nothing new starts on THAT
|
|
703
|
+
// account. Other accounts (a second login, a different backend) keep running — the hold
|
|
704
|
+
// is keyed, not global. The set is derived from the durable records rather than tracked
|
|
705
|
+
// separately, which is what makes it survive a restart, expire on its own, and lift the
|
|
706
|
+
// instant a user cancels a resume.
|
|
707
|
+
// The watchdog's one-shot override — read and cleared here, so a forced sweep never
|
|
708
|
+
// leaks into the next ordinary one.
|
|
709
|
+
const forced = this.forceNextPump;
|
|
710
|
+
this.forceNextPump = false;
|
|
711
|
+
const holds = this.queue.length > 0 && !forced ? this.semaphore.accountHolds() : NO_HOLDS;
|
|
712
|
+
const anyHold = holds.deadline.size > 0 || holds.inFlight.size > 0;
|
|
713
|
+
// Only pay for the config read when something is actually held: a queued record may name
|
|
714
|
+
// no runner, and then the account it would use is the configured default.
|
|
715
|
+
const defaultRunner = anyHold ? (await loadConfig(this.repoRoot)).defaultRunner : undefined;
|
|
716
|
+
while (this.queue.length > 0 && capacity()) {
|
|
717
|
+
// FIFO among the runs that CAN start; a held one keeps its place in the queue rather
|
|
718
|
+
// than being dequeued and re-queued (which would churn its position and its record).
|
|
719
|
+
const next = !anyHold
|
|
720
|
+
? 0
|
|
721
|
+
: this.queue.findIndex((id) => {
|
|
722
|
+
const queued = this.store.getRun(id);
|
|
723
|
+
return !queued || !accountHeldFor(queued, holds, defaultRunner ?? 'claude');
|
|
724
|
+
});
|
|
725
|
+
if (next === -1)
|
|
726
|
+
break; // everything queued is waiting on a held account
|
|
727
|
+
const runId = this.queue.splice(next, 1)[0];
|
|
728
|
+
if (!runId)
|
|
729
|
+
break;
|
|
730
|
+
// A forced sweep has to reach the spawn: the gate inside `execute` asks the same
|
|
731
|
+
// question and would send this run straight back to the queue.
|
|
732
|
+
if (forced)
|
|
733
|
+
this.forceStarted.add(runId);
|
|
734
|
+
const job = this.pendingJobs.get(runId);
|
|
735
|
+
const continuation = this.pendingContinuations.get(runId);
|
|
736
|
+
this.pendingJobs.delete(runId);
|
|
737
|
+
this.pendingContinuations.delete(runId);
|
|
738
|
+
if (!job && !continuation)
|
|
739
|
+
continue;
|
|
740
|
+
this.starting.add(runId);
|
|
741
|
+
if (continuation) {
|
|
742
|
+
const hydrated = this.hydrateQueuedContinuation(runId, continuation);
|
|
743
|
+
void this.runContinuation(runId, hydrated.stepId, hydrated.sessionId, hydrated.backend, hydrated.prompt, hydrated.images, hydrated.persistedImages, hydrated.persistedAttachments).catch((err) => {
|
|
744
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
745
|
+
this.store.updateRun(runId, {
|
|
746
|
+
status: 'failed',
|
|
747
|
+
error: `continue crashed: ${message}`,
|
|
748
|
+
finishedAt: new Date().toISOString(),
|
|
749
|
+
});
|
|
750
|
+
this.starting.delete(runId);
|
|
751
|
+
this.dropActive(runId);
|
|
752
|
+
});
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (!job)
|
|
756
|
+
continue;
|
|
757
|
+
// Rebuild the prompt from the store at the last instant (#472), so an edit
|
|
758
|
+
// or a stacked message that landed while the run waited is honored. Entered
|
|
759
|
+
// in the same synchronous tick as the `pendingJobs.delete` above, so no
|
|
760
|
+
// handler can observe a half-dequeued run.
|
|
761
|
+
const input = this.hydrateQueuedInput(runId, job.input);
|
|
762
|
+
void this.execute(runId, job.workflow, input).catch((err) => {
|
|
763
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
764
|
+
this.store.updateRun(runId, {
|
|
765
|
+
status: 'failed',
|
|
766
|
+
error: `engine crashed: ${message}`,
|
|
767
|
+
finishedAt: new Date().toISOString(),
|
|
768
|
+
});
|
|
769
|
+
const state = this.active.get(runId);
|
|
770
|
+
if (state) {
|
|
771
|
+
this.clearIdleTimer(state);
|
|
772
|
+
this.clearAutosaveTimer(state);
|
|
773
|
+
}
|
|
774
|
+
this.starting.delete(runId);
|
|
775
|
+
this.dropActive(runId);
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
} while (this.pumpAgain);
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
this.pumping = false;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Make one `queued` RECORD executable again — the engine half a queued run needs but does not
|
|
786
|
+
* persist (`pendingJobs` / `pendingContinuations` are process-local, the record is not).
|
|
787
|
+
*
|
|
788
|
+
* Two callers, one path: boot recovery re-adopts everything the previous process was holding,
|
|
789
|
+
* and the queue watchdog re-adopts anything the running process has somehow lost. A queued
|
|
790
|
+
* record with no work item behind it is invisible to `pump()` and would sit there for good,
|
|
791
|
+
* which is the worst failure this engine has — the task is neither running nor failed, just
|
|
792
|
+
* silently never going to happen.
|
|
793
|
+
*
|
|
794
|
+
* A continuation is reconstructed first: its executable details are gone, but the pending
|
|
795
|
+
* `continue-N` step and the session before it are durable, which is enough. Otherwise the
|
|
796
|
+
* workflow is revived from the record. A run that can be neither is failed loudly rather than
|
|
797
|
+
* left in the queue as a ghost.
|
|
798
|
+
*/
|
|
799
|
+
async reviveQueuedRun(run, reason) {
|
|
800
|
+
const queuedContinuation = [...run.steps]
|
|
801
|
+
.reverse()
|
|
802
|
+
.find((step) => step.status === 'pending' && step.id.startsWith('continue-'));
|
|
803
|
+
const sessionStep = queuedContinuation
|
|
804
|
+
? [...run.steps].reverse().find((step) => step.id !== queuedContinuation.id && step.sessionId)
|
|
805
|
+
: undefined;
|
|
806
|
+
if (queuedContinuation && sessionStep?.sessionId) {
|
|
807
|
+
const backend = run.runner ?? 'claude';
|
|
808
|
+
const sessionBackend = sessionStep.backend ?? backend;
|
|
809
|
+
this.pendingContinuations.set(run.id, {
|
|
810
|
+
stepId: queuedContinuation.id,
|
|
811
|
+
sessionId: sessionBackend === backend ? sessionStep.sessionId : undefined,
|
|
812
|
+
backend,
|
|
813
|
+
prompt: RESTART_CONTINUATION_PROMPT,
|
|
814
|
+
images: [],
|
|
815
|
+
});
|
|
816
|
+
this.queue.push(run.id);
|
|
817
|
+
this.store.appendEvent(run.id, {
|
|
818
|
+
type: 'lifecycle',
|
|
819
|
+
message: `${reason} — interrupted continuation re-queued`,
|
|
820
|
+
});
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const workflow = await this.reviveWorkflow(run);
|
|
824
|
+
if (!workflow) {
|
|
825
|
+
this.store.updateRun(run.id, {
|
|
826
|
+
status: 'failed',
|
|
827
|
+
error: 'interrupted — workflow definition not recoverable after a restart',
|
|
828
|
+
finishedAt: new Date().toISOString(),
|
|
829
|
+
});
|
|
830
|
+
this.store.appendEvent(run.id, {
|
|
831
|
+
type: 'lifecycle',
|
|
832
|
+
message: `${reason} — workflow definition not recoverable, task failed`,
|
|
833
|
+
});
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
// Re-apply the inbox ceiling (#471). `execute()` gates again at spawn time, so the agent is
|
|
837
|
+
// safe either way — but a run queued while the inbox was on and recovered after it was
|
|
838
|
+
// switched off would otherwise keep echoing `generateFollowups: true` on a run that
|
|
839
|
+
// demonstrably produced none. Normalize the record, the way startRun does.
|
|
840
|
+
const generateFollowups = followupsEnabled() ? run.generateFollowups : false;
|
|
841
|
+
if (generateFollowups !== run.generateFollowups) {
|
|
842
|
+
this.store.updateRun(run.id, { generateFollowups });
|
|
843
|
+
}
|
|
844
|
+
this.pendingJobs.set(run.id, {
|
|
845
|
+
workflow,
|
|
846
|
+
// Folded through the same helper `pump()` uses (#472) so a restart carries the stack.
|
|
847
|
+
// Idempotent: hydration always composes from `run.task` + the stack, never from an
|
|
848
|
+
// already-folded `input.task`, so re-hydrating at dequeue yields the same string.
|
|
849
|
+
input: this.hydrateQueuedInput(run.id, {
|
|
850
|
+
task: run.task,
|
|
851
|
+
model: run.model,
|
|
852
|
+
runner: run.runner,
|
|
853
|
+
generateFollowups,
|
|
854
|
+
// Re-thread autonomy (#489): the rebuilt input feeds `execute`, whose mid-run auto-nudge
|
|
855
|
+
// reads `input.autonomous`. Without this a recovered autonomous run would run
|
|
856
|
+
// non-autonomously and later wrongly park at `review`.
|
|
857
|
+
autonomous: run.autonomous,
|
|
858
|
+
// Preserve an explicit worktree opt-out across a queued restart.
|
|
859
|
+
worktree: run.worktree,
|
|
860
|
+
}),
|
|
861
|
+
});
|
|
862
|
+
this.queue.push(run.id);
|
|
863
|
+
this.store.appendEvent(run.id, { type: 'lifecycle', message: `${reason} — task re-queued` });
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Startup recovery (#367) — re-adopt runs that were live when the previous
|
|
867
|
+
* cezar process exited (requires the store opened with `keepLive`):
|
|
868
|
+
* - `queued` → back into the queue (FIFO by createdAt), from the persisted
|
|
869
|
+
* workflowDef (or the catalog by name for older records);
|
|
870
|
+
* - `waiting` → the turn was over and the ball was in the user's court —
|
|
871
|
+
* settle exactly like a closed session (review/done, Continue still works);
|
|
872
|
+
* - `running` → mark interrupted, then immediately resume the last agent
|
|
873
|
+
* session via the Continue path, pointing the agent at its handoff file.
|
|
874
|
+
* Call once, before the server starts taking requests.
|
|
875
|
+
*/
|
|
876
|
+
async recover() {
|
|
877
|
+
const live = this.store
|
|
878
|
+
.listRuns()
|
|
879
|
+
.filter((r) => ['queued', 'waiting', 'running'].includes(r.status))
|
|
880
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
881
|
+
// A crash never reaches `dropActive`, so its temp directory (#785) outlived the run.
|
|
882
|
+
// Startup is the one moment we know which runs are still live, so sweep every other
|
|
883
|
+
// per-run directory here — bounded to `<dataDir>/tmp`, never a sibling.
|
|
884
|
+
sweepAgentTmpDirs(this.dataDir, live.map((r) => r.id));
|
|
885
|
+
for (const run of live) {
|
|
886
|
+
if (run.status === 'queued') {
|
|
887
|
+
await this.reviveQueuedRun(run, 'cezar restarted');
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (run.status === 'waiting') {
|
|
891
|
+
for (const step of run.steps) {
|
|
892
|
+
if (step.status === 'waiting' || step.status === 'running') {
|
|
893
|
+
this.store.updateStep(run.id, step.id, { status: 'done', finishedAt: new Date().toISOString() });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
this.store.appendEvent(run.id, {
|
|
897
|
+
type: 'lifecycle',
|
|
898
|
+
message: 'cezar restarted — the open session was settled',
|
|
899
|
+
});
|
|
900
|
+
await this.settleSuccess(run.id);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
// `running`: the process died mid-turn. Mark it interrupted (the state
|
|
904
|
+
// continueRun expects), then pick the work back up from the last session.
|
|
905
|
+
const finishedAt = new Date().toISOString();
|
|
906
|
+
for (const step of run.steps) {
|
|
907
|
+
if (step.status === 'running' || step.status === 'waiting') {
|
|
908
|
+
this.store.updateStep(run.id, step.id, { status: 'failed', finishedAt });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
this.store.updateRun(run.id, {
|
|
912
|
+
status: 'failed',
|
|
913
|
+
error: 'interrupted — cezar process exited during the run',
|
|
914
|
+
finishedAt,
|
|
915
|
+
currentStepId: undefined,
|
|
916
|
+
});
|
|
917
|
+
const resumed = this.continueRun(run.id, {
|
|
918
|
+
text: RESTART_CONTINUATION_PROMPT,
|
|
919
|
+
}, true);
|
|
920
|
+
this.store.appendEvent(run.id, {
|
|
921
|
+
type: 'lifecycle',
|
|
922
|
+
message: resumed.ok
|
|
923
|
+
? 'cezar restarted — resuming the interrupted task from its last session'
|
|
924
|
+
: `cezar restarted — could not resume the interrupted task (${resumed.error ?? 'unknown'})`,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
// Re-arm usage-limit resumes (spec 2026-08-03-auto-resume-after-usage-limit): the wait is
|
|
928
|
+
// routinely longer than a cezar session, so the deadline is durable and the timer is rebuilt
|
|
929
|
+
// from it. `pump()` reconciles again on every sweep, so this is the fast path, not the only
|
|
930
|
+
// one — see `reconcileAutoResumes`.
|
|
931
|
+
this.reconcileAutoResumes();
|
|
932
|
+
void this.pump();
|
|
933
|
+
}
|
|
934
|
+
/** The persisted definition when it looks sane, else the catalog by name. */
|
|
935
|
+
async reviveWorkflow(run) {
|
|
936
|
+
// "Looks sane" is the STORE's job now: it parses `workflowDef` against the definition schema
|
|
937
|
+
// and `.catch`es a def that no longer fits to `undefined`, so anything present here already
|
|
938
|
+
// has the `steps` array the old inline `Array.isArray` check was asking for.
|
|
939
|
+
const def = run.workflowDef;
|
|
940
|
+
if (def)
|
|
941
|
+
return def;
|
|
942
|
+
const { workflows } = await loadWorkflows(this.repoRoot);
|
|
943
|
+
return workflows.find((w) => w.name === run.workflow) ?? null;
|
|
944
|
+
}
|
|
945
|
+
/** Remove a run from the live registries — keeps `waiting ⊆ active`. */
|
|
946
|
+
dropActive(runId) {
|
|
947
|
+
const state = this.active.get(runId);
|
|
948
|
+
state?.releaseRepoRoot?.();
|
|
949
|
+
if (state)
|
|
950
|
+
state.releaseRepoRoot = undefined;
|
|
951
|
+
this.waiting.delete(runId);
|
|
952
|
+
this.monitoring.delete(runId);
|
|
953
|
+
if (state)
|
|
954
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
955
|
+
this.active.delete(runId);
|
|
956
|
+
this.memoryPausing.delete(runId);
|
|
957
|
+
this.lastNamerKey.delete(runId);
|
|
958
|
+
this.forceStarted.delete(runId);
|
|
959
|
+
// The run's slot is gone from busySlots() as of the deletes above — hand it
|
|
960
|
+
// to the workspace's oldest queued run, in ANY project. Every terminal path
|
|
961
|
+
// funnels through here, so this one call covers them all.
|
|
962
|
+
// Same reasoning as retention below — every terminal path funnels through here, so the
|
|
963
|
+
// usage-limit question ("did this run stop because the account is out of window, and when
|
|
964
|
+
// does that window reopen?") is asked once, in one place, off the record the failing path
|
|
965
|
+
// has already written. Nothing to do for any other outcome.
|
|
966
|
+
//
|
|
967
|
+
// BEFORE releasing the slot, and that order is the whole point: `releaseSlot` pumps every
|
|
968
|
+
// manager, and a pump reads the hold off the records. Publishing the schedule afterwards
|
|
969
|
+
// left a window — measured as exactly one extra task — where the queue saw a free slot and
|
|
970
|
+
// an account that looked healthy, and started work that was already doomed.
|
|
971
|
+
this.scheduleAutoResumeIfLimited(runId);
|
|
972
|
+
this.releaseSlot();
|
|
973
|
+
// A run leaving the active registry is a terminal transition (done/review/
|
|
974
|
+
// failed/cancelled) — the one moment the finished-worktree count can grow.
|
|
975
|
+
// Enforce count-based retention (#483) here so a single hook covers every
|
|
976
|
+
// terminal path. Fire-and-forget: retention must never delay or throw into
|
|
977
|
+
// the lifecycle.
|
|
978
|
+
void this.enforceRetention();
|
|
979
|
+
// The run's temp directory (#785) goes on the same terminal transition, and
|
|
980
|
+
// unconditionally — it is scratch, not an artifact, so unlike a worktree
|
|
981
|
+
// there is no keep-count to respect and nothing left to recover from it. A
|
|
982
|
+
// Continue (or an auto-resume) re-creates it through `agentEnv`.
|
|
983
|
+
removeAgentTmpDir(this.dataDir, runId);
|
|
984
|
+
}
|
|
985
|
+
// ---- usage-limit auto-resume (spec 2026-08-03-auto-resume-after-usage-limit) --------------
|
|
986
|
+
/**
|
|
987
|
+
* A run just failed: if the provider said "usage limit, back at T", promise to resume it at
|
|
988
|
+
* `T + AUTO_RESUME_GRACE_MS` instead of leaving the task dead until someone notices.
|
|
989
|
+
*
|
|
990
|
+
* Every refusal below is silent-but-honest — the run stays `failed` with its Continue button,
|
|
991
|
+
* which is exactly the pre-feature behavior — except the safety cap, which says so on the
|
|
992
|
+
* transcript, because a run that stops resuming itself needs to explain why.
|
|
993
|
+
*/
|
|
994
|
+
scheduleAutoResumeIfLimited(runId) {
|
|
995
|
+
if (this.autoResumeTimers.has(runId))
|
|
996
|
+
return; // already promised
|
|
997
|
+
const run = this.store.getRun(runId);
|
|
998
|
+
if (!run || run.status !== 'failed')
|
|
999
|
+
return;
|
|
1000
|
+
// Archiving IS resigning from a task. Reviving one because a window happened to reopen would
|
|
1001
|
+
// be the feature working against the clearest signal the user can give it.
|
|
1002
|
+
if (run.archived)
|
|
1003
|
+
return;
|
|
1004
|
+
const limit = parseUsageLimit(run.error);
|
|
1005
|
+
if (!limit)
|
|
1006
|
+
return;
|
|
1007
|
+
if (!this.semaphore.autoResumeOnUsageLimit())
|
|
1008
|
+
return;
|
|
1009
|
+
// No session to resume = nothing this feature can do; `continueRun` would refuse anyway.
|
|
1010
|
+
if (!run.steps.some((step) => step.sessionId))
|
|
1011
|
+
return;
|
|
1012
|
+
const attempts = run.autoResumeAttempts ?? 0;
|
|
1013
|
+
if (attempts >= MAX_AUTO_RESUMES) {
|
|
1014
|
+
this.store.appendEvent(runId, {
|
|
1015
|
+
type: 'note',
|
|
1016
|
+
message: `automatic resume cap reached (${MAX_AUTO_RESUMES}) — continue this task manually`,
|
|
1017
|
+
});
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const wakeAt = new Date(limit.resetAt.getTime() + AUTO_RESUME_GRACE_MS);
|
|
1021
|
+
this.armAutoResume(runId, wakeAt.getTime());
|
|
1022
|
+
this.store.appendEvent(runId, {
|
|
1023
|
+
type: 'lifecycle',
|
|
1024
|
+
message: `usage limit reached — resuming automatically at ${formatWakeInstant(wakeAt)}`,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
/** Publish the deadline on the record (the cockpit's only source) and arm the timer for it. */
|
|
1028
|
+
armAutoResume(runId, deadline) {
|
|
1029
|
+
this.store.updateRun(runId, { autoResumeAt: new Date(deadline).toISOString() });
|
|
1030
|
+
const timer = setTimeout(() => this.fireAutoResume(runId), Math.max(0, deadline - Date.now()));
|
|
1031
|
+
timer.unref?.();
|
|
1032
|
+
this.autoResumeTimers.set(runId, timer);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* The window has reopened. Re-check the record synchronously — hours may have passed, and the
|
|
1036
|
+
* user may have continued, deleted or cancelled the run in them — then hand the resume to the
|
|
1037
|
+
* ordinary queued-continuation path so it obeys both concurrency caps like any other work.
|
|
1038
|
+
*/
|
|
1039
|
+
fireAutoResume(runId) {
|
|
1040
|
+
this.autoResumeTimers.delete(runId);
|
|
1041
|
+
const run = this.store.getRun(runId);
|
|
1042
|
+
if (!run || run.status !== 'failed' || !run.autoResumeAt)
|
|
1043
|
+
return;
|
|
1044
|
+
// Belt and braces against the one gap `reconcileAutoResumes` cannot close: the setting going
|
|
1045
|
+
// off in the window between the last pump and this tick.
|
|
1046
|
+
if (!this.semaphore.autoResumeOnUsageLimit()) {
|
|
1047
|
+
this.clearAutoResume(runId);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const attempts = (run.autoResumeAttempts ?? 0) + 1;
|
|
1051
|
+
// `continueRun` retires the pending resume (timer + record fields) on the way in — this is a
|
|
1052
|
+
// resume, not a user turn, so the counter is put back straight after.
|
|
1053
|
+
const resumed = this.continueRun(runId, { text: AUTO_RESUME_PROMPT }, true);
|
|
1054
|
+
if (!resumed.ok) {
|
|
1055
|
+
// Refusals happen before `continueRun` retires anything, so the deadline is still on the
|
|
1056
|
+
// record — and a deadline in the past is a promise the cockpit keeps displaying and the
|
|
1057
|
+
// engine will never keep. Retire it here instead, and say why.
|
|
1058
|
+
this.clearAutoResume(runId);
|
|
1059
|
+
this.store.appendEvent(runId, {
|
|
1060
|
+
type: 'note',
|
|
1061
|
+
message: `automatic resume could not start — ${resumed.error ?? 'unknown'}`,
|
|
1062
|
+
});
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
this.store.updateRun(runId, { autoResumeAttempts: attempts });
|
|
1066
|
+
this.store.appendEvent(runId, {
|
|
1067
|
+
type: 'lifecycle',
|
|
1068
|
+
message: `usage limit reset — resuming automatically (${attempts}/${MAX_AUTO_RESUMES})`,
|
|
1069
|
+
});
|
|
1070
|
+
// A deferred continuation only ENQUEUES itself; the queue moves when something pumps it, and
|
|
1071
|
+
// `recover()` — the other deferring caller — pumps once after its whole bulk sweep. A timer
|
|
1072
|
+
// firing on its own has no such follow-up, so without this the resumed run sits at `queued`
|
|
1073
|
+
// until some unrelated run happens to finish. This is the pump for it.
|
|
1074
|
+
void this.pump();
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Make the armed timers agree with the records and the current setting. Runs on every `pump()`
|
|
1078
|
+
* — which is where a settings change lands (a config PUT refreshes the shared semaphore, which
|
|
1079
|
+
* pumps every manager) — and once from `recover()`.
|
|
1080
|
+
*
|
|
1081
|
+
* It is a RECONCILE rather than a one-shot restore because the deadline is durable state and
|
|
1082
|
+
* the timer is not: a restart, a rebuilt project context, a manager disposed mid-wait, or a
|
|
1083
|
+
* refusal all leave a record promising a resume that no timer is holding. Rebuilding from the
|
|
1084
|
+
* record covers every one of those at once — the alternative is a hint counting down to a time
|
|
1085
|
+
* that has already passed, which is exactly the failure this method exists to make impossible.
|
|
1086
|
+
*
|
|
1087
|
+
* Cheap: an in-memory scan, and arming is skipped for every run already held.
|
|
1088
|
+
*/
|
|
1089
|
+
reconcileAutoResumes() {
|
|
1090
|
+
if (!this.semaphore.autoResumeOnUsageLimit()) {
|
|
1091
|
+
// Sweep the RECORDS, not the timer map. A record promising a resume that no timer is
|
|
1092
|
+
// holding is the exact population this method exists for, and it is also the one the
|
|
1093
|
+
// setting can be switched off in front of: cezar restarted while it was off, the config
|
|
1094
|
+
// was hand-edited, or the project context was disposed mid-wait. Retiring only the armed
|
|
1095
|
+
// timers leaves such a record with a live `autoResumeAt`, which `accountHolds()` reads as
|
|
1096
|
+
// a deadline hold — so nothing new starts on that account, `rescueStalledQueue` treats the
|
|
1097
|
+
// phantom appointment as a legitimate reason to sit still, and the cockpit shows a
|
|
1098
|
+
// `scheduled` row for a resume that will never come. `clearAutoResume` covers the armed
|
|
1099
|
+
// ones too, so this one loop is the whole cancellation.
|
|
1100
|
+
const pending = new Set([
|
|
1101
|
+
...this.autoResumeTimers.keys(),
|
|
1102
|
+
...this.store.listRuns().filter((run) => run.autoResumeAt !== undefined).map((run) => run.id),
|
|
1103
|
+
]);
|
|
1104
|
+
for (const runId of pending) {
|
|
1105
|
+
this.clearAutoResume(runId);
|
|
1106
|
+
this.store.appendEvent(runId, {
|
|
1107
|
+
type: 'note',
|
|
1108
|
+
message: 'automatic resume cancelled — auto-resume is switched off',
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
for (const run of this.store.listRuns()) {
|
|
1114
|
+
if (run.status !== 'failed' || !run.autoResumeAt)
|
|
1115
|
+
continue;
|
|
1116
|
+
if (this.autoResumeTimers.has(run.id))
|
|
1117
|
+
continue;
|
|
1118
|
+
const deadline = Date.parse(run.autoResumeAt);
|
|
1119
|
+
// A deadline that is unreadable, belongs to a run that has spent its cap, or belongs to a
|
|
1120
|
+
// task the user has archived is retired rather than re-armed: it can only mislead. One
|
|
1121
|
+
// that has just passed arms at zero — the window is open, which is the point.
|
|
1122
|
+
if (run.archived
|
|
1123
|
+
|| !Number.isFinite(deadline)
|
|
1124
|
+
|| (run.autoResumeAttempts ?? 0) >= MAX_AUTO_RESUMES) {
|
|
1125
|
+
this.store.updateRun(run.id, { autoResumeAt: undefined });
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
// …and one missed by more than a day is retired loudly: reviving a task from another era
|
|
1129
|
+
// is a surprise, not a service, and this is what keeps a sweep from resurrecting every
|
|
1130
|
+
// limit-stopped task a user has long since walked away from.
|
|
1131
|
+
if (Date.now() - deadline > AUTO_RESUME_MISSED_WINDOW_MS) {
|
|
1132
|
+
this.store.updateRun(run.id, { autoResumeAt: undefined });
|
|
1133
|
+
this.store.appendEvent(run.id, {
|
|
1134
|
+
type: 'note',
|
|
1135
|
+
message: 'automatic resume expired — its window reopened over a day ago; continue this task manually',
|
|
1136
|
+
});
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
this.armAutoResume(run.id, deadline);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Hand a run that has not spawned anything back to the queue, when the account it would run on
|
|
1144
|
+
* went into a usage-limit hold (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
1145
|
+
*
|
|
1146
|
+
* The dequeue-time gate in `pump()` cannot be the only one: a run can sit between dequeue and
|
|
1147
|
+
* spawn for a long time — an in-place run waiting for the exclusive repo-root lease is the
|
|
1148
|
+
* measured case — and the account can close in that gap. This is the last honest moment to
|
|
1149
|
+
* refuse, because everything after it costs a real agent turn.
|
|
1150
|
+
*
|
|
1151
|
+
* "Untouched" is the contract: the run has created no session and no worktree, so it goes back
|
|
1152
|
+
* as plain `queued` with its `startedAt` cleared, and `pump()` will pick it up when the window
|
|
1153
|
+
* reopens. Returns true when the caller must abandon the run.
|
|
1154
|
+
*/
|
|
1155
|
+
requeueWhileHeld(runId, workflow, input, runner, state) {
|
|
1156
|
+
const run = this.store.getRun(runId);
|
|
1157
|
+
if (!run || run.status === 'cancelled' || state?.cancelled)
|
|
1158
|
+
return false;
|
|
1159
|
+
// The watchdog sent this one through. Checked, never consumed: the spawn path asks this
|
|
1160
|
+
// question TWICE — here at the top of `execute`, and again after the exclusive repo-root
|
|
1161
|
+
// lease is granted — so a one-shot flag would clear at the first gate and let the second one
|
|
1162
|
+
// hand an in-place run straight back, re-wedging the queue the rescue had just freed.
|
|
1163
|
+
// `dropActive` retires the entry on every terminal path, so the set still cleans itself up.
|
|
1164
|
+
if (this.forceStarted.has(runId))
|
|
1165
|
+
return false;
|
|
1166
|
+
if (!accountHeldFor({ ...run, runner }, this.semaphore.accountHolds(), runner))
|
|
1167
|
+
return false;
|
|
1168
|
+
state?.releaseRepoRoot?.();
|
|
1169
|
+
if (state)
|
|
1170
|
+
state.releaseRepoRoot = undefined;
|
|
1171
|
+
this.pendingJobs.set(runId, { workflow, input });
|
|
1172
|
+
this.queue.push(runId);
|
|
1173
|
+
this.store.updateRun(runId, { status: 'queued', startedAt: undefined, currentStepId: undefined });
|
|
1174
|
+
this.store.appendEvent(runId, {
|
|
1175
|
+
type: 'note',
|
|
1176
|
+
message: 'held in the queue — this agent account is waiting out a usage limit',
|
|
1177
|
+
});
|
|
1178
|
+
this.dropActive(runId);
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* The failsafe: a queue must never be able to wedge.
|
|
1183
|
+
*
|
|
1184
|
+
* Everything else in this file makes an idle queue CORRECT under some condition — a slot cap, a
|
|
1185
|
+
* repo-root lease, and now a usage-limit hold. That is also what makes a wedged queue look
|
|
1186
|
+
* correct, and the hold has already produced one in the field: two resumes fired together, each
|
|
1187
|
+
* holding the account the other was waiting on, and the whole workspace stopped with every task
|
|
1188
|
+
* `queued`. That specific bug is fixed and tested, but "the queue stopped and nothing will ever
|
|
1189
|
+
* restart it" is too expensive a failure mode to leave resting on any single fix being right.
|
|
1190
|
+
*
|
|
1191
|
+
* The test is deliberately about JUSTIFICATION rather than about any particular bug: idling is
|
|
1192
|
+
* legitimate while work is running (here or in another project), or while a real appointment is
|
|
1193
|
+
* still ahead — a scheduled resume that will fire and pump on its own. Anything else is a
|
|
1194
|
+
* queue with work in it, nothing running anywhere, and no event coming to wake it. That gets one
|
|
1195
|
+
* forced sweep, which starts work under the ordinary caps and lets the account's real state
|
|
1196
|
+
* re-assert itself: if the window truly is shut, that task meets the limit and re-establishes an
|
|
1197
|
+
* honest hold, with a real deadline behind it this time.
|
|
1198
|
+
*
|
|
1199
|
+
* Public so a test can drive the wedge directly instead of waiting out the interval.
|
|
1200
|
+
*/
|
|
1201
|
+
async rescueStalledQueue(now = Date.now()) {
|
|
1202
|
+
// First, the worst shape: a record that says `queued` while the engine holds no job, no
|
|
1203
|
+
// continuation and no queue entry for it. `pump()` cannot see such a run — it iterates the
|
|
1204
|
+
// queue, and this one is not in it — so nothing will ever start it. Re-adopt it through the
|
|
1205
|
+
// same path boot recovery uses.
|
|
1206
|
+
for (const run of this.store.listRuns()) {
|
|
1207
|
+
if (run.status !== 'queued')
|
|
1208
|
+
continue;
|
|
1209
|
+
if (this.active.has(run.id) || this.starting.has(run.id))
|
|
1210
|
+
continue;
|
|
1211
|
+
if (this.pendingJobs.has(run.id) || this.pendingContinuations.has(run.id))
|
|
1212
|
+
continue;
|
|
1213
|
+
if (this.queue.includes(run.id))
|
|
1214
|
+
continue;
|
|
1215
|
+
console.warn(`[cez] queue watchdog: re-adopting queued run ${run.id} the engine had lost`);
|
|
1216
|
+
await this.reviveQueuedRun(run, 'queue watchdog');
|
|
1217
|
+
}
|
|
1218
|
+
if (this.queue.length === 0)
|
|
1219
|
+
return;
|
|
1220
|
+
if (this.busySlots() > 0 || this.starting.size > 0)
|
|
1221
|
+
return;
|
|
1222
|
+
if (this.semaphore.busy() > 0)
|
|
1223
|
+
return;
|
|
1224
|
+
// A future deadline is a real reason to sit still: that timer will fire and pump.
|
|
1225
|
+
for (const run of this.store.listRuns()) {
|
|
1226
|
+
if (run.status !== 'failed' || !run.autoResumeAt)
|
|
1227
|
+
continue;
|
|
1228
|
+
const deadline = Date.parse(run.autoResumeAt);
|
|
1229
|
+
if (Number.isFinite(deadline) && deadline > now)
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (this.semaphore.accountHolds().inFlight.size === 0) {
|
|
1233
|
+
// Not the hold, then — some other wakeup went missing. An ordinary pump is the whole fix,
|
|
1234
|
+
// and it is idempotent, so this stays quiet.
|
|
1235
|
+
void this.pump();
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
console.warn('[cez] queue watchdog: work is queued, nothing is running, and the usage-limit hold has no'
|
|
1239
|
+
+ ' deadline behind it — starting the next task anyway');
|
|
1240
|
+
this.forceNextPump = true;
|
|
1241
|
+
void this.pump();
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* The accounts this project is currently holding: one key per run parked on a usage-limit
|
|
1245
|
+
* resume that has not come due yet (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
1246
|
+
*
|
|
1247
|
+
* Published to the shared semaphore so the hold spans PROJECTS — one Claude account can be
|
|
1248
|
+
* driving tasks in three repos, and a limit closes it for all of them. Derived from the
|
|
1249
|
+
* records on every ask rather than tracked as state: a deadline that passes, a resume that
|
|
1250
|
+
* fires, a cancel, an archive and a delete all lift the hold with no bookkeeping.
|
|
1251
|
+
*
|
|
1252
|
+
* Deliberately excludes a deadline that has already passed — that run is about to resume, and
|
|
1253
|
+
* holding the queue for it would only stall the very work the window reopened for.
|
|
1254
|
+
*/
|
|
1255
|
+
accountHolds(now = Date.now()) {
|
|
1256
|
+
const deadline = new Set();
|
|
1257
|
+
const inFlight = new Set();
|
|
1258
|
+
for (const run of this.store.listRuns()) {
|
|
1259
|
+
// A holding run always carries the runner it actually ran on, so the fallback is unused
|
|
1260
|
+
// here — it is spelled out rather than `!` so a future record shape degrades, not throws.
|
|
1261
|
+
const key = () => runAccountKey(run, run.runner ?? 'claude');
|
|
1262
|
+
if (run.status === 'failed' && run.autoResumeAt) {
|
|
1263
|
+
const at = Date.parse(run.autoResumeAt);
|
|
1264
|
+
if (Number.isFinite(at) && at > now)
|
|
1265
|
+
deadline.add(key());
|
|
1266
|
+
}
|
|
1267
|
+
else if (resumeInFlight(run)) {
|
|
1268
|
+
inFlight.add(key());
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return { deadline, inFlight };
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* The PER-TASK off switch (`DELETE /api/v1/runs/:id/auto-resume`, and the archive route):
|
|
1275
|
+
* stop resuming THIS task, without touching the workspace setting or any other task.
|
|
1276
|
+
*
|
|
1277
|
+
* Idempotent — a run with nothing pending answers the same way, because "this task will not
|
|
1278
|
+
* resume itself" is equally true either way. Returns false only when the run does not exist,
|
|
1279
|
+
* which is the route's 404.
|
|
1280
|
+
*/
|
|
1281
|
+
cancelAutoResume(runId) {
|
|
1282
|
+
const run = this.store.getRun(runId);
|
|
1283
|
+
if (!run)
|
|
1284
|
+
return false;
|
|
1285
|
+
const pending = run.autoResumeAt !== undefined || this.autoResumeTimers.has(runId);
|
|
1286
|
+
this.clearAutoResume(runId);
|
|
1287
|
+
if (pending) {
|
|
1288
|
+
this.store.appendEvent(runId, {
|
|
1289
|
+
type: 'note',
|
|
1290
|
+
message: 'automatic resume cancelled for this task',
|
|
1291
|
+
});
|
|
1292
|
+
// This run may have been the last thing holding its account's queue — nothing else will
|
|
1293
|
+
// notice, since the hold is derived and its release is not an event.
|
|
1294
|
+
void this.pump();
|
|
1295
|
+
}
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
/** Retire a pending resume — timer, deadline and counter. The counter goes too because every
|
|
1299
|
+
* caller is a fresh epoch: a human Continue, or a resume that re-stamps its own count. */
|
|
1300
|
+
clearAutoResume(runId) {
|
|
1301
|
+
const timer = this.autoResumeTimers.get(runId);
|
|
1302
|
+
if (timer)
|
|
1303
|
+
clearTimeout(timer);
|
|
1304
|
+
this.autoResumeTimers.delete(runId);
|
|
1305
|
+
const run = this.store.getRun(runId);
|
|
1306
|
+
if (!run)
|
|
1307
|
+
return;
|
|
1308
|
+
if (run.autoResumeAt !== undefined || run.autoResumeAttempts !== undefined) {
|
|
1309
|
+
this.store.updateRun(runId, { autoResumeAt: undefined, autoResumeAttempts: undefined });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/** Reclaim finished worktrees beyond the keep-limit (#483) — directory only,
|
|
1313
|
+
* `cez/<id8>` branch kept. Best-effort; a failure never affects run
|
|
1314
|
+
* lifecycle. `review`/live runs are excluded by the selector. */
|
|
1315
|
+
async enforceRetention() {
|
|
1316
|
+
try {
|
|
1317
|
+
const keep = await resolveWorktreeRetention(this.repoRoot);
|
|
1318
|
+
await reclaimWorktrees(this.repoRoot, this.store, keep);
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
// retention is best-effort; swallow so terminal transitions never break.
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
/** Last live-refresh namer inputs per run — unchanged inputs skip the call. */
|
|
1325
|
+
lastNamerKey = new Map();
|
|
1326
|
+
/**
|
|
1327
|
+
* Acquire the one-at-a-time lease for runs executing in `repoRoot`.
|
|
1328
|
+
*
|
|
1329
|
+
* A lease waiter is idle, so it parks in `waiting` and gives its
|
|
1330
|
+
* `maxParallel` slot back (the #347 rule): isolated worktrees keep using
|
|
1331
|
+
* every configured slot while root runs line up. The store status stays
|
|
1332
|
+
* `running` — only the queue's busy count changes, so the GUI never shows a
|
|
1333
|
+
* lease-blocked run as awaiting user input.
|
|
1334
|
+
*
|
|
1335
|
+
* The lease is held for the run's whole lifetime, including the idle
|
|
1336
|
+
* `waiting` parks between agent turns. A parked session is still live and
|
|
1337
|
+
* writes to the working tree the moment it resumes, so handing the tree to
|
|
1338
|
+
* another run there would reintroduce the concurrent-edit bug (#438) this
|
|
1339
|
+
* lease exists to prevent.
|
|
1340
|
+
*
|
|
1341
|
+
* Returns false when the run was cancelled while waiting: the lease was
|
|
1342
|
+
* never granted and the caller must not touch the working tree.
|
|
1343
|
+
*/
|
|
1344
|
+
async acquireRepoRoot(runId, state) {
|
|
1345
|
+
// `cancel()` can land between the run going `running` and reaching here,
|
|
1346
|
+
// while `interrupt` is still the default no-op — never enter the chain.
|
|
1347
|
+
if (state.cancelled)
|
|
1348
|
+
return false;
|
|
1349
|
+
const previous = this.repoRootTail;
|
|
1350
|
+
let release = () => undefined;
|
|
1351
|
+
this.repoRootTail = new Promise((resolve) => {
|
|
1352
|
+
release = resolve;
|
|
1353
|
+
});
|
|
1354
|
+
// Until `previous` resolves this run does not own the tree yet, so a drop
|
|
1355
|
+
// during the wait must not hand the tree to the next waiter — chain our
|
|
1356
|
+
// release behind `previous` instead of resolving the tail early.
|
|
1357
|
+
state.releaseRepoRoot = () => {
|
|
1358
|
+
void previous.then(release);
|
|
1359
|
+
};
|
|
1360
|
+
let abort = () => undefined;
|
|
1361
|
+
const cancelled = new Promise((resolve) => {
|
|
1362
|
+
abort = resolve;
|
|
1363
|
+
});
|
|
1364
|
+
const parked = state.interrupt;
|
|
1365
|
+
state.interrupt = () => {
|
|
1366
|
+
parked();
|
|
1367
|
+
abort();
|
|
1368
|
+
};
|
|
1369
|
+
this.waiting.add(runId);
|
|
1370
|
+
this.releaseSlot();
|
|
1371
|
+
try {
|
|
1372
|
+
await Promise.race([previous, cancelled]);
|
|
1373
|
+
}
|
|
1374
|
+
finally {
|
|
1375
|
+
state.interrupt = parked;
|
|
1376
|
+
this.waiting.delete(runId);
|
|
1377
|
+
}
|
|
1378
|
+
if (state.cancelled)
|
|
1379
|
+
return false;
|
|
1380
|
+
state.releaseRepoRoot = release;
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
cancel(runId) {
|
|
1384
|
+
// Still waiting in the queue: just drop it there.
|
|
1385
|
+
const queuedAt = this.queue.indexOf(runId);
|
|
1386
|
+
if (queuedAt >= 0) {
|
|
1387
|
+
this.queue.splice(queuedAt, 1);
|
|
1388
|
+
this.pendingJobs.delete(runId);
|
|
1389
|
+
this.pendingContinuations.delete(runId);
|
|
1390
|
+
this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
|
|
1391
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'cancelled while queued' });
|
|
1392
|
+
return true;
|
|
1393
|
+
}
|
|
1394
|
+
const state = this.active.get(runId);
|
|
1395
|
+
if (!state)
|
|
1396
|
+
return false;
|
|
1397
|
+
state.cancelled = true;
|
|
1398
|
+
this.clearIdleTimer(state);
|
|
1399
|
+
state.interrupt();
|
|
1400
|
+
return true;
|
|
1401
|
+
}
|
|
1402
|
+
isActive(runId) {
|
|
1403
|
+
return this.active.has(runId) || this.starting.has(runId) || this.queue.includes(runId);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Fold a queued run's persisted prompt — `run.task` plus everything stacked
|
|
1407
|
+
* onto it (#472) — into the job input that is about to execute.
|
|
1408
|
+
*
|
|
1409
|
+
* Called from `pump()` immediately before `execute()`, which makes the RECORD
|
|
1410
|
+
* the single source of truth for a queued run's prompt. Before this, the
|
|
1411
|
+
* executing copy lived in `pendingJobs` (memory) while the record held a
|
|
1412
|
+
* second one, so an edit that PATCHed the record silently did nothing until a
|
|
1413
|
+
* restart. `recover()` rebuilds through the same helper, so both paths agree.
|
|
1414
|
+
*
|
|
1415
|
+
* **Read-only, and that is load-bearing.** It composes into the in-memory
|
|
1416
|
+
* `input` and never writes the folded string back to `RunRecord.task`; the
|
|
1417
|
+
* task and its stack stay separate on disk for the life of the run. Writing
|
|
1418
|
+
* back would re-append the whole stack on every recovery and compound without
|
|
1419
|
+
* bound — asserted directly by a test.
|
|
1420
|
+
*/
|
|
1421
|
+
hydrateQueuedInput(runId, input) {
|
|
1422
|
+
const run = this.store.getRun(runId);
|
|
1423
|
+
if (!run)
|
|
1424
|
+
return input;
|
|
1425
|
+
const stack = run.queuedMessages ?? [];
|
|
1426
|
+
const task = [run.task, ...stack.map((m) => m.text)]
|
|
1427
|
+
.map((part) => part.trim())
|
|
1428
|
+
.filter((part) => part.length > 0)
|
|
1429
|
+
.join('\n\n');
|
|
1430
|
+
// Keep the original in-memory blocks for a live process (including the
|
|
1431
|
+
// best-effort case where persistence failed). Recovery has no such copy,
|
|
1432
|
+
// so rebuild it from the durable task-image URLs.
|
|
1433
|
+
const images = input.images?.length
|
|
1434
|
+
? input.images
|
|
1435
|
+
: this.readPersistedImages(runId, run.taskImages ?? [], 'task').blocks;
|
|
1436
|
+
const stackedImages = this.readPersistedImages(runId, stack.flatMap((m) => m.images ?? []), 'queued').blocks;
|
|
1437
|
+
return {
|
|
1438
|
+
...input,
|
|
1439
|
+
task,
|
|
1440
|
+
...(images.length ? { images } : { images: undefined }),
|
|
1441
|
+
...(stackedImages.length ? { stackedImages } : { stackedImages: undefined }),
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
/** Apply edits and messages made while a restart continuation waits for
|
|
1445
|
+
* capacity. The durable record remains the source of truth, just as it is for
|
|
1446
|
+
* an ordinary queued workflow (#472), so a second restart reconstructs and
|
|
1447
|
+
* hydrates the same amendments instead of dropping them. */
|
|
1448
|
+
hydrateQueuedContinuation(runId, continuation) {
|
|
1449
|
+
const run = this.store.getRun(runId);
|
|
1450
|
+
if (!run) {
|
|
1451
|
+
return { ...continuation, persistedImages: [], persistedAttachments: [] };
|
|
1452
|
+
}
|
|
1453
|
+
const stack = run.queuedMessages ?? [];
|
|
1454
|
+
const amendedTask = [run.task, ...stack.map((message) => message.text)]
|
|
1455
|
+
.map((part) => part.trim())
|
|
1456
|
+
.filter((part) => part.length > 0)
|
|
1457
|
+
.join('\n\n');
|
|
1458
|
+
const prompt = amendedTask
|
|
1459
|
+
? `${continuation.prompt}\n\nCurrent task and queued updates:\n\n${amendedTask}`
|
|
1460
|
+
: continuation.prompt;
|
|
1461
|
+
const persisted = this.readPersistedImages(runId, stack.flatMap((message) => message.images ?? []), 'queued');
|
|
1462
|
+
return {
|
|
1463
|
+
...continuation,
|
|
1464
|
+
prompt,
|
|
1465
|
+
persistedImages: persisted.blocks,
|
|
1466
|
+
persistedAttachments: persisted.attachments,
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
readPersistedImages(runId, urls, kind) {
|
|
1470
|
+
const blocks = [];
|
|
1471
|
+
const attachments = [];
|
|
1472
|
+
for (const url of urls) {
|
|
1473
|
+
const name = url.split('/').pop();
|
|
1474
|
+
if (!name || name.includes('..') || name.includes('/') || name.includes('\\'))
|
|
1475
|
+
continue;
|
|
1476
|
+
const path = join(this.dataDir, 'runs', `${runId}-images`, name);
|
|
1477
|
+
try {
|
|
1478
|
+
const data = readFileSync(path);
|
|
1479
|
+
blocks.push({
|
|
1480
|
+
type: 'image',
|
|
1481
|
+
source: { type: 'base64', media_type: mediaTypeFor(name), data: data.toString('base64') },
|
|
1482
|
+
});
|
|
1483
|
+
attachments.push({ name, url, path });
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
// Degrade, never fail the boot (AGENTS.md): the user deleted `.ai/cezar/`
|
|
1487
|
+
// or the file is unreadable — start with the text and say which image went.
|
|
1488
|
+
this.store.appendEvent(runId, {
|
|
1489
|
+
type: 'note',
|
|
1490
|
+
message: `${kind} attachment ${name} could not be read — starting without it`,
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return { blocks, attachments };
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Still waiting for a slot? Checked against the engine's own queue rather than
|
|
1498
|
+
* the record's `status` (#472): the record is written by `execute()` a tick
|
|
1499
|
+
* after `pump()` dequeues, so a status read can see `queued` for a run that has
|
|
1500
|
+
* already started. The pending maps are deleted synchronously at dequeue, so
|
|
1501
|
+
* they are the authoritative answer for "can this prompt still be amended".
|
|
1502
|
+
*/
|
|
1503
|
+
isQueued(runId) {
|
|
1504
|
+
return this.pendingJobs.has(runId) || this.pendingContinuations.has(runId);
|
|
1505
|
+
}
|
|
1506
|
+
/** Split `ContentBlock[]` into the persisted shape a stacked message holds. */
|
|
1507
|
+
toQueuedMessage(runId, content) {
|
|
1508
|
+
const text = content
|
|
1509
|
+
.filter((b) => b.type === 'text')
|
|
1510
|
+
.map((b) => b.text)
|
|
1511
|
+
.join('\n');
|
|
1512
|
+
const images = content
|
|
1513
|
+
.filter((b) => b.type === 'image')
|
|
1514
|
+
.map((b) => this.persistImage(runId, b.source.media_type, b.source.data, 'pasted'))
|
|
1515
|
+
.filter((saved) => saved !== null)
|
|
1516
|
+
.map((saved) => saved.url);
|
|
1517
|
+
return {
|
|
1518
|
+
id: randomUUID(),
|
|
1519
|
+
text,
|
|
1520
|
+
...(images.length ? { images } : {}),
|
|
1521
|
+
createdAt: new Date().toISOString(),
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Append a prompt message onto a still-queued run (#472). Returns the stored
|
|
1526
|
+
* entry, or null when the run has already started — the caller then falls
|
|
1527
|
+
* through to `deferMessage`.
|
|
1528
|
+
*/
|
|
1529
|
+
enqueueMessage(runId, content) {
|
|
1530
|
+
if (!this.isQueued(runId))
|
|
1531
|
+
return null;
|
|
1532
|
+
const run = this.store.getRun(runId);
|
|
1533
|
+
if (!run)
|
|
1534
|
+
return null;
|
|
1535
|
+
const message = this.toQueuedMessage(runId, content);
|
|
1536
|
+
this.store.updateRun(runId, { queuedMessages: [...(run.queuedMessages ?? []), message] });
|
|
1537
|
+
return message;
|
|
1538
|
+
}
|
|
1539
|
+
/** Edit a stacked message in place. Omitted fields retain their current value. */
|
|
1540
|
+
editQueuedMessage(runId, msgId, edit) {
|
|
1541
|
+
if (!this.isQueued(runId))
|
|
1542
|
+
return null;
|
|
1543
|
+
const run = this.store.getRun(runId);
|
|
1544
|
+
const stack = run?.queuedMessages;
|
|
1545
|
+
if (!stack)
|
|
1546
|
+
return null;
|
|
1547
|
+
const at = stack.findIndex((m) => m.id === msgId);
|
|
1548
|
+
if (at < 0)
|
|
1549
|
+
return null;
|
|
1550
|
+
const current = stack[at];
|
|
1551
|
+
const replacementImages = edit.images === undefined
|
|
1552
|
+
? current.images
|
|
1553
|
+
: this.toQueuedMessage(runId, edit.images).images;
|
|
1554
|
+
const replacement = {
|
|
1555
|
+
id: msgId,
|
|
1556
|
+
text: edit.text ?? current.text,
|
|
1557
|
+
...(replacementImages?.length ? { images: replacementImages } : {}),
|
|
1558
|
+
createdAt: current.createdAt,
|
|
1559
|
+
};
|
|
1560
|
+
const next = [...stack];
|
|
1561
|
+
next[at] = replacement;
|
|
1562
|
+
this.store.updateRun(runId, { queuedMessages: next });
|
|
1563
|
+
// Images the edit dropped are now orphans.
|
|
1564
|
+
this.dropOrphanImages(runId, stack[at].images ?? [], next);
|
|
1565
|
+
return replacement;
|
|
1566
|
+
}
|
|
1567
|
+
/** Remove a stacked message and its now-orphaned attachments. */
|
|
1568
|
+
removeQueuedMessage(runId, msgId) {
|
|
1569
|
+
if (!this.isQueued(runId))
|
|
1570
|
+
return false;
|
|
1571
|
+
const run = this.store.getRun(runId);
|
|
1572
|
+
const stack = run?.queuedMessages;
|
|
1573
|
+
if (!stack)
|
|
1574
|
+
return false;
|
|
1575
|
+
const target = stack.find((m) => m.id === msgId);
|
|
1576
|
+
if (!target)
|
|
1577
|
+
return false;
|
|
1578
|
+
const next = stack.filter((m) => m.id !== msgId);
|
|
1579
|
+
this.store.updateRun(runId, { queuedMessages: next });
|
|
1580
|
+
this.dropOrphanImages(runId, target.images ?? [], next);
|
|
1581
|
+
return true;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Delete image files no longer referenced by anything (#472). Best effort — a
|
|
1585
|
+
* leftover file is harmless and goes with the run. Never touches a URL still
|
|
1586
|
+
* referenced by another stacked entry or by the initial prompt's `taskImages`.
|
|
1587
|
+
*/
|
|
1588
|
+
dropOrphanImages(runId, candidates, stack) {
|
|
1589
|
+
if (!candidates.length)
|
|
1590
|
+
return;
|
|
1591
|
+
const run = this.store.getRun(runId);
|
|
1592
|
+
const referenced = new Set([
|
|
1593
|
+
...(run?.taskImages ?? []),
|
|
1594
|
+
...stack.flatMap((m) => m.images ?? []),
|
|
1595
|
+
]);
|
|
1596
|
+
for (const url of candidates) {
|
|
1597
|
+
if (referenced.has(url))
|
|
1598
|
+
continue;
|
|
1599
|
+
const name = url.split('/').pop();
|
|
1600
|
+
// Defend the join against a crafted URL: only a bare file name may be deleted.
|
|
1601
|
+
if (!name || name.includes('..') || name.includes('/') || name.includes('\\'))
|
|
1602
|
+
continue;
|
|
1603
|
+
try {
|
|
1604
|
+
rmSync(join(this.dataDir, 'runs', `${runId}-images`, name), { force: true });
|
|
1605
|
+
}
|
|
1606
|
+
catch {
|
|
1607
|
+
/* best effort */
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Edit the initial prompt of a still-queued run (#472). Re-derives the
|
|
1613
|
+
* heuristic title and the PR/issue chips, but never re-runs the LLM namer —
|
|
1614
|
+
* it already fired at creation and a second model call per edit is unjustified.
|
|
1615
|
+
*/
|
|
1616
|
+
editTask(runId, task) {
|
|
1617
|
+
if (!this.isQueued(runId))
|
|
1618
|
+
return false;
|
|
1619
|
+
const run = this.store.getRun(runId);
|
|
1620
|
+
if (!run)
|
|
1621
|
+
return false;
|
|
1622
|
+
const workflow = this.pendingJobs.get(runId)?.workflow;
|
|
1623
|
+
const skillHint = workflow?.steps.find((s) => stepKind(s) === 'agent' && s.skill)?.skill?.trim();
|
|
1624
|
+
const refs = refineTaskRefs(extractTaskRefs(task), skillHint);
|
|
1625
|
+
// Hand-edited titles always win (#389): `user` beats the heuristic, and a
|
|
1626
|
+
// `marker` title the agent declared beats it too.
|
|
1627
|
+
const keepTitle = run.titleOrigin === 'user' || run.titleOrigin === 'marker';
|
|
1628
|
+
this.store.updateRun(runId, {
|
|
1629
|
+
task,
|
|
1630
|
+
...(keepTitle || !workflow ? {} : { title: makeRunTitle(task, workflow) }),
|
|
1631
|
+
...(refs.prNumber !== undefined ? { prNumber: refs.prNumber } : {}),
|
|
1632
|
+
...(refs.issueNumber !== undefined ? { issueNumber: refs.issueNumber } : {}),
|
|
1633
|
+
});
|
|
1634
|
+
return true;
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Buffer a message that arrived in the gap between dequeue and session-open
|
|
1638
|
+
* (#472). `pump()` has already folded the stack and `execute()` is spawning the
|
|
1639
|
+
* backend, so there is nothing left to amend and no session to deliver into —
|
|
1640
|
+
* without this rung the message would 409, a genuinely dropped message in the
|
|
1641
|
+
* feature built to stop dropping them. Flushed as an ordinary follow-up turn
|
|
1642
|
+
* the instant the session opens; dropped if the run never starts, which the
|
|
1643
|
+
* existing error path already surfaces.
|
|
1644
|
+
*
|
|
1645
|
+
* The buffer lives on the manager rather than the `ActiveRun` because the
|
|
1646
|
+
* `ActiveRun` does not exist yet for part of this window.
|
|
1647
|
+
*/
|
|
1648
|
+
deferMessage(runId, content) {
|
|
1649
|
+
// The window spans two sub-states: `starting` (no `ActiveRun` yet) and the
|
|
1650
|
+
// longer stretch where the `ActiveRun` exists but the backend is still being
|
|
1651
|
+
// spawned. `execute()` deletes the run from `starting` as soon as it builds
|
|
1652
|
+
// the state — seconds before the session opens — so checking `starting`
|
|
1653
|
+
// alone would reopen exactly the drop this rung exists to close.
|
|
1654
|
+
const state = this.active.get(runId);
|
|
1655
|
+
const startingUp = this.starting.has(runId) || (state !== undefined && !state.sessionEverOpened && !state.cancelled);
|
|
1656
|
+
if (!startingUp)
|
|
1657
|
+
return false;
|
|
1658
|
+
const pending = this.deferredMessages.get(runId) ?? [];
|
|
1659
|
+
pending.push(content);
|
|
1660
|
+
this.deferredMessages.set(runId, pending);
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
/** Deliver anything `deferMessage` buffered, once the session is live. */
|
|
1664
|
+
flushDeferred(runId) {
|
|
1665
|
+
const pending = this.deferredMessages.get(runId);
|
|
1666
|
+
if (!pending?.length)
|
|
1667
|
+
return;
|
|
1668
|
+
// Re-buffer whatever the session refused rather than dropping it. `sendMessage`
|
|
1669
|
+
// answers false when the session is not open yet — and silently losing a message
|
|
1670
|
+
// here would be precisely the failure `deferMessage` exists to prevent. Anything
|
|
1671
|
+
// left over is retried by the next session that opens on this run.
|
|
1672
|
+
const unsent = pending.filter((content) => !this.sendMessage(runId, content));
|
|
1673
|
+
if (unsent.length)
|
|
1674
|
+
this.deferredMessages.set(runId, unsent);
|
|
1675
|
+
else
|
|
1676
|
+
this.deferredMessages.delete(runId);
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Deliver a user message into the run's live claude session (mid-turn or
|
|
1680
|
+
* while `waiting`). Returns false when there is no open session — the GUI
|
|
1681
|
+
* then offers "Continue" instead.
|
|
1682
|
+
*/
|
|
1683
|
+
sendMessage(runId, content) {
|
|
1684
|
+
const delivered = this.deliverMessage(runId, content, true);
|
|
1685
|
+
if (delivered) {
|
|
1686
|
+
const state = this.active.get(runId);
|
|
1687
|
+
if (state)
|
|
1688
|
+
state.monitoringWakeups = 0;
|
|
1689
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: undefined });
|
|
1690
|
+
}
|
|
1691
|
+
return delivered;
|
|
1692
|
+
}
|
|
1693
|
+
/** Shared live-session delivery. Synthetic scheduler prompts reuse lifecycle
|
|
1694
|
+
* bookkeeping without masquerading as user-authored transcript messages. */
|
|
1695
|
+
deliverMessage(runId, content, userAuthored) {
|
|
1696
|
+
const state = this.active.get(runId);
|
|
1697
|
+
if (!state?.session?.open || state.cancelled)
|
|
1698
|
+
return false;
|
|
1699
|
+
const text = content
|
|
1700
|
+
.filter((b) => b.type === 'text')
|
|
1701
|
+
.map((b) => b.text)
|
|
1702
|
+
.join('\n');
|
|
1703
|
+
// Persist the attached images so the thread can render them (not just count them) — the same
|
|
1704
|
+
// on-disk store + `/images/` route the agent's own screenshots use. `pasted` prefix marks
|
|
1705
|
+
// these as user attachments (vs. agent tool screenshots) on disk (#357).
|
|
1706
|
+
const persisted = userAuthored ? content
|
|
1707
|
+
.filter((b) => b.type === 'image')
|
|
1708
|
+
.map((b) => this.persistImage(runId, b.source.media_type, b.source.data, 'pasted'))
|
|
1709
|
+
.filter((saved) => saved !== null) : [];
|
|
1710
|
+
const images = persisted.map((saved) => saved.url);
|
|
1711
|
+
if (userAuthored) {
|
|
1712
|
+
this.store.appendEvent(runId, {
|
|
1713
|
+
type: 'user-message',
|
|
1714
|
+
stepId: state.currentStepId,
|
|
1715
|
+
text,
|
|
1716
|
+
imageCount: content.filter((b) => b.type === 'image').length,
|
|
1717
|
+
images,
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
// Tell the agent where the pasted files live on disk (#357): the base64 blocks below still
|
|
1721
|
+
// ride along so the model can *view* them, but a real path is what lets it *operate* on them
|
|
1722
|
+
// (save, `cp`, attach to a GitHub issue/PR) — and it's the only usable reference on backends
|
|
1723
|
+
// (codex, opencode) that drop image blocks entirely before reaching the model.
|
|
1724
|
+
const expanded = userAuthored ? expandRegistrySlashSkill(content, state.skills ?? []) : content;
|
|
1725
|
+
const deliverable = persisted.length ? [...expanded, pastedAttachmentsNote(persisted)] : expanded;
|
|
1726
|
+
const delivered = state.session.sendMessage(deliverable);
|
|
1727
|
+
if (delivered) {
|
|
1728
|
+
this.clearIdleTimer(state);
|
|
1729
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
1730
|
+
this.waiting.delete(runId); // resumed — the run counts against slots again
|
|
1731
|
+
this.monitoring.delete(runId);
|
|
1732
|
+
// Clear any `monitoring` activity — the agent is actively working again
|
|
1733
|
+
// (spec 2026-07-18-subagent-monitoring-status, #490).
|
|
1734
|
+
this.store.updateRun(runId, { status: 'running', activity: undefined });
|
|
1735
|
+
if (state.currentStepId) {
|
|
1736
|
+
this.store.updateStep(runId, state.currentStepId, { status: 'running' });
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return delivered;
|
|
1740
|
+
}
|
|
1741
|
+
/** Close the open session gracefully — the run then completes as `done`
|
|
1742
|
+
* (or rests at `review` when the worktree holds changes, spec 009).
|
|
1743
|
+
* On a run already resting at `review` (no session — the engine loop is
|
|
1744
|
+
* over), "Finish" is the third review exit: accept the changes without a
|
|
1745
|
+
* PR and flip straight to `done`. */
|
|
1746
|
+
finish(runId) {
|
|
1747
|
+
const state = this.active.get(runId);
|
|
1748
|
+
if (state?.session?.open) {
|
|
1749
|
+
this.clearIdleTimer(state);
|
|
1750
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
|
|
1751
|
+
state.session.end();
|
|
1752
|
+
return true;
|
|
1753
|
+
}
|
|
1754
|
+
const run = this.store.getRun(runId);
|
|
1755
|
+
if (run?.status === 'review' && !this.isActive(runId)) {
|
|
1756
|
+
this.store.updateRun(runId, { status: 'done' });
|
|
1757
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'review accepted — finished without a PR' });
|
|
1758
|
+
return true;
|
|
1759
|
+
}
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* "Continue" (spec 003): reopen a finished run's claude session in-process
|
|
1764
|
+
* (`claude --resume <sessionId>`) as a new synthetic step. The session then
|
|
1765
|
+
* behaves exactly like an interactive step: `waiting` after each turn,
|
|
1766
|
+
* messages via sendMessage, closed by finish/idle/cancel.
|
|
1767
|
+
*/
|
|
1768
|
+
continueRun(runId, opts = {},
|
|
1769
|
+
/** Restart recovery may discover several interrupted tasks at once. Those
|
|
1770
|
+
* continuations are queued; an explicit user Continue remains immediate. */
|
|
1771
|
+
deferForCapacity = false) {
|
|
1772
|
+
if (agentModelsLocked(this.repoRoot) && opts.model?.trim()) {
|
|
1773
|
+
return { ok: false, error: AGENT_MODELS_LOCKED_ERROR };
|
|
1774
|
+
}
|
|
1775
|
+
if (this.active.has(runId))
|
|
1776
|
+
return { ok: false, error: 'run is still active' };
|
|
1777
|
+
const run = this.store.getRun(runId);
|
|
1778
|
+
if (!run)
|
|
1779
|
+
return { ok: false, error: 'not found' };
|
|
1780
|
+
// `review` is continuable too — that's the "Send back" path (spec 009).
|
|
1781
|
+
if (!['done', 'failed', 'cancelled', 'review'].includes(run.status)) {
|
|
1782
|
+
return { ok: false, error: `cannot continue a ${run.status} run` };
|
|
1783
|
+
}
|
|
1784
|
+
const sessionStep = [...run.steps].reverse().find((s) => s.sessionId);
|
|
1785
|
+
if (!sessionStep?.sessionId)
|
|
1786
|
+
return { ok: false, error: 'no agent session to resume' };
|
|
1787
|
+
const targetRunner = opts.runner ?? run.runner ?? 'claude';
|
|
1788
|
+
// Session ids are provider-owned opaque values. New records carry explicit
|
|
1789
|
+
// affinity; for legacy records, the run's current runner is the conservative
|
|
1790
|
+
// owner until a continuation emits a new, attributed session id (#562).
|
|
1791
|
+
const sessionBackend = sessionStep.backend ?? run.runner ?? 'claude';
|
|
1792
|
+
// A session id only resolves inside the config dir that created it (spec
|
|
1793
|
+
// 2026-07-29-agent-profiles), so switching ACCOUNT ends the session exactly like switching
|
|
1794
|
+
// backend does: `claude --resume <id>` under another login finds nothing and would silently
|
|
1795
|
+
// open a fresh conversation while the thread claimed it had resumed. A step that recorded no
|
|
1796
|
+
// account predates the feature and therefore ran under the discovered one.
|
|
1797
|
+
const sessionAccount = sessionStep.profileId ?? DEFAULT_AGENT_ACCOUNT_ID;
|
|
1798
|
+
const accountSwitched = opts.agentProfile !== undefined && opts.agentProfile !== sessionAccount;
|
|
1799
|
+
const resume = sessionBackend === targetRunner && !accountSwitched;
|
|
1800
|
+
// Follow-up runner/model/account override (#401, spec 2026-07-29-agent-profiles): the composer
|
|
1801
|
+
// lets the user pick which backend, model and login handle this continuation — the same flat
|
|
1802
|
+
// pill the /new composer offers. Omitted → the run's current backend/model/account is kept
|
|
1803
|
+
// (backward compat). A provided choice is persisted BEFORE scheduling, so it becomes the
|
|
1804
|
+
// run's current backend — `runContinuation` reads it off the record, later continuations
|
|
1805
|
+
// default to it, and the header reflects the active engine. An empty model ('') clears the
|
|
1806
|
+
// pin, letting the runner pick the model (auto).
|
|
1807
|
+
if (opts.runner !== undefined || opts.model !== undefined || opts.agentProfile !== undefined) {
|
|
1808
|
+
// Guard the pairing before persisting anything: the model override applies to the runner
|
|
1809
|
+
// this continuation will actually use (`opts.runner ?? record.runner ?? 'claude'` — the
|
|
1810
|
+
// same resolution `runContinuation` reads off the record). A model that is recognizably
|
|
1811
|
+
// another runner's preset would corrupt the run; free-form/custom ids pass untouched.
|
|
1812
|
+
if (opts.model && modelConflictsWithRunner(opts.model, targetRunner)) {
|
|
1813
|
+
return { ok: false, error: `model '${opts.model}' is not a ${targetRunner} model` };
|
|
1814
|
+
}
|
|
1815
|
+
// A runner switch that carries NO explicit model must not leave the previous backend's pin
|
|
1816
|
+
// on the record: the guard above only sees `opts.model`, so without this an inherited
|
|
1817
|
+
// `opus` would survive a switch to codex and `runContinuation` would hand it to the codex
|
|
1818
|
+
// runner. Clearing (not rejecting) is right — the pin belonged to the old backend and is
|
|
1819
|
+
// meaningless for the new one, which is exactly what the composer already displays (auto).
|
|
1820
|
+
// Only a recognizably foreign preset is cleared; a free-form/custom id is left alone.
|
|
1821
|
+
const inheritedPinIsForeign = opts.model === undefined &&
|
|
1822
|
+
run.model !== undefined &&
|
|
1823
|
+
modelConflictsWithRunner(run.model, targetRunner);
|
|
1824
|
+
// An account belongs to ONE agent, so a runner switch that names no account must not leave
|
|
1825
|
+
// the previous backend's login on the record. It is inert immediately (resolution applies
|
|
1826
|
+
// the run's account only to steps on the run's own runner) and wrong later, when a further
|
|
1827
|
+
// continuation switches back and inherits a login the user picked for a different task.
|
|
1828
|
+
const inheritedAccountIsForeign = opts.agentProfile === undefined &&
|
|
1829
|
+
run.agentProfile !== undefined &&
|
|
1830
|
+
targetRunner !== (run.runner ?? 'claude');
|
|
1831
|
+
this.store.updateRun(runId, {
|
|
1832
|
+
...(opts.runner !== undefined ? { runner: opts.runner } : {}),
|
|
1833
|
+
...(opts.model !== undefined
|
|
1834
|
+
? { model: opts.model === '' ? undefined : opts.model }
|
|
1835
|
+
: inheritedPinIsForeign
|
|
1836
|
+
? { model: undefined }
|
|
1837
|
+
: {}),
|
|
1838
|
+
// Persisted BEFORE scheduling, like the runner/model pair: `runContinuation` resolves the
|
|
1839
|
+
// account off the record, and every later continuation then defaults to it.
|
|
1840
|
+
...(opts.agentProfile !== undefined
|
|
1841
|
+
? { agentProfile: opts.agentProfile }
|
|
1842
|
+
: inheritedAccountIsForeign
|
|
1843
|
+
? { agentProfile: undefined }
|
|
1844
|
+
: {}),
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
// Everything that could refuse this continuation has now passed, so a pending usage-limit
|
|
1848
|
+
// resume is superseded either way: this IS that resume (it re-stamps its own counter), or a
|
|
1849
|
+
// human got there first — and then the counter starts over, because the cap only exists to
|
|
1850
|
+
// bound UNATTENDED resumes.
|
|
1851
|
+
this.clearAutoResume(runId);
|
|
1852
|
+
const continuations = run.steps.filter((s) => s.id.startsWith('continue-')).length;
|
|
1853
|
+
const stepId = `continue-${continuations + 1}`;
|
|
1854
|
+
this.store.addStep(runId, { id: stepId, name: 'Continue', kind: 'agent' });
|
|
1855
|
+
const prompt = opts.text?.trim() || 'Continue.';
|
|
1856
|
+
const images = opts.images ?? [];
|
|
1857
|
+
if (deferForCapacity) {
|
|
1858
|
+
this.pendingContinuations.set(runId, {
|
|
1859
|
+
stepId,
|
|
1860
|
+
sessionId: resume ? sessionStep.sessionId : undefined,
|
|
1861
|
+
backend: targetRunner,
|
|
1862
|
+
prompt,
|
|
1863
|
+
images,
|
|
1864
|
+
});
|
|
1865
|
+
this.queue.push(runId);
|
|
1866
|
+
this.store.updateRun(runId, {
|
|
1867
|
+
status: 'queued',
|
|
1868
|
+
error: undefined,
|
|
1869
|
+
finishedAt: undefined,
|
|
1870
|
+
currentStepId: undefined,
|
|
1871
|
+
});
|
|
1872
|
+
return { ok: true };
|
|
1873
|
+
}
|
|
1874
|
+
void this.runContinuation(runId, stepId, resume ? sessionStep.sessionId : undefined, targetRunner, prompt, images).catch((err) => {
|
|
1875
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1876
|
+
this.store.updateRun(runId, {
|
|
1877
|
+
status: 'failed',
|
|
1878
|
+
error: `continue crashed: ${message}`,
|
|
1879
|
+
finishedAt: new Date().toISOString(),
|
|
1880
|
+
});
|
|
1881
|
+
this.dropActive(runId);
|
|
1882
|
+
});
|
|
1883
|
+
return { ok: true };
|
|
1884
|
+
}
|
|
1885
|
+
async runContinuation(runId, stepId, sessionId, backend, prompt,
|
|
1886
|
+
/** Screenshots pasted into the follow-up composer — delivered with the
|
|
1887
|
+
* reopened session's opening message, exactly like a live-session
|
|
1888
|
+
* message's attachments. */
|
|
1889
|
+
images = [],
|
|
1890
|
+
/** Queued-message screenshots were persisted when they were enqueued and
|
|
1891
|
+
* reconstructed at dequeue. Keep them separate from fresh `images` so
|
|
1892
|
+
* opening a recovered continuation does not persist duplicate files. */
|
|
1893
|
+
persistedImages = [], persistedAttachments = []) {
|
|
1894
|
+
// Continuation runs in the task's worktree when it still exists (spec
|
|
1895
|
+
// 006) — the resumed session sees exactly what the original run left.
|
|
1896
|
+
// Retention (#483) may have reclaimed this run's worktree directory while
|
|
1897
|
+
// keeping its branch and worktreePath. Re-materialize it on resume and clear
|
|
1898
|
+
// the stamp so the session regains its isolated tree and the run is eligible
|
|
1899
|
+
// for retention again — otherwise it keeps a dir on disk while staying
|
|
1900
|
+
// invisible to the enforcer forever. Best-effort; falls back to repoRoot.
|
|
1901
|
+
await rematerializeReclaimedWorktree(this.repoRoot, this.store, runId);
|
|
1902
|
+
const record = this.store.getRun(runId);
|
|
1903
|
+
// The env is a live ceiling: a run created while the inbox was on must not keep writing
|
|
1904
|
+
// follow-ups after it is switched off.
|
|
1905
|
+
const generateFollowups = followupsEnabled() && record?.generateFollowups !== false;
|
|
1906
|
+
const cwd = record?.worktreePath && existsSync(record.worktreePath)
|
|
1907
|
+
? record.worktreePath
|
|
1908
|
+
: this.repoRoot;
|
|
1909
|
+
const state = { cancelled: false, interrupt: () => undefined, cwd };
|
|
1910
|
+
this.active.set(runId, state);
|
|
1911
|
+
this.starting.delete(runId);
|
|
1912
|
+
if (state.cwd === this.repoRoot) {
|
|
1913
|
+
if (repositoryRootLockDisabled()) {
|
|
1914
|
+
this.store.appendEvent(runId, {
|
|
1915
|
+
type: 'note',
|
|
1916
|
+
message: REPOSITORY_ROOT_LOCK_DISABLED_NOTE,
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
else {
|
|
1920
|
+
this.store.appendEvent(runId, {
|
|
1921
|
+
type: 'note',
|
|
1922
|
+
message: 'waiting for exclusive access to the repository working tree',
|
|
1923
|
+
});
|
|
1924
|
+
if (!(await this.acquireRepoRoot(runId, state))) {
|
|
1925
|
+
this.store.updateRun(runId, {
|
|
1926
|
+
status: 'cancelled',
|
|
1927
|
+
finishedAt: new Date().toISOString(),
|
|
1928
|
+
currentStepId: undefined,
|
|
1929
|
+
});
|
|
1930
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
|
|
1931
|
+
this.dropActive(runId);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
this.armAutosave(state);
|
|
1937
|
+
if (record)
|
|
1938
|
+
seedHandoffFile(this.dataDir, record); // idempotent — normally already there
|
|
1939
|
+
// Registry snapshot for `/skill` expansion. `execute` loads this for the workflow's own
|
|
1940
|
+
// sessions; a continuation builds its OWN ActiveRun, and without this the resumed session
|
|
1941
|
+
// expanded against an empty registry and leaked `/om-...` verbatim to the backend, which
|
|
1942
|
+
// answered "Unknown skill" (#811). Best-effort — discovery must never break Continue.
|
|
1943
|
+
state.skills = await discoverSkills(this.repoRoot).catch(() => []);
|
|
1944
|
+
this.store.updateRun(runId, {
|
|
1945
|
+
status: 'running',
|
|
1946
|
+
error: undefined,
|
|
1947
|
+
finishedAt: undefined,
|
|
1948
|
+
currentStepId: stepId,
|
|
1949
|
+
activity: undefined, // resuming a monitoring run — it's actively working again (#490)
|
|
1950
|
+
});
|
|
1951
|
+
this.store.updateStep(runId, stepId, {
|
|
1952
|
+
status: 'running',
|
|
1953
|
+
iterations: 1,
|
|
1954
|
+
startedAt: new Date().toISOString(),
|
|
1955
|
+
sessionId,
|
|
1956
|
+
backend,
|
|
1957
|
+
});
|
|
1958
|
+
this.store.appendEvent(runId, { type: 'step-start', stepId, name: 'Continue', kind: 'agent', iteration: 1 });
|
|
1959
|
+
// Attachments pasted into the follow-up composer, on the same terms as a live-session
|
|
1960
|
+
// message (#357): persisted to the run's own image store so the thread renders the bubble's
|
|
1961
|
+
// images rather than a bare count, and handed to the agent BOTH as base64 blocks (so it can
|
|
1962
|
+
// view them) and as absolute paths appended to the prompt (so it can operate on them — and
|
|
1963
|
+
// because codex/opencode drop image blocks before they reach the model).
|
|
1964
|
+
const freshAttachments = images
|
|
1965
|
+
.filter((b) => b.type === 'image')
|
|
1966
|
+
.map((b) => this.persistImage(runId, b.source.media_type, b.source.data, 'pasted'))
|
|
1967
|
+
.filter((saved) => saved !== null);
|
|
1968
|
+
const openingImages = [...images, ...persistedImages];
|
|
1969
|
+
const attachments = [...freshAttachments, ...persistedAttachments];
|
|
1970
|
+
this.store.appendEvent(runId, {
|
|
1971
|
+
type: 'user-message',
|
|
1972
|
+
stepId,
|
|
1973
|
+
text: prompt,
|
|
1974
|
+
imageCount: openingImages.filter((b) => b.type === 'image').length,
|
|
1975
|
+
...(attachments.length ? { images: attachments.map((saved) => saved.url) } : {}),
|
|
1976
|
+
});
|
|
1977
|
+
let stepCost = 0;
|
|
1978
|
+
let turnText = '';
|
|
1979
|
+
let sessionError;
|
|
1980
|
+
const sink = this.makeUiSink(runId, stepId);
|
|
1981
|
+
const onEvent = (event) => {
|
|
1982
|
+
if (event.type === 'image') {
|
|
1983
|
+
const saved = this.persistImage(runId, event.mediaType, event.data);
|
|
1984
|
+
if (saved)
|
|
1985
|
+
this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
if (event.type === 'text') {
|
|
1989
|
+
turnText = appendTurnText(turnText, event.text);
|
|
1990
|
+
const text = stripAskMarker(stripTaskMarkers(stripMonitoringMarker(stripDoneMarker(event.text))));
|
|
1991
|
+
if (text)
|
|
1992
|
+
this.store.appendEvent(runId, { type: 'text', text, stepId });
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
this.store.appendEvent(runId, { ...event, stepId });
|
|
1996
|
+
if (event.type === 'error') {
|
|
1997
|
+
sessionError ??= event.message;
|
|
1998
|
+
state.session?.interrupt();
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (sessionError)
|
|
2002
|
+
return;
|
|
2003
|
+
if (event.type === 'session') {
|
|
2004
|
+
this.store.updateStep(runId, stepId, { sessionId: event.sessionId, backend });
|
|
2005
|
+
}
|
|
2006
|
+
if (event.type === 'token-usage') {
|
|
2007
|
+
this.store.updateStep(runId, stepId, { tokensUsed: event.tokensUsed });
|
|
2008
|
+
}
|
|
2009
|
+
if (event.type === 'cost') {
|
|
2010
|
+
stepCost += event.usd;
|
|
2011
|
+
this.store.updateStep(runId, stepId, { costUsd: stepCost });
|
|
2012
|
+
}
|
|
2013
|
+
if (event.type === 'turn-end') {
|
|
2014
|
+
// Belt-and-braces: v2 `turn.completed` already flushed the delta
|
|
2015
|
+
// coalescers; the v1 turn boundary flushes again (idempotent) so no
|
|
2016
|
+
// buffered delta can outlive its turn.
|
|
2017
|
+
sink.flushAll();
|
|
2018
|
+
void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
|
|
2019
|
+
const sessionOpen = !state.cancelled && state.session?.open;
|
|
2020
|
+
const done = sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
2021
|
+
// `CEZ:ASK` → the user is genuinely blocked; wins over `CEZ:MONITORING`
|
|
2022
|
+
// (a pending question is always attention), loses to `CEZ:DONE` (#473).
|
|
2023
|
+
const askResult = sessionOpen && !done ? parseAskMarkerResult(turnText) : undefined;
|
|
2024
|
+
const ask = askResult?.kind === 'valid' ? askResult.request : null;
|
|
2025
|
+
const askRejection = askResult ? askMarkerRejection(askResult) : undefined;
|
|
2026
|
+
const monitoring = sessionOpen && !done && !ask && MONITORING_MARKER_RE.test(turnText.trimEnd());
|
|
2027
|
+
turnText = '';
|
|
2028
|
+
if (askRejection)
|
|
2029
|
+
this.store.appendEvent(runId, { type: 'note', message: askRejection, stepId });
|
|
2030
|
+
if (done) {
|
|
2031
|
+
// Goal achieved (agent contract, #347) — same as in runAgentStep.
|
|
2032
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
|
|
2033
|
+
appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
|
|
2034
|
+
state.session?.end();
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (sessionOpen) {
|
|
2038
|
+
// Autonomous (#autonomous): never hand the ball back to the user. Nudge the agent to
|
|
2039
|
+
// keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`.
|
|
2040
|
+
const autoContinued = state.autonomous &&
|
|
2041
|
+
(state.autoContinues ?? 0) < MAX_AUTO_CONTINUES &&
|
|
2042
|
+
!state.cancelled &&
|
|
2043
|
+
(() => {
|
|
2044
|
+
const sent = state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]);
|
|
2045
|
+
if (!sent)
|
|
2046
|
+
return false;
|
|
2047
|
+
state.autoContinues = (state.autoContinues ?? 0) + 1;
|
|
2048
|
+
this.store.appendEvent(runId, {
|
|
2049
|
+
type: 'note',
|
|
2050
|
+
message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
|
|
2051
|
+
});
|
|
2052
|
+
return true;
|
|
2053
|
+
})();
|
|
2054
|
+
if (!autoContinued) {
|
|
2055
|
+
// `CEZ:ASK` → park `waiting` (attention) AND surface the structured
|
|
2056
|
+
// question as an ask card (#473). `CEZ:MONITORING` → non-attention
|
|
2057
|
+
// `running`/`activity:'monitoring'` (#490). Both share the waiting
|
|
2058
|
+
// lifecycle (free the slot, keep the idle timer); the autonomous
|
|
2059
|
+
// nudge above still wins over either.
|
|
2060
|
+
if (ask)
|
|
2061
|
+
emitAskRequested(sink, ask);
|
|
2062
|
+
if (monitoring) {
|
|
2063
|
+
this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
|
|
2064
|
+
this.store.updateStep(runId, stepId, { status: 'running' });
|
|
2065
|
+
this.monitoring.add(runId);
|
|
2066
|
+
this.clearIdleTimer(state);
|
|
2067
|
+
this.armMonitoringWakeTimer(runId, state);
|
|
2068
|
+
}
|
|
2069
|
+
else {
|
|
2070
|
+
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
2071
|
+
this.store.updateStep(runId, stepId, { status: 'waiting' });
|
|
2072
|
+
this.monitoring.delete(runId);
|
|
2073
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
2074
|
+
}
|
|
2075
|
+
this.waiting.add(runId);
|
|
2076
|
+
if (!monitoring)
|
|
2077
|
+
this.armIdleTimer(runId, state);
|
|
2078
|
+
this.releaseSlot();
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
// A turn that completed is the ONLY evidence the provider's window actually reopened, so
|
|
2082
|
+
// it is what retires the consecutive-resume counter — which in turn releases the account
|
|
2083
|
+
// hold for every other task queued behind it (spec
|
|
2084
|
+
// 2026-08-03-auto-resume-after-usage-limit). `settleSuccess` does the same for a run that
|
|
2085
|
+
// finishes outright; this covers the far more common "parked for the user" ending.
|
|
2086
|
+
if (this.store.getRun(runId)?.autoResumeAttempts !== undefined) {
|
|
2087
|
+
this.store.updateRun(runId, { autoResumeAttempts: undefined });
|
|
2088
|
+
}
|
|
2089
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
// Backend + model come off the record: the run's current backend by default, or the
|
|
2093
|
+
// follow-up override that `continueRun` persisted before scheduling (#401).
|
|
2094
|
+
const continueBackend = backend;
|
|
2095
|
+
/** Settle this turn as a failure before anything is spawned — the shape both
|
|
2096
|
+
* pre-spawn gates below need (model identity, #405; temp directory, #785). */
|
|
2097
|
+
const failBeforeSpawn = (message) => {
|
|
2098
|
+
const failedAt = new Date().toISOString();
|
|
2099
|
+
sink.sessionEnded('error', message);
|
|
2100
|
+
this.store.updateStep(runId, stepId, {
|
|
2101
|
+
status: 'failed',
|
|
2102
|
+
error: message,
|
|
2103
|
+
finishedAt: failedAt,
|
|
2104
|
+
});
|
|
2105
|
+
this.store.updateRun(runId, {
|
|
2106
|
+
status: 'failed',
|
|
2107
|
+
error: `continue failed: ${message}`,
|
|
2108
|
+
finishedAt: failedAt,
|
|
2109
|
+
currentStepId: undefined,
|
|
2110
|
+
});
|
|
2111
|
+
this.store.appendEvent(runId, {
|
|
2112
|
+
type: 'lifecycle',
|
|
2113
|
+
message: `continue failed — ${message}`,
|
|
2114
|
+
});
|
|
2115
|
+
this.dropActive(runId);
|
|
2116
|
+
};
|
|
2117
|
+
// Apply the SAME canonical-identity gate the first spawn applies (#405, review M1).
|
|
2118
|
+
// A follow-up may switch both runner and model (#401), so without this the record keeps
|
|
2119
|
+
// asserting the identity the run STARTED with while a different model serves the turn —
|
|
2120
|
+
// the exact defect that PR existed to remove — and the raw record string reaches the CLI
|
|
2121
|
+
// in the un-normalised wire form the first step already converted away (`anthropic/opus`
|
|
2122
|
+
// instead of `opus`). Fail loud here too rather than let the backend pick a default.
|
|
2123
|
+
let continueModel;
|
|
2124
|
+
try {
|
|
2125
|
+
const normalized = normalizeModelForBackend(continueBackend, agentModelsLocked(this.repoRoot) ? undefined : record?.model, { configuredProvider: await configuredModelProvider(continueBackend, state.cwd) });
|
|
2126
|
+
continueModel = normalized?.backendModel;
|
|
2127
|
+
this.store.updateRun(runId, {
|
|
2128
|
+
modelIdentity: normalized ? formatModelIdentity(normalized.identity) : undefined,
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
catch (err) {
|
|
2132
|
+
if (!(err instanceof ModelIdentityError))
|
|
2133
|
+
throw err;
|
|
2134
|
+
failBeforeSpawn(err.message);
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
// Resuming reattaches to a session that lives inside ONE account's config dir, so the
|
|
2138
|
+
// continuation must run under the account that created it — not whatever the project has
|
|
2139
|
+
// been switched to since. The owning step is the one carrying this session id.
|
|
2140
|
+
const owningStep = sessionId === undefined
|
|
2141
|
+
? undefined
|
|
2142
|
+
: record?.steps.find((s) => s.sessionId === sessionId);
|
|
2143
|
+
const resumedProfileId = owningStep?.profileId;
|
|
2144
|
+
// The owning step also names the session's tools: resolve `allowedTools`/`bashAllowlist`
|
|
2145
|
+
// from the persisted `workflowDef` exactly as the first spawn did (`runAgentStep`).
|
|
2146
|
+
// Rebuilding with the bare DEFAULT_ALLOWED_TOOLS silently revoked every per-step grant
|
|
2147
|
+
// (MCP servers, subagents) on Continue, restart recovery and the usage-limit auto-resume
|
|
2148
|
+
// — and dropping `bashAllowlist` WIDENED Bash from an allowlist to unrestricted
|
|
2149
|
+
// (`AgentRunSpec.allowedTools`, #430). Record steps share ids with `workflowDef.steps`;
|
|
2150
|
+
// a synthetic `continue-N` owner and a fresh-session continuation (backend switch — no
|
|
2151
|
+
// owning session) both extend the run's tail, so they resolve from the definition's last
|
|
2152
|
+
// agent step. A legacy record without `workflowDef` (#367), or a session no step owns,
|
|
2153
|
+
// keeps today's defaults.
|
|
2154
|
+
const defSteps = record?.workflowDef?.steps;
|
|
2155
|
+
const toolsStep = defSteps === undefined || (sessionId !== undefined && owningStep === undefined)
|
|
2156
|
+
? undefined
|
|
2157
|
+
: defSteps.find((s) => s.id === owningStep?.id)
|
|
2158
|
+
?? [...defSteps].reverse().find((s) => stepKind(s) === 'agent');
|
|
2159
|
+
// The temp-directory preflight (#785) rides along with the account resolution: a resumed
|
|
2160
|
+
// turn hits the same broken `/tmp` a fresh one would, and an agent whose shell silently
|
|
2161
|
+
// returns nothing is worse than a turn that refuses to start and says why.
|
|
2162
|
+
let continueProfile;
|
|
2163
|
+
try {
|
|
2164
|
+
continueProfile = await this.agentEnvForStep(runId, continueBackend, {
|
|
2165
|
+
generateFollowups,
|
|
2166
|
+
recordedProfileId: resumedProfileId,
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
catch (err) {
|
|
2170
|
+
if (!(err instanceof AgentTempDirError))
|
|
2171
|
+
throw err;
|
|
2172
|
+
failBeforeSpawn(err.message);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
this.store.updateStep(runId, stepId, { profileId: continueProfile.profileId });
|
|
2176
|
+
const runner = createRunner(continueBackend);
|
|
2177
|
+
state.currentStepId = stepId;
|
|
2178
|
+
this.beginUsageInvocation(runId, state, stepId);
|
|
2179
|
+
// A continuation's opening message becomes the session's `userPrompt` and never passes
|
|
2180
|
+
// through `deliverMessage`, so it needs the SAME delivery-only `/skill` rewrite the
|
|
2181
|
+
// live path applies (#811). Delivery-only: the `user-message` event above already
|
|
2182
|
+
// persisted the user's original text, and the transcript must keep showing that.
|
|
2183
|
+
const openingPrompt = expandRegistrySlashSkillText(prompt, state.skills ?? []);
|
|
2184
|
+
const session = runner.startSession({
|
|
2185
|
+
// The Continue step is a fresh agent session on the same run — the
|
|
2186
|
+
// run's extra system prompt (already resolved at execute time and
|
|
2187
|
+
// echoed on the record) rides along with the handoff contract.
|
|
2188
|
+
systemPrompt: composeSystemPrompt(record?.systemPrompt, generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS),
|
|
2189
|
+
userPrompt: attachments.length
|
|
2190
|
+
? `${openingPrompt}\n\n${pastedAttachmentsText(attachments)}`
|
|
2191
|
+
: openingPrompt,
|
|
2192
|
+
...(openingImages.length ? { images: openingImages } : {}),
|
|
2193
|
+
cwd: state.cwd,
|
|
2194
|
+
allowedTools: allowedToolsForStep(toolsStep, continueBackend),
|
|
2195
|
+
bashAllowlist: toolsStep?.bashAllowlist,
|
|
2196
|
+
additionalDirectories: agentDirectories(join(this.dataDir, 'runs'), continueProfile.env),
|
|
2197
|
+
env: continueProfile.env,
|
|
2198
|
+
model: continueModel,
|
|
2199
|
+
sessionId,
|
|
2200
|
+
resume: sessionId !== undefined,
|
|
2201
|
+
timeoutMs: 0,
|
|
2202
|
+
}, onEvent, { onUiEvent: (event) => this.handleRunnerUiEvent(runId, state, sink, event) });
|
|
2203
|
+
state.session = session;
|
|
2204
|
+
state.sessionEverOpened = true;
|
|
2205
|
+
this.flushDeferred(runId);
|
|
2206
|
+
state.interrupt = () => session.interrupt();
|
|
2207
|
+
if (session.pid !== undefined)
|
|
2208
|
+
registerRunProcess(runId, session.pid);
|
|
2209
|
+
const finishedAt = () => new Date().toISOString();
|
|
2210
|
+
try {
|
|
2211
|
+
await session.result;
|
|
2212
|
+
if (sessionError)
|
|
2213
|
+
throw new Error(sessionError);
|
|
2214
|
+
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
2215
|
+
if (state.cancelled) {
|
|
2216
|
+
this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
|
|
2217
|
+
this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
|
|
2218
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
|
|
2219
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=cancelled`);
|
|
2220
|
+
}
|
|
2221
|
+
else {
|
|
2222
|
+
this.store.updateStep(runId, stepId, { status: 'done', finishedAt: finishedAt() });
|
|
2223
|
+
this.store.appendEvent(runId, { type: 'step-end', stepId, status: 'done' });
|
|
2224
|
+
await this.settleSuccess(runId);
|
|
2225
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=done`);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
catch (err) {
|
|
2229
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2230
|
+
sink.sessionEnded('error', message);
|
|
2231
|
+
this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
|
|
2232
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
|
|
2233
|
+
this.store.updateRun(runId, {
|
|
2234
|
+
status: 'failed',
|
|
2235
|
+
error: `continue failed: ${message}`,
|
|
2236
|
+
finishedAt: finishedAt(),
|
|
2237
|
+
currentStepId: undefined,
|
|
2238
|
+
});
|
|
2239
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
|
|
2240
|
+
}
|
|
2241
|
+
finally {
|
|
2242
|
+
this.recordUsagePeaks(runId);
|
|
2243
|
+
this.clearIdleTimer(state);
|
|
2244
|
+
this.clearAutosaveTimer(state);
|
|
2245
|
+
if (state.cwd !== this.repoRoot)
|
|
2246
|
+
await autosaveCommit(state.cwd, 'turn end');
|
|
2247
|
+
this.dropActive(runId);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
// ---- execution -----------------------------------------------------------
|
|
2251
|
+
async execute(runId, workflow, input) {
|
|
2252
|
+
const state = {
|
|
2253
|
+
cancelled: false,
|
|
2254
|
+
interrupt: () => undefined,
|
|
2255
|
+
cwd: this.repoRoot,
|
|
2256
|
+
autonomous: input.autonomous === true,
|
|
2257
|
+
autoContinues: 0,
|
|
2258
|
+
};
|
|
2259
|
+
this.active.set(runId, state);
|
|
2260
|
+
this.starting.delete(runId);
|
|
2261
|
+
const emit = (event) => this.store.appendEvent(runId, event);
|
|
2262
|
+
// Resolve the agent backend for this run: the task choice (GUI) wins over
|
|
2263
|
+
// the config default. Per-step `runner` can still override it below.
|
|
2264
|
+
const config = await loadConfig(this.repoRoot);
|
|
2265
|
+
const taskBackend = input.runner ?? config.defaultRunner;
|
|
2266
|
+
// The account may have gone into a usage-limit hold since this run was dequeued — the queue
|
|
2267
|
+
// gate cannot be the only one, because dequeue is not the moment of no return. Nothing has
|
|
2268
|
+
// happened yet here, so the run goes back to the queue untouched (spec
|
|
2269
|
+
// 2026-08-03-auto-resume-after-usage-limit).
|
|
2270
|
+
if (this.requeueWhileHeld(runId, workflow, input, taskBackend))
|
|
2271
|
+
return;
|
|
2272
|
+
// Extra system prompt (R2 2.3): POST override > config default; echoed on
|
|
2273
|
+
// the record so the UI/API can show what the run actually used.
|
|
2274
|
+
const extraSystemPrompt = resolveExtraSystemPrompt(input.systemPrompt, config.systemPrompt);
|
|
2275
|
+
// Canonical provider/model identity (#405) — the normalised `provider/model`
|
|
2276
|
+
// the task ran with, persisted for cost attribution / reproducible replay
|
|
2277
|
+
// beside the free-text `model`. Best-effort here (a per-step `runner`/`model`
|
|
2278
|
+
// can still override below); the authoritative fail-loud gate is at spawn.
|
|
2279
|
+
let modelIdentity;
|
|
2280
|
+
try {
|
|
2281
|
+
const normalized = normalizeModelForBackend(taskBackend, agentModelsLocked(this.repoRoot) ? undefined : input.model, { configuredProvider: await configuredModelProvider(taskBackend, this.repoRoot) });
|
|
2282
|
+
modelIdentity = normalized ? formatModelIdentity(normalized.identity) : undefined;
|
|
2283
|
+
}
|
|
2284
|
+
catch {
|
|
2285
|
+
// An unresolvable task-level model surfaces loudly at the step below; the
|
|
2286
|
+
// metadata echo stays absent rather than guessing.
|
|
2287
|
+
}
|
|
2288
|
+
this.store.updateRun(runId, {
|
|
2289
|
+
status: 'running',
|
|
2290
|
+
startedAt: new Date().toISOString(),
|
|
2291
|
+
runner: taskBackend,
|
|
2292
|
+
systemPrompt: extraSystemPrompt,
|
|
2293
|
+
modelIdentity,
|
|
2294
|
+
});
|
|
2295
|
+
emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}" (runner: ${taskBackend})` });
|
|
2296
|
+
// Worktree per task (spec 006): the agent works on its own branch in
|
|
2297
|
+
// `.ai/cezar/worktrees/<id>`, never in the user's working tree. A Git task
|
|
2298
|
+
// that requests isolation fails closed if the worktree cannot be
|
|
2299
|
+
// established; only explicit opt-out and non-Git modes run in place.
|
|
2300
|
+
const repo = await getRepoInfo(this.repoRoot);
|
|
2301
|
+
if (repo && input.worktree === false) {
|
|
2302
|
+
// Composer opt-out: run in the repo working tree, no branch/worktree. The
|
|
2303
|
+
// repository-root lease serializes these runs by default; the explicit
|
|
2304
|
+
// CEZ_DISABLE_REPO_LOCK=1 escape hatch allows unsafe overlap.
|
|
2305
|
+
// Pin the starting commit: the session's Changes and Commits views use it
|
|
2306
|
+
// as their stable lower bound while reading the current working copy.
|
|
2307
|
+
const startingCommit = await getHeadCommit(repo.root);
|
|
2308
|
+
if (startingCommit)
|
|
2309
|
+
this.store.updateRun(runId, { baseBranch: startingCommit });
|
|
2310
|
+
emit({ type: 'note', message: 'worktree off — running in the repo working tree' });
|
|
2311
|
+
}
|
|
2312
|
+
else if (repo) {
|
|
2313
|
+
emit({
|
|
2314
|
+
type: 'note',
|
|
2315
|
+
message: `worktree on — using an isolated task worktree (${input.worktree === true ? 'explicit request' : 'default'})`,
|
|
2316
|
+
});
|
|
2317
|
+
// Fork from the configured base branch (config.json `baseBranch`, e.g.
|
|
2318
|
+
// `develop`) — also the target of the eventual draft PR. Unresolvable
|
|
2319
|
+
// (typo, not fetched) → note + the currently checked-out branch.
|
|
2320
|
+
//
|
|
2321
|
+
// A task that already recorded a fork point keeps it: its worktree is
|
|
2322
|
+
// reused as-is, and re-resolving against a since-changed config would
|
|
2323
|
+
// silently re-anchor the `merge-base` every diff/shortstat is measured
|
|
2324
|
+
// from, shifting "what did this task change" under an existing task.
|
|
2325
|
+
const recorded = this.store.getRun(runId)?.baseBranch;
|
|
2326
|
+
let base = recorded ?? repo.branch;
|
|
2327
|
+
const configured = recorded ? undefined : config.baseBranch;
|
|
2328
|
+
if (configured) {
|
|
2329
|
+
const resolved = await resolveBaseRef(this.repoRoot, configured);
|
|
2330
|
+
if (resolved) {
|
|
2331
|
+
base = resolved;
|
|
2332
|
+
}
|
|
2333
|
+
else {
|
|
2334
|
+
emit({
|
|
2335
|
+
type: 'note',
|
|
2336
|
+
message: `configured base branch "${configured}" not found (locally or on origin) — using "${repo.branch}"`,
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
try {
|
|
2341
|
+
const wt = await createWorktree(this.repoRoot, runId, base);
|
|
2342
|
+
state.cwd = wt.path;
|
|
2343
|
+
this.store.updateRun(runId, {
|
|
2344
|
+
worktreePath: wt.path,
|
|
2345
|
+
branch: wt.branch,
|
|
2346
|
+
baseBranch: wt.baseBranch,
|
|
2347
|
+
});
|
|
2348
|
+
emit({ type: 'note', message: `worktree ready — branch ${wt.branch} (base ${wt.baseBranch})` });
|
|
2349
|
+
// Seed from this manager's project root: each multi-project context has
|
|
2350
|
+
// its own manager/repoRoot and must never copy another project's layer.
|
|
2351
|
+
const seededConfig = await seedAgentConfigLocalLayer(this.repoRoot, state.cwd).catch(() => []);
|
|
2352
|
+
if (seededConfig.length > 0) {
|
|
2353
|
+
emit({ type: 'note', message: `seeded personal agent config: ${seededConfig.join(', ')}` });
|
|
2354
|
+
}
|
|
2355
|
+
this.armAutosave(state);
|
|
2356
|
+
}
|
|
2357
|
+
catch (err) {
|
|
2358
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2359
|
+
const error = `worktree creation failed: ${message}`;
|
|
2360
|
+
emit({ type: 'note', message: `${error} — task stopped before workflow execution` });
|
|
2361
|
+
this.store.updateRun(runId, {
|
|
2362
|
+
status: 'failed',
|
|
2363
|
+
error,
|
|
2364
|
+
finishedAt: new Date().toISOString(),
|
|
2365
|
+
currentStepId: undefined,
|
|
2366
|
+
});
|
|
2367
|
+
emit({ type: 'lifecycle', message: `run failed — ${error}` });
|
|
2368
|
+
this.dropActive(runId);
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
else {
|
|
2373
|
+
emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
|
|
2374
|
+
}
|
|
2375
|
+
if (state.cwd === this.repoRoot) {
|
|
2376
|
+
if (repositoryRootLockDisabled()) {
|
|
2377
|
+
emit({
|
|
2378
|
+
type: 'note',
|
|
2379
|
+
message: REPOSITORY_ROOT_LOCK_DISABLED_NOTE,
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
else {
|
|
2383
|
+
emit({
|
|
2384
|
+
type: 'note',
|
|
2385
|
+
message: 'waiting for exclusive access to the repository working tree',
|
|
2386
|
+
});
|
|
2387
|
+
// A cancel during the wait leaves the lease ungranted; the step loop
|
|
2388
|
+
// below breaks on `cancelled` before touching the tree and settles the
|
|
2389
|
+
// run through the usual path.
|
|
2390
|
+
await this.acquireRepoRoot(runId, state);
|
|
2391
|
+
}
|
|
2392
|
+
// THE window that matters for an in-place run. Waiting for the exclusive tree can take
|
|
2393
|
+
// minutes, and a run parked on that lease holds no slot (#347) — so the queue keeps
|
|
2394
|
+
// advancing behind it and the dequeue-time gate is long past. Measured with five in-place
|
|
2395
|
+
// tasks and `maxParallel: 2`: four of them started. Re-ask here, where the very next thing
|
|
2396
|
+
// is a spawn, and hand the run back to the queue if the account closed meanwhile. This
|
|
2397
|
+
// check also covers the explicit lock-bypass path, where the account may close while the
|
|
2398
|
+
// run is preparing its first step.
|
|
2399
|
+
if (this.requeueWhileHeld(runId, workflow, input, taskBackend, state))
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
// Handoff journal (spec 007) — seeded after the worktree exists so the
|
|
2403
|
+
// header can name the branch. Idempotent: an existing file stays as-is.
|
|
2404
|
+
const seeded = this.store.getRun(runId);
|
|
2405
|
+
if (seeded)
|
|
2406
|
+
seedHandoffFile(this.dataDir, seeded);
|
|
2407
|
+
const skills = await discoverSkills(this.repoRoot);
|
|
2408
|
+
// Every ActiveRun construction site must carry the registry — `runContinuation` builds
|
|
2409
|
+
// its own, and the one that skipped this leaked raw `/skill` text to the backend (#811).
|
|
2410
|
+
state.skills = skills;
|
|
2411
|
+
const retriesUsed = new Map();
|
|
2412
|
+
let checkFailure = null;
|
|
2413
|
+
let runError = null;
|
|
2414
|
+
// `startRun` already persisted task images so a queued bubble can render them
|
|
2415
|
+
// (#612). Reuse those files for the agent-facing path note instead of minting
|
|
2416
|
+
// duplicate pasted files when execution finally begins.
|
|
2417
|
+
let startAttachments = (this.store.getRun(runId)?.taskImages ?? [])
|
|
2418
|
+
.map((url) => {
|
|
2419
|
+
const name = url.split('/').pop();
|
|
2420
|
+
if (!name || name.includes('..') || name.includes('/') || name.includes('\\'))
|
|
2421
|
+
return null;
|
|
2422
|
+
const path = join(this.dataDir, 'runs', `${runId}-images`, name);
|
|
2423
|
+
return existsSync(path) ? { name, url, path } : null;
|
|
2424
|
+
})
|
|
2425
|
+
.filter((saved) => saved !== null);
|
|
2426
|
+
// Task screenshots go with the FIRST agent step's opening message only —
|
|
2427
|
+
// later steps and retry loops run in fresh sessions without them. Stacked
|
|
2428
|
+
// attachments (#472) ride along too, but are NOT re-persisted above: they
|
|
2429
|
+
// already live on disk, and adding them to `taskImages` would both duplicate
|
|
2430
|
+
// the files and make the task bubble claim the stack's images as its own.
|
|
2431
|
+
let startImages = input.stackedImages?.length ? [...(input.images ?? []), ...input.stackedImages] : input.images;
|
|
2432
|
+
const lastAgentIdx = findLastAgentStepIndex(workflow);
|
|
2433
|
+
let i = 0;
|
|
2434
|
+
while (i < workflow.steps.length) {
|
|
2435
|
+
if (state.cancelled)
|
|
2436
|
+
break;
|
|
2437
|
+
const step = workflow.steps[i];
|
|
2438
|
+
const kind = stepKind(step);
|
|
2439
|
+
const record = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
|
|
2440
|
+
const iteration = (record?.iterations ?? 0) + 1;
|
|
2441
|
+
this.store.updateRun(runId, { currentStepId: step.id });
|
|
2442
|
+
this.store.updateStep(runId, step.id, {
|
|
2443
|
+
status: 'running',
|
|
2444
|
+
iterations: iteration,
|
|
2445
|
+
startedAt: new Date().toISOString(),
|
|
2446
|
+
error: undefined,
|
|
2447
|
+
});
|
|
2448
|
+
emit({ type: 'step-start', stepId: step.id, name: step.name ?? step.id, kind, iteration });
|
|
2449
|
+
if (kind === 'agent') {
|
|
2450
|
+
// The last agent step of the workflow is interactive: after its turn
|
|
2451
|
+
// the session stays open for follow-ups until finish/idle/cancel.
|
|
2452
|
+
const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
|
|
2453
|
+
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend, extraSystemPrompt, chainStepNote(workflow.steps, i), startAttachments);
|
|
2454
|
+
startImages = undefined;
|
|
2455
|
+
startAttachments = [];
|
|
2456
|
+
checkFailure = null;
|
|
2457
|
+
if (state.cancelled)
|
|
2458
|
+
break;
|
|
2459
|
+
if (failure) {
|
|
2460
|
+
this.finishStep(runId, step.id, 'failed', failure, emit);
|
|
2461
|
+
runError = `step "${step.id}" failed: ${failure}`;
|
|
2462
|
+
break;
|
|
2463
|
+
}
|
|
2464
|
+
this.finishStep(runId, step.id, 'done', undefined, emit);
|
|
2465
|
+
i++;
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
const { ok, output } = await this.runCheckStep(state, step, emit);
|
|
2469
|
+
if (state.cancelled)
|
|
2470
|
+
break;
|
|
2471
|
+
if (ok) {
|
|
2472
|
+
this.finishStep(runId, step.id, 'done', undefined, emit);
|
|
2473
|
+
i++;
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
const used = retriesUsed.get(step.id) ?? 0;
|
|
2477
|
+
if (step.onFail && used < step.onFail.max) {
|
|
2478
|
+
retriesUsed.set(step.id, used + 1);
|
|
2479
|
+
checkFailure = output;
|
|
2480
|
+
this.finishStep(runId, step.id, 'failed', 'check failed — looping back', emit);
|
|
2481
|
+
const retryIdx = workflow.steps.findIndex((s) => s.id === step.onFail?.retry);
|
|
2482
|
+
emit({
|
|
2483
|
+
type: 'note',
|
|
2484
|
+
stepId: step.id,
|
|
2485
|
+
message: `check failed — retrying from "${step.onFail.retry}" (attempt ${used + 1}/${step.onFail.max})`,
|
|
2486
|
+
});
|
|
2487
|
+
// Steps we're about to re-run go back to pending so the GUI rail
|
|
2488
|
+
// reads top-to-bottom truthfully.
|
|
2489
|
+
for (const s of workflow.steps.slice(retryIdx, i + 1)) {
|
|
2490
|
+
this.store.updateStep(runId, s.id, { status: 'pending' });
|
|
2491
|
+
}
|
|
2492
|
+
i = retryIdx;
|
|
2493
|
+
continue;
|
|
2494
|
+
}
|
|
2495
|
+
this.finishStep(runId, step.id, 'failed', `\`${step.command}\` exited non-zero`, emit);
|
|
2496
|
+
runError = `check "${step.id}" failed${step.onFail ? ` after ${used + 1} attempts` : ''}`;
|
|
2497
|
+
break;
|
|
2498
|
+
}
|
|
2499
|
+
// Final autosave: the branch always ends holding the finished state.
|
|
2500
|
+
this.clearAutosaveTimer(state);
|
|
2501
|
+
if (state.cwd !== this.repoRoot)
|
|
2502
|
+
await autosaveCommit(state.cwd, 'run finalize');
|
|
2503
|
+
const finishedAt = new Date().toISOString();
|
|
2504
|
+
if (state.cancelled) {
|
|
2505
|
+
const run = this.store.getRun(runId);
|
|
2506
|
+
for (const s of run?.steps ?? []) {
|
|
2507
|
+
if (s.status === 'running' || s.status === 'waiting') {
|
|
2508
|
+
this.store.updateStep(runId, s.id, { status: 'cancelled' });
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
this.store.updateRun(runId, { status: 'cancelled', finishedAt, currentStepId: undefined });
|
|
2512
|
+
emit({ type: 'lifecycle', message: 'run cancelled' });
|
|
2513
|
+
}
|
|
2514
|
+
else if (runError) {
|
|
2515
|
+
this.store.updateRun(runId, { status: 'failed', error: runError, finishedAt, currentStepId: undefined });
|
|
2516
|
+
emit({ type: 'lifecycle', message: `run failed — ${runError}` });
|
|
2517
|
+
}
|
|
2518
|
+
else {
|
|
2519
|
+
await this.settleSuccess(runId);
|
|
2520
|
+
}
|
|
2521
|
+
this.clearIdleTimer(state);
|
|
2522
|
+
this.dropActive(runId);
|
|
2523
|
+
}
|
|
2524
|
+
/** Returns an error message, or null on success. */
|
|
2525
|
+
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images, taskBackend, extraSystemPrompt,
|
|
2526
|
+
/** The chain-boundary note for this step (#410), or undefined when the
|
|
2527
|
+
* workflow has a single agent step and there is no boundary to explain. */
|
|
2528
|
+
chainNote,
|
|
2529
|
+
/** Pasted attachments already materialized to disk (#357) — their absolute
|
|
2530
|
+
* paths are appended to `userPrompt` so the agent can operate on the
|
|
2531
|
+
* real files, not just view the inline image blocks. */
|
|
2532
|
+
attachments = []) {
|
|
2533
|
+
let systemPrompt;
|
|
2534
|
+
if (step.skill) {
|
|
2535
|
+
const skill = skills.find((s) => s.name === step.skill);
|
|
2536
|
+
if (skill) {
|
|
2537
|
+
// The body alone often does not identify the selected skill. Keep its
|
|
2538
|
+
// name and catalog description in the normalized runner payload so a
|
|
2539
|
+
// numeric task such as "432" still gives the model enough context to
|
|
2540
|
+
// describe the work — and therefore derive a useful title (#432).
|
|
2541
|
+
systemPrompt = skillSystemPrompt(skill);
|
|
2542
|
+
// Directory team skills (SKILL.md + references/) get materialized
|
|
2543
|
+
// into <cwd>/.claude/skills/<name>/ — the run's worktree when there
|
|
2544
|
+
// is one — so claude sees the companion files on disk; the shared
|
|
2545
|
+
// info/exclude keeps them out of git (and out of autosave commits).
|
|
2546
|
+
if (skill.source === 'team' && skill.team?.dir) {
|
|
2547
|
+
const seeded = await materializeSkillDir(state.cwd, skill).catch(() => false);
|
|
2548
|
+
if (seeded) {
|
|
2549
|
+
emit({
|
|
2550
|
+
type: 'note',
|
|
2551
|
+
stepId: step.id,
|
|
2552
|
+
message: `team skill "${skill.name}" materialized to .claude/skills/${skill.name}/`,
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
else {
|
|
2558
|
+
emit({
|
|
2559
|
+
type: 'note',
|
|
2560
|
+
stepId: step.id,
|
|
2561
|
+
message: `skill "${step.skill}" not found in .ai/cezar/skills, .ai/skills or the team skills repo — running with the plain prompt`,
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
let userPrompt = applyTemplate(step.prompt ?? '{{task}}', input.task);
|
|
2566
|
+
if (chainNote)
|
|
2567
|
+
userPrompt = `${chainNote}\n\n---\n\n${userPrompt}`;
|
|
2568
|
+
if (checkFailure) {
|
|
2569
|
+
userPrompt += `\n\nA verification command failed after the previous attempt. Fix the cause. Failing output:\n\n${checkFailure}`;
|
|
2570
|
+
}
|
|
2571
|
+
if (images?.length) {
|
|
2572
|
+
emit({
|
|
2573
|
+
type: 'note',
|
|
2574
|
+
stepId: step.id,
|
|
2575
|
+
message: `${images.length} screenshot${images.length > 1 ? 's' : ''} attached to the task`,
|
|
2576
|
+
});
|
|
2577
|
+
// Point the agent at the on-disk files for the pasted subset (#357) — the
|
|
2578
|
+
// base64 blocks above still let it *view* the images; this is what lets it
|
|
2579
|
+
// *use* them as files (save, attach to an issue/PR, copy into the repo).
|
|
2580
|
+
if (attachments.length)
|
|
2581
|
+
userPrompt += `\n\n${pastedAttachmentsText(attachments)}`;
|
|
2582
|
+
}
|
|
2583
|
+
const sessionId = randomUUID();
|
|
2584
|
+
const backend = step.runner ?? taskBackend;
|
|
2585
|
+
this.store.updateStep(runId, step.id, { sessionId, backend });
|
|
2586
|
+
const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
|
|
2587
|
+
const startTokens = stepRecord?.tokensUsed ?? 0;
|
|
2588
|
+
let stepCost = stepRecord?.costUsd ?? 0;
|
|
2589
|
+
let turnText = '';
|
|
2590
|
+
let sessionError;
|
|
2591
|
+
const sink = this.makeUiSink(runId, step.id);
|
|
2592
|
+
const onEvent = (event) => {
|
|
2593
|
+
if (event.type === 'image') {
|
|
2594
|
+
const saved = this.persistImage(runId, event.mediaType, event.data);
|
|
2595
|
+
if (saved)
|
|
2596
|
+
emit({ type: 'image', stepId: step.id, ...saved });
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
if (event.type === 'text') {
|
|
2600
|
+
turnText = appendTurnText(turnText, event.text);
|
|
2601
|
+
const text = stripAskMarker(stripTaskMarkers(stripMonitoringMarker(stripDoneMarker(event.text))));
|
|
2602
|
+
if (text)
|
|
2603
|
+
emit({ type: 'text', text, stepId: step.id });
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
emit({ ...event, stepId: step.id });
|
|
2607
|
+
if (event.type === 'error') {
|
|
2608
|
+
sessionError ??= event.message;
|
|
2609
|
+
state.session?.interrupt();
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
if (sessionError)
|
|
2613
|
+
return;
|
|
2614
|
+
if (event.type === 'session') {
|
|
2615
|
+
// Codex/OpenCode mint their own session id — persist it so resume works.
|
|
2616
|
+
this.store.updateStep(runId, step.id, { sessionId: event.sessionId, backend });
|
|
2617
|
+
}
|
|
2618
|
+
if (event.type === 'token-usage') {
|
|
2619
|
+
this.store.updateStep(runId, step.id, { tokensUsed: startTokens + event.tokensUsed });
|
|
2620
|
+
}
|
|
2621
|
+
if (event.type === 'cost') {
|
|
2622
|
+
stepCost += event.usd;
|
|
2623
|
+
this.store.updateStep(runId, step.id, { costUsd: stepCost });
|
|
2624
|
+
}
|
|
2625
|
+
if (event.type === 'turn-end') {
|
|
2626
|
+
// v2 `turn.completed` already flushed the coalescers; the v1 turn
|
|
2627
|
+
// boundary flushes again (idempotent) as a backstop.
|
|
2628
|
+
sink.flushAll();
|
|
2629
|
+
void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
|
|
2630
|
+
const sessionOpen = !state.cancelled && state.session?.open;
|
|
2631
|
+
const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
2632
|
+
// `CEZ:ASK` → the user is blocked; wins over `CEZ:MONITORING`, loses to
|
|
2633
|
+
// `CEZ:DONE` (#473).
|
|
2634
|
+
const askResult = interactive && sessionOpen && !done ? parseAskMarkerResult(turnText) : undefined;
|
|
2635
|
+
const ask = askResult?.kind === 'valid' ? askResult.request : null;
|
|
2636
|
+
const askRejection = askResult ? askMarkerRejection(askResult) : undefined;
|
|
2637
|
+
const monitoring = interactive &&
|
|
2638
|
+
sessionOpen &&
|
|
2639
|
+
!done &&
|
|
2640
|
+
!ask &&
|
|
2641
|
+
MONITORING_MARKER_RE.test(turnText.trimEnd());
|
|
2642
|
+
turnText = '';
|
|
2643
|
+
if (askRejection)
|
|
2644
|
+
emit({ type: 'note', stepId: step.id, message: askRejection });
|
|
2645
|
+
if (done) {
|
|
2646
|
+
// Goal achieved (agent contract, #347): close the session instead
|
|
2647
|
+
// of parking at `waiting` — the run completes and frees its slot.
|
|
2648
|
+
emit({ type: 'lifecycle', message: 'goal achieved — session closed' });
|
|
2649
|
+
appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
|
|
2650
|
+
state.session?.end();
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
const waiting = interactive && sessionOpen;
|
|
2654
|
+
if (waiting) {
|
|
2655
|
+
// Turn over, session open. Either the ball is in the user's court
|
|
2656
|
+
// (`waiting`) — optionally with a structured `CEZ:ASK` question the
|
|
2657
|
+
// cockpit renders as an ask card (#473) — or the agent declared it is
|
|
2658
|
+
// still working on its own downstream work with `CEZ:MONITORING`, which
|
|
2659
|
+
// parks as `running`/`activity:'monitoring'`, a non-attention state,
|
|
2660
|
+
// instead of raising "needs you" (#490). Lifecycle is identical: the
|
|
2661
|
+
// run frees its slot and keeps the idle timer.
|
|
2662
|
+
if (ask)
|
|
2663
|
+
emitAskRequested(sink, ask);
|
|
2664
|
+
if (monitoring) {
|
|
2665
|
+
this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
|
|
2666
|
+
this.store.updateStep(runId, step.id, { status: 'running' });
|
|
2667
|
+
this.monitoring.add(runId);
|
|
2668
|
+
this.clearIdleTimer(state);
|
|
2669
|
+
this.armMonitoringWakeTimer(runId, state);
|
|
2670
|
+
}
|
|
2671
|
+
else {
|
|
2672
|
+
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
2673
|
+
this.store.updateStep(runId, step.id, { status: 'waiting' });
|
|
2674
|
+
this.monitoring.delete(runId);
|
|
2675
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
2676
|
+
}
|
|
2677
|
+
this.waiting.add(runId);
|
|
2678
|
+
if (!monitoring)
|
|
2679
|
+
this.armIdleTimer(runId, state);
|
|
2680
|
+
this.releaseSlot(); // the freed slot can start a queued run right away — in any project
|
|
2681
|
+
}
|
|
2682
|
+
// The window is proven open — see the twin in `runContinuation`.
|
|
2683
|
+
if (this.store.getRun(runId)?.autoResumeAttempts !== undefined) {
|
|
2684
|
+
this.store.updateRun(runId, { autoResumeAttempts: undefined });
|
|
2685
|
+
}
|
|
2686
|
+
// Cez's own heartbeat — the handoff stays current even when the
|
|
2687
|
+
// agent forgets to write (spec 007).
|
|
2688
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
|
|
2689
|
+
}
|
|
2690
|
+
};
|
|
2691
|
+
const stepBackend = step.runner ?? taskBackend;
|
|
2692
|
+
// Normalise the selected model to canonical `provider/model` and back to the
|
|
2693
|
+
// backend's own wire form via the ONE shared mapper (#405). Fail-loud: an
|
|
2694
|
+
// unresolvable model (e.g. a bare id on opencode) returns the step error
|
|
2695
|
+
// instead of letting the backend silently substitute its default.
|
|
2696
|
+
let backendModel;
|
|
2697
|
+
try {
|
|
2698
|
+
const normalized = normalizeModelForBackend(stepBackend, agentModelsLocked(this.repoRoot) ? undefined : step.model ?? input.model, { configuredProvider: await configuredModelProvider(stepBackend, state.cwd) });
|
|
2699
|
+
backendModel = normalized?.backendModel;
|
|
2700
|
+
// Persist the identity of what ACTUALLY runs (#405, review M1). The run-start echo
|
|
2701
|
+
// (line ~993) is best-effort from `taskBackend`/`input.model`; a per-step `runner`/`model`
|
|
2702
|
+
// override makes it assert a model that never ran. Re-write it here, from the resolved
|
|
2703
|
+
// step identity, so the record — the product of this PR — is always one that ran.
|
|
2704
|
+
this.store.updateRun(runId, {
|
|
2705
|
+
modelIdentity: normalized ? formatModelIdentity(normalized.identity) : undefined,
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
catch (err) {
|
|
2709
|
+
if (err instanceof ModelIdentityError)
|
|
2710
|
+
return err.message;
|
|
2711
|
+
throw err;
|
|
2712
|
+
}
|
|
2713
|
+
// Which agent account this step spawns under, and — recorded on the step before the spawn —
|
|
2714
|
+
// which one its session belongs to. `sessionId` and `profileId` are a pair: a resume that
|
|
2715
|
+
// reads the wrong account's config dir finds no session and silently starts a fresh one.
|
|
2716
|
+
// Resolved together with the temp-directory preflight (#785): the step fails with a named,
|
|
2717
|
+
// actionable error instead of spawning a backend whose shell would return empty output.
|
|
2718
|
+
let stepProfile;
|
|
2719
|
+
try {
|
|
2720
|
+
stepProfile = await this.agentEnvForStep(runId, stepBackend, {
|
|
2721
|
+
generateFollowups: followupsEnabled() && input.generateFollowups !== false,
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
catch (err) {
|
|
2725
|
+
if (err instanceof AgentTempDirError)
|
|
2726
|
+
return err.message;
|
|
2727
|
+
throw err;
|
|
2728
|
+
}
|
|
2729
|
+
this.store.updateStep(runId, step.id, { profileId: stepProfile.profileId });
|
|
2730
|
+
const runner = createRunner(stepBackend);
|
|
2731
|
+
let session;
|
|
2732
|
+
state.currentStepId = step.id;
|
|
2733
|
+
this.beginUsageInvocation(runId, state, step.id);
|
|
2734
|
+
try {
|
|
2735
|
+
session = runner.startSession({
|
|
2736
|
+
// Skill body, then the run's extra prompt (POST override or config
|
|
2737
|
+
// default), then the handoff/todos contract — every agent step.
|
|
2738
|
+
systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, followupsEnabled() && input.generateFollowups !== false
|
|
2739
|
+
? HANDOFF_INSTRUCTIONS
|
|
2740
|
+
: HANDOFF_ONLY_INSTRUCTIONS),
|
|
2741
|
+
userPrompt,
|
|
2742
|
+
images,
|
|
2743
|
+
cwd: state.cwd,
|
|
2744
|
+
allowedTools: allowedToolsForStep(step, stepBackend),
|
|
2745
|
+
bashAllowlist: step.bashAllowlist,
|
|
2746
|
+
// The handoff file lives outside the worktree — grant access.
|
|
2747
|
+
additionalDirectories: agentDirectories(join(this.dataDir, 'runs'), stepProfile.env),
|
|
2748
|
+
env: stepProfile.env,
|
|
2749
|
+
model: backendModel,
|
|
2750
|
+
sessionId,
|
|
2751
|
+
// Interactive sessions have no wall clock — the idle timer rules.
|
|
2752
|
+
timeoutMs: interactive ? 0 : undefined,
|
|
2753
|
+
}, onEvent, {
|
|
2754
|
+
autoEndAfterFirstTurn: !interactive,
|
|
2755
|
+
onUiEvent: (event) => this.handleRunnerUiEvent(runId, state, sink, event),
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2758
|
+
catch (err) {
|
|
2759
|
+
state.currentStepId = undefined;
|
|
2760
|
+
return err instanceof Error ? err.message : String(err);
|
|
2761
|
+
}
|
|
2762
|
+
state.session = session;
|
|
2763
|
+
state.sessionEverOpened = true;
|
|
2764
|
+
this.flushDeferred(runId);
|
|
2765
|
+
state.currentStepId = step.id;
|
|
2766
|
+
state.interrupt = () => session.interrupt();
|
|
2767
|
+
if (session.pid !== undefined)
|
|
2768
|
+
registerRunProcess(runId, session.pid);
|
|
2769
|
+
try {
|
|
2770
|
+
const result = await session.result;
|
|
2771
|
+
if (sessionError) {
|
|
2772
|
+
sink.sessionEnded('error', sessionError);
|
|
2773
|
+
return sessionError;
|
|
2774
|
+
}
|
|
2775
|
+
// v2 counterpart of v1's `done` (spec: the mappers leave session-close
|
|
2776
|
+
// events to the RunManager — only it knows how the session settled).
|
|
2777
|
+
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
2778
|
+
this.store.updateStep(runId, step.id, { tokensUsed: startTokens + result.tokensUsed });
|
|
2779
|
+
return null;
|
|
2780
|
+
}
|
|
2781
|
+
catch (err) {
|
|
2782
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2783
|
+
sink.sessionEnded('error', message); // alongside v1's fatal `error`
|
|
2784
|
+
return message;
|
|
2785
|
+
}
|
|
2786
|
+
finally {
|
|
2787
|
+
this.recordUsagePeaks(runId);
|
|
2788
|
+
this.clearIdleTimer(state);
|
|
2789
|
+
this.monitoring.delete(runId);
|
|
2790
|
+
this.waiting.delete(runId);
|
|
2791
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
2792
|
+
state.session = undefined;
|
|
2793
|
+
state.currentStepId = undefined;
|
|
2794
|
+
state.interrupt = () => undefined;
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
/**
|
|
2798
|
+
* Protocol-v2 sink for one agent session (R2 step 2.1): the runner's
|
|
2799
|
+
* `onUiEvent` stream flows through here. Persisted snapshots ride the same
|
|
2800
|
+
* NDJSON file as v1 (the store stamps `seq`/`ts`, `appendEvent` fans them
|
|
2801
|
+
* out live too); coalesced `item.delta` flushes go out live-only via
|
|
2802
|
+
* `emitEphemeral` — raw deltas never hit disk (spec §performance
|
|
2803
|
+
* guardrails). One sink per session: cumulative usage dedup and the
|
|
2804
|
+
* item-shape cache are session-scoped, like the mapper state feeding them.
|
|
2805
|
+
*/
|
|
2806
|
+
makeUiSink(runId, stepId) {
|
|
2807
|
+
return new UiEventSink({
|
|
2808
|
+
persist: (event) => this.store.appendEvent(runId, { ...event, stepId }),
|
|
2809
|
+
emitLive: (event) => this.store.emitEphemeral(runId, { ...event, stepId }),
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
/** Native backend asks arrive before turn-end. Persist and park immediately
|
|
2813
|
+
* so the cockpit shows attention and the run releases its workspace slot. */
|
|
2814
|
+
handleRunnerUiEvent(runId, state, sink, event) {
|
|
2815
|
+
this.recordUsageUiEvent(runId, state, event);
|
|
2816
|
+
sink.handle(event);
|
|
2817
|
+
if (event.type !== 'ask.requested' || state.cancelled)
|
|
2818
|
+
return;
|
|
2819
|
+
this.clearIdleTimer(state);
|
|
2820
|
+
this.monitoring.delete(runId);
|
|
2821
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
2822
|
+
this.waiting.add(runId);
|
|
2823
|
+
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
2824
|
+
if (state.currentStepId)
|
|
2825
|
+
this.store.updateStep(runId, state.currentStepId, { status: 'waiting' });
|
|
2826
|
+
this.releaseSlot();
|
|
2827
|
+
}
|
|
2828
|
+
/** Persist the invocation checkpoint before launching a runner. A throw or
|
|
2829
|
+
* process exit before `turn.started` therefore leaves a durable mismatch. */
|
|
2830
|
+
beginUsageInvocation(runId, state, stepId) {
|
|
2831
|
+
const step = this.store.getRun(runId)?.steps.find((candidate) => candidate.id === stepId);
|
|
2832
|
+
if (!step)
|
|
2833
|
+
return;
|
|
2834
|
+
const epoch = (step.usageInvocationEpoch ?? 0) + 1;
|
|
2835
|
+
this.persistUsageCheckpoint(runId, stepId, {
|
|
2836
|
+
usageInvocationEpoch: epoch,
|
|
2837
|
+
usageInvocationsStarted: (step.usageInvocationsStarted ?? 0) + 1,
|
|
2838
|
+
});
|
|
2839
|
+
state.usageInvocation = {
|
|
2840
|
+
stepId,
|
|
2841
|
+
epoch,
|
|
2842
|
+
observed: false,
|
|
2843
|
+
startedTurns: new Set(),
|
|
2844
|
+
recordedTurns: new Set(),
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
/** Fold backend-neutral completed-turn usage into the current step exactly
|
|
2848
|
+
* once. Invocation/turn counters are written before the event reaches the
|
|
2849
|
+
* NDJSON sink so crashes cannot preserve a falsely complete subtotal. */
|
|
2850
|
+
recordUsageUiEvent(runId, state, event) {
|
|
2851
|
+
const invocation = state.usageInvocation;
|
|
2852
|
+
if (!invocation)
|
|
2853
|
+
return;
|
|
2854
|
+
const step = this.store.getRun(runId)?.steps.find((candidate) => candidate.id === invocation.stepId);
|
|
2855
|
+
if (!step)
|
|
2856
|
+
return;
|
|
2857
|
+
if (event.type === 'turn.started') {
|
|
2858
|
+
if (invocation.startedTurns.has(event.turnId))
|
|
2859
|
+
return;
|
|
2860
|
+
invocation.startedTurns.add(event.turnId);
|
|
2861
|
+
const firstObservedTurn = !invocation.observed;
|
|
2862
|
+
invocation.observed = true;
|
|
2863
|
+
this.persistUsageCheckpoint(runId, invocation.stepId, {
|
|
2864
|
+
usageTurnsStarted: (step.usageTurnsStarted ?? 0) + 1,
|
|
2865
|
+
...(firstObservedTurn
|
|
2866
|
+
? { usageInvocationsObserved: (step.usageInvocationsObserved ?? 0) + 1 }
|
|
2867
|
+
: {}),
|
|
2868
|
+
});
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
if (event.type !== 'turn.completed')
|
|
2872
|
+
return;
|
|
2873
|
+
if (!invocation.startedTurns.has(event.turnId) || invocation.recordedTurns.has(event.turnId))
|
|
2874
|
+
return;
|
|
2875
|
+
const input = event.usage?.input;
|
|
2876
|
+
const output = event.usage?.output;
|
|
2877
|
+
if (typeof input !== 'number' ||
|
|
2878
|
+
!Number.isFinite(input) ||
|
|
2879
|
+
input < 0 ||
|
|
2880
|
+
typeof output !== 'number' ||
|
|
2881
|
+
!Number.isFinite(output) ||
|
|
2882
|
+
output < 0) {
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
invocation.recordedTurns.add(event.turnId);
|
|
2886
|
+
this.persistUsageCheckpoint(runId, invocation.stepId, {
|
|
2887
|
+
inputTokens: (step.inputTokens ?? 0) + input,
|
|
2888
|
+
outputTokens: (step.outputTokens ?? 0) + output,
|
|
2889
|
+
usageTurnsRecorded: (step.usageTurnsRecorded ?? 0) + 1,
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
/** Usage completeness is a crash boundary, unlike high-frequency token
|
|
2893
|
+
* snapshots: the checkpoint must reach `runs.json` before the runner starts
|
|
2894
|
+
* or the matching UI event is persisted and forwarded. */
|
|
2895
|
+
persistUsageCheckpoint(runId, stepId, patch) {
|
|
2896
|
+
this.store.updateStep(runId, stepId, patch);
|
|
2897
|
+
this.store.flush();
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Turn-end bookkeeping (#389), shared by `runAgentStep` and
|
|
2901
|
+
* `runContinuation` — called (fire-and-forget) from every `turn-end` event:
|
|
2902
|
+
*
|
|
2903
|
+
* - `titleSummary`: derived from the turn's text, set ONCE — only while the
|
|
2904
|
+
* record has none. A user's inline edit also lands in `titleSummary`
|
|
2905
|
+
* (see `PATCH /api/runs/:id`), so an edit is never overwritten either.
|
|
2906
|
+
* - `diffStat`: cheap `git diff --shortstat` vs the base, refreshed every
|
|
2907
|
+
* turn. Async and best-effort — a git failure becomes at most a `note`
|
|
2908
|
+
* event, NEVER a run failure. `updateRun` fans the record out over SSE,
|
|
2909
|
+
* so the list views pick both up with no extra wiring.
|
|
2910
|
+
*
|
|
2911
|
+
* Not `private` so the integration tests can drive a turn-end directly —
|
|
2912
|
+
* a real agent session is the only other way to reach this path.
|
|
2913
|
+
*/
|
|
2914
|
+
/**
|
|
2915
|
+
* The namer's apply path (task auto-naming spec). Fire-and-forget: called
|
|
2916
|
+
* without await from `startRun` (creation) and `recordTurnEnd` (live
|
|
2917
|
+
* refresh). A user-owned title (`titleOrigin: 'user'`) is never overwritten;
|
|
2918
|
+
* namer-owned titles may be replaced by fresher namer results.
|
|
2919
|
+
*/
|
|
2920
|
+
async autoNameRun(runId, skillName, task, live) {
|
|
2921
|
+
// CEZ_AUTONAME=0 kills all LLM naming; dry-run skips it too unless
|
|
2922
|
+
// CEZ_AUTONAME=1 forces the mock path — see autoNamingActive.
|
|
2923
|
+
if (!autoNamingActive())
|
|
2924
|
+
return;
|
|
2925
|
+
try {
|
|
2926
|
+
let skillDescription;
|
|
2927
|
+
if (skillName) {
|
|
2928
|
+
const skills = await discoverSkills(this.repoRoot).catch(() => []);
|
|
2929
|
+
skillDescription = skills.find((s) => s.name === skillName)?.description;
|
|
2930
|
+
}
|
|
2931
|
+
const result = await generateRunName(this.repoRoot, { task, skillName, skillDescription, ...live });
|
|
2932
|
+
if (!result)
|
|
2933
|
+
return;
|
|
2934
|
+
const run = this.store.getRun(runId);
|
|
2935
|
+
// Marker-owned state outranks the namer (spec 2026-07-18-task-ref-markers):
|
|
2936
|
+
// a declared title blocks the whole apply (this call raced the marker),
|
|
2937
|
+
// and a declared pr/issue kind blocks that kind field-by-field.
|
|
2938
|
+
if (!run || run.titleOrigin === 'user' || run.titleOrigin === 'marker')
|
|
2939
|
+
return;
|
|
2940
|
+
this.store.updateRun(runId, {
|
|
2941
|
+
titleSummary: result.titleSummary,
|
|
2942
|
+
titleOrigin: 'auto',
|
|
2943
|
+
...(result.prNumber !== undefined && run.markerRefs?.pr === undefined
|
|
2944
|
+
? { prNumber: result.prNumber }
|
|
2945
|
+
: {}),
|
|
2946
|
+
...(result.issueNumber !== undefined && run.markerRefs?.issue === undefined
|
|
2947
|
+
? { issueNumber: result.issueNumber }
|
|
2948
|
+
: {}),
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
catch {
|
|
2952
|
+
// Naming is best-effort — nothing here may disturb the run.
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
async recordTurnEnd(runId, turnText) {
|
|
2956
|
+
try {
|
|
2957
|
+
const run = this.store.getRun(runId);
|
|
2958
|
+
if (!run)
|
|
2959
|
+
return;
|
|
2960
|
+
this.applyTurnMarkers(runId, run, turnText);
|
|
2961
|
+
// Titles are the namer's job (task auto-naming spec) — turn text is
|
|
2962
|
+
// deliberately NEVER a title source; see maybeRefreshTitle below. The
|
|
2963
|
+
// one exception is an explicit CEZ:TITLE declaration (applied above).
|
|
2964
|
+
if (run.worktreePath && existsSync(run.worktreePath)) {
|
|
2965
|
+
// `taskBranch` + `runStartedAt` are what keep this number *this task's* (#751): a
|
|
2966
|
+
// review/QA run repoints the worktree onto the branch under review, and without the
|
|
2967
|
+
// branch to compare HEAD against and the moment it was checked out, the stat would
|
|
2968
|
+
// claim that whole branch's diff.
|
|
2969
|
+
const stat = await worktreeShortstat(run.worktreePath, run.baseBranch ?? 'HEAD', {
|
|
2970
|
+
taskBranch: run.branch,
|
|
2971
|
+
runStartedAt: run.startedAt,
|
|
2972
|
+
});
|
|
2973
|
+
if (stat)
|
|
2974
|
+
this.store.updateRun(runId, { diffStat: stat });
|
|
2975
|
+
else
|
|
2976
|
+
this.store.appendEvent(runId, { type: 'note', message: 'diff stat unavailable — git diff --shortstat failed in the worktree' });
|
|
2977
|
+
}
|
|
2978
|
+
await this.maybeRefreshTitle(runId, turnText);
|
|
2979
|
+
}
|
|
2980
|
+
catch {
|
|
2981
|
+
// Bookkeeping only — nothing here may disturb the run.
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* In-band declarations from the finished turn (spec
|
|
2986
|
+
* 2026-07-18-task-ref-markers): the main thread's own `CEZ:PR=` /
|
|
2987
|
+
* `CEZ:ISSUE=` / `CEZ:TITLE=` lines, parsed from the accumulated turn text
|
|
2988
|
+
* like `CEZ:DONE` — never from tool output. Declared numbers overwrite the
|
|
2989
|
+
* regex/namer display tier (the store re-resolves the referenced-PR chip);
|
|
2990
|
+
* a declared title takes `titleOrigin: 'marker'`, which beats the namer but
|
|
2991
|
+
* never a user rename, and silences the live refresh below.
|
|
2992
|
+
*/
|
|
2993
|
+
applyTurnMarkers(runId, run, turnText) {
|
|
2994
|
+
const markers = parseTaskMarkers(turnText);
|
|
2995
|
+
if (markers.pr !== undefined || markers.issue !== undefined) {
|
|
2996
|
+
this.store.applyMarkerRefs(runId, { pr: markers.pr, issue: markers.issue });
|
|
2997
|
+
}
|
|
2998
|
+
if (markers.title && run.titleOrigin !== 'user') {
|
|
2999
|
+
const current = this.store.getRun(runId);
|
|
3000
|
+
const refNumber = current?.prNumber ?? current?.issueNumber;
|
|
3001
|
+
const validated = postValidateTitle(markers.title, refNumber);
|
|
3002
|
+
// Same junk guard as composeNameResult: a declaration that validates to
|
|
3003
|
+
// nothing (or to a bare number prefix) must not blank the title.
|
|
3004
|
+
if (validated && validated !== `${refNumber}:`) {
|
|
3005
|
+
this.store.updateRun(runId, { titleSummary: validated, titleOrigin: 'marker' });
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* Live title refresh (task auto-naming spec, step 3): re-run the namer with
|
|
3011
|
+
* the turn's context. Skips: toggle off (`liveTitleUpdates` config over
|
|
3012
|
+
* `CEZ_TITLE_UPDATES` env, default ON), user-owned title, marker-owned title
|
|
3013
|
+
* (the agent declares via `CEZ:TITLE` — the token-saving fast path), dry-run
|
|
3014
|
+
* mocks (canned answers add nothing), empty turn text, unchanged namer inputs.
|
|
3015
|
+
*/
|
|
3016
|
+
async maybeRefreshTitle(runId, turnText) {
|
|
3017
|
+
if (!autoNamingActive())
|
|
3018
|
+
return;
|
|
3019
|
+
if (!turnText.trim())
|
|
3020
|
+
return;
|
|
3021
|
+
const config = await loadConfig(this.repoRoot);
|
|
3022
|
+
if (!liveTitleUpdatesEnabled(config))
|
|
3023
|
+
return;
|
|
3024
|
+
const run = this.store.getRun(runId);
|
|
3025
|
+
if (!run || run.titleOrigin === 'user' || run.titleOrigin === 'marker')
|
|
3026
|
+
return;
|
|
3027
|
+
const statText = run.diffStat ? `${run.diffStat.files} files, +${run.diffStat.adds} -${run.diffStat.dels}` : undefined;
|
|
3028
|
+
const key = `${turnText.slice(0, 200)}|${statText ?? ''}`;
|
|
3029
|
+
if (this.lastNamerKey.get(runId) === key)
|
|
3030
|
+
return;
|
|
3031
|
+
this.lastNamerKey.set(runId, key);
|
|
3032
|
+
const workflow = await this.reviveWorkflow(run);
|
|
3033
|
+
const skillName = workflow?.steps.find((s) => stepKind(s) === 'agent' && s.skill)?.skill?.trim();
|
|
3034
|
+
void this.autoNameRun(runId, skillName, run.task, { turnText, diffStat: statText });
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* End-of-session telemetry (#348): stop sampling the run's process tree and
|
|
3038
|
+
* fold the session's peaks into the run record. `max` with existing values —
|
|
3039
|
+
* a run can hold several sessions (multiple agent steps, Continue) and the
|
|
3040
|
+
* record keeps the highest water mark across all of them.
|
|
3041
|
+
*/
|
|
3042
|
+
recordUsagePeaks(runId) {
|
|
3043
|
+
const peaks = unregisterRunProcess(runId);
|
|
3044
|
+
if (!peaks)
|
|
3045
|
+
return;
|
|
3046
|
+
const run = this.store.getRun(runId);
|
|
3047
|
+
this.store.updateRun(runId, {
|
|
3048
|
+
peakRssBytes: Math.max(run?.peakRssBytes ?? 0, peaks.peakRssBytes),
|
|
3049
|
+
peakProcCount: Math.max(run?.peakProcCount ?? 0, peaks.peakProcCount),
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Diff-first review gate (spec 009), shared by `execute` and
|
|
3054
|
+
* `runContinuation`: a *successful* run whose worktree holds changes rests
|
|
3055
|
+
* at `review` instead of `done` — the user inspects the diff first, then
|
|
3056
|
+
* sends feedback back, opens a draft PR, or just finishes. Failed/cancelled
|
|
3057
|
+
* runs never enter review; no worktree or an empty diff means plain `done`.
|
|
3058
|
+
*
|
|
3059
|
+
* The gate is opt-in (#489): the review park happens only when it is enabled
|
|
3060
|
+
* (`reviewGateEnabled` — config toggle over the `CEZ_REVIEW_GATE` env, default
|
|
3061
|
+
* OFF) AND the run is not autonomous. Autonomous runs — and runs with the gate
|
|
3062
|
+
* off — settle straight to `done`, leaving the diff in the worktree untouched.
|
|
3063
|
+
*/
|
|
3064
|
+
async settleSuccess(runId) {
|
|
3065
|
+
const run = this.store.getRun(runId);
|
|
3066
|
+
let review = false;
|
|
3067
|
+
if (run?.worktreePath && existsSync(run.worktreePath)) {
|
|
3068
|
+
const diff = await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD');
|
|
3069
|
+
const hasDiff = diff.trim().length > 0 && !diff.startsWith('(diff failed');
|
|
3070
|
+
const config = await loadConfig(this.repoRoot);
|
|
3071
|
+
review = hasDiff && reviewGateEnabled(config) && run.autonomous !== true;
|
|
3072
|
+
}
|
|
3073
|
+
this.store.updateRun(runId, {
|
|
3074
|
+
status: review ? 'review' : 'done',
|
|
3075
|
+
finishedAt: new Date().toISOString(),
|
|
3076
|
+
currentStepId: undefined,
|
|
3077
|
+
// A run that got all the way to a settled turn is not in a limit loop, so the resume
|
|
3078
|
+
// counter starts over — otherwise a task that legitimately met the limit once a week would
|
|
3079
|
+
// creep toward the cap forever and stop resuming for no reason anyone could see.
|
|
3080
|
+
autoResumeAttempts: undefined,
|
|
3081
|
+
});
|
|
3082
|
+
this.store.appendEvent(runId, {
|
|
3083
|
+
type: 'lifecycle',
|
|
3084
|
+
message: review
|
|
3085
|
+
? 'changes ready for review — send feedback, open a draft PR, or finish'
|
|
3086
|
+
: 'run finished',
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Agent screenshot (an image block inside a tool result) or a user-pasted
|
|
3091
|
+
* attachment: the base64 data never enters the NDJSON event log — it lands
|
|
3092
|
+
* as a file under `.ai/cezar/runs/<id>-images/` and the transcript event
|
|
3093
|
+
* carries only the name + serving URL. `namePrefix` distinguishes the two
|
|
3094
|
+
* origins on disk (`screenshot-<n>.<ext>` for agent tool screenshots,
|
|
3095
|
+
* `pasted-<n>.<ext>` for user-pasted attachments, #357) and the absolute
|
|
3096
|
+
* `path` lets the agent operate on the file directly (save/attach/upload).
|
|
3097
|
+
* Best effort: on failure the attachment is dropped, the transcript still
|
|
3098
|
+
* shows the tool result's `[screenshot]` placeholder (or the image count).
|
|
3099
|
+
*/
|
|
3100
|
+
persistImage(runId, mediaType, data, namePrefix = 'screenshot') {
|
|
3101
|
+
try {
|
|
3102
|
+
const ext = /png/.test(mediaType) ? 'png'
|
|
3103
|
+
: /jpe?g/.test(mediaType) ? 'jpg'
|
|
3104
|
+
: /webp/.test(mediaType) ? 'webp'
|
|
3105
|
+
: /gif/.test(mediaType) ? 'gif'
|
|
3106
|
+
: 'img';
|
|
3107
|
+
const dir = join(this.dataDir, 'runs', `${runId}-images`);
|
|
3108
|
+
mkdirSync(dir, { recursive: true });
|
|
3109
|
+
// Seed from the highest numeric suffix already on disk, NOT the file count:
|
|
3110
|
+
// `screenshot-*` and `pasted-*` share one numbering space, so counting would
|
|
3111
|
+
// re-issue a live number after any deletion. Only matters on the first write
|
|
3112
|
+
// of a process (restart case) — afterwards the map is authoritative.
|
|
3113
|
+
let seq = this.queuedImageSeq.get(runId);
|
|
3114
|
+
if (seq === undefined)
|
|
3115
|
+
seq = highestImageSeq(dir);
|
|
3116
|
+
// `persistImage` is fully synchronous, so two pastes cannot interleave between
|
|
3117
|
+
// the read of the counter and the write. The exclusive-create flag is the
|
|
3118
|
+
// belt-and-braces guard for a stale seed: it degrades to a renamed file rather
|
|
3119
|
+
// than a silent overwrite.
|
|
3120
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
3121
|
+
seq += 1;
|
|
3122
|
+
const name = `${namePrefix}-${seq}.${ext}`;
|
|
3123
|
+
const path = join(dir, name);
|
|
3124
|
+
try {
|
|
3125
|
+
writeFileSync(path, Buffer.from(data, 'base64'), { flag: 'wx' });
|
|
3126
|
+
}
|
|
3127
|
+
catch (err) {
|
|
3128
|
+
if (err.code === 'EEXIST')
|
|
3129
|
+
continue;
|
|
3130
|
+
throw err;
|
|
3131
|
+
}
|
|
3132
|
+
this.queuedImageSeq.set(runId, seq);
|
|
3133
|
+
// Versioned, because that is the only surface served now. The cockpit still upgrades
|
|
3134
|
+
// the unversioned URLs sitting in OLD transcripts when it renders them
|
|
3135
|
+
// (`resolveApiUrl`), but a URL minted today must be fetchable as written.
|
|
3136
|
+
return { name, url: `/api/v1/runs/${runId}/images/${name}`, path };
|
|
3137
|
+
}
|
|
3138
|
+
return null;
|
|
3139
|
+
}
|
|
3140
|
+
catch {
|
|
3141
|
+
return null;
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
armIdleTimer(runId, state) {
|
|
3145
|
+
this.clearIdleTimer(state);
|
|
3146
|
+
state.idleTimer = setTimeout(() => {
|
|
3147
|
+
if (state.session?.open && !state.cancelled) {
|
|
3148
|
+
this.store.appendEvent(runId, {
|
|
3149
|
+
type: 'lifecycle',
|
|
3150
|
+
message: `session closed after ${Math.round(IDLE_TIMEOUT_MS / 60_000)}m of inactivity`,
|
|
3151
|
+
});
|
|
3152
|
+
state.session.end();
|
|
3153
|
+
}
|
|
3154
|
+
}, IDLE_TIMEOUT_MS);
|
|
3155
|
+
state.idleTimer.unref?.();
|
|
3156
|
+
}
|
|
3157
|
+
clearIdleTimer(state) {
|
|
3158
|
+
if (state.idleTimer) {
|
|
3159
|
+
clearTimeout(state.idleTimer);
|
|
3160
|
+
state.idleTimer = undefined;
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
reconcileMonitoringWakeTimers() {
|
|
3164
|
+
for (const runId of this.monitoring) {
|
|
3165
|
+
const state = this.active.get(runId);
|
|
3166
|
+
if (state)
|
|
3167
|
+
this.armMonitoringWakeTimer(runId, state);
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
armMonitoringWakeTimer(runId, state) {
|
|
3171
|
+
const minutes = this.semaphore.monitoringWakeIntervalMinutes();
|
|
3172
|
+
if (minutes === null) {
|
|
3173
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
if ((state.monitoringWakeups ?? 0) >= MAX_AUTO_CONTINUES) {
|
|
3177
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
3178
|
+
if (!this.store.getRun(runId)?.monitoringWakeCapReached) {
|
|
3179
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: true });
|
|
3180
|
+
this.store.appendEvent(runId, {
|
|
3181
|
+
type: 'note',
|
|
3182
|
+
message: `automatic monitoring wake-up cap reached (${MAX_AUTO_CONTINUES}); session remains parked`,
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
if (state.monitoringWakeTimer && state.monitoringWakeIntervalMinutes === minutes)
|
|
3188
|
+
return;
|
|
3189
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
3190
|
+
state.monitoringWakeIntervalMinutes = minutes;
|
|
3191
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: undefined });
|
|
3192
|
+
const deadline = Date.now() + minutes * 60_000;
|
|
3193
|
+
this.store.updateRun(runId, { monitoringWakeAt: new Date(deadline).toISOString() });
|
|
3194
|
+
state.monitoringWakeTimer = setTimeout(() => {
|
|
3195
|
+
state.monitoringWakeTimer = undefined;
|
|
3196
|
+
this.store.updateRun(runId, { monitoringWakeAt: undefined });
|
|
3197
|
+
if (!this.monitoring.has(runId) || !state.session?.open || state.cancelled)
|
|
3198
|
+
return;
|
|
3199
|
+
const wakeups = state.monitoringWakeups ?? 0;
|
|
3200
|
+
if (wakeups >= MAX_AUTO_CONTINUES) {
|
|
3201
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: true });
|
|
3202
|
+
this.store.appendEvent(runId, {
|
|
3203
|
+
type: 'note',
|
|
3204
|
+
message: `automatic monitoring wake-up cap reached (${MAX_AUTO_CONTINUES}); session remains parked`,
|
|
3205
|
+
});
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
state.monitoringWakeups = wakeups + 1;
|
|
3209
|
+
this.store.appendEvent(runId, {
|
|
3210
|
+
type: 'note',
|
|
3211
|
+
message: `automatic monitoring wake-up (${state.monitoringWakeups}/${MAX_AUTO_CONTINUES})`,
|
|
3212
|
+
});
|
|
3213
|
+
this.deliverMessage(runId, [{ type: 'text', text: MONITORING_WAKE_NUDGE }], false);
|
|
3214
|
+
}, Math.max(0, deadline - Date.now()));
|
|
3215
|
+
state.monitoringWakeTimer.unref?.();
|
|
3216
|
+
}
|
|
3217
|
+
clearMonitoringWakeTimer(state, runId) {
|
|
3218
|
+
if (state.monitoringWakeTimer)
|
|
3219
|
+
clearTimeout(state.monitoringWakeTimer);
|
|
3220
|
+
state.monitoringWakeTimer = undefined;
|
|
3221
|
+
state.monitoringWakeIntervalMinutes = undefined;
|
|
3222
|
+
if (runId)
|
|
3223
|
+
this.store.updateRun(runId, { monitoringWakeAt: undefined });
|
|
3224
|
+
}
|
|
3225
|
+
/** Autosave-commit the worktree every 90 s while the run lives (spec 006).
|
|
3226
|
+
* Opt-in via CEZ_AUTOSAVE=1 (#471) — see periodicAutosaveEnabled. */
|
|
3227
|
+
armAutosave(state) {
|
|
3228
|
+
if (!periodicAutosaveEnabled())
|
|
3229
|
+
return;
|
|
3230
|
+
if (state.cwd === this.repoRoot || state.autosaveTimer)
|
|
3231
|
+
return;
|
|
3232
|
+
state.autosaveTimer = setInterval(() => {
|
|
3233
|
+
void autosaveCommit(state.cwd, 'periodic');
|
|
3234
|
+
}, AUTOSAVE_INTERVAL_MS);
|
|
3235
|
+
state.autosaveTimer.unref?.();
|
|
3236
|
+
}
|
|
3237
|
+
clearAutosaveTimer(state) {
|
|
3238
|
+
if (state.autosaveTimer) {
|
|
3239
|
+
clearInterval(state.autosaveTimer);
|
|
3240
|
+
state.autosaveTimer = undefined;
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
runCheckStep(state, step, emit) {
|
|
3244
|
+
const command = step.command;
|
|
3245
|
+
emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
|
|
3246
|
+
return new Promise((resolve) => {
|
|
3247
|
+
// Check steps run in the same cwd as the agent steps — the worktree.
|
|
3248
|
+
const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env });
|
|
3249
|
+
state.interrupt = () => child.kill('SIGTERM');
|
|
3250
|
+
let output = '';
|
|
3251
|
+
const collect = (chunk) => {
|
|
3252
|
+
if (output.length < CHECK_OUTPUT_CAP) {
|
|
3253
|
+
output += chunk.toString('utf8');
|
|
3254
|
+
if (output.length >= CHECK_OUTPUT_CAP)
|
|
3255
|
+
output += '\n… (output truncated)';
|
|
3256
|
+
}
|
|
3257
|
+
};
|
|
3258
|
+
child.stdout.on('data', collect);
|
|
3259
|
+
child.stderr.on('data', collect);
|
|
3260
|
+
child.on('error', (err) => {
|
|
3261
|
+
state.interrupt = () => undefined;
|
|
3262
|
+
const message = `failed to spawn: ${err.message}`;
|
|
3263
|
+
emit({ type: 'check-output', stepId: step.id, command, text: message, exitCode: -1 });
|
|
3264
|
+
resolve({ ok: false, output: message });
|
|
3265
|
+
});
|
|
3266
|
+
child.on('close', (code) => {
|
|
3267
|
+
state.interrupt = () => undefined;
|
|
3268
|
+
const trimmed = output.trim() || '(no output)';
|
|
3269
|
+
emit({ type: 'check-output', stepId: step.id, command, text: trimmed, exitCode: code ?? -1 });
|
|
3270
|
+
resolve({ ok: code === 0, output: trimmed });
|
|
3271
|
+
});
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
finishStep(runId, stepId, status, error, emit) {
|
|
3275
|
+
this.store.updateStep(runId, stepId, {
|
|
3276
|
+
status,
|
|
3277
|
+
error,
|
|
3278
|
+
finishedAt: new Date().toISOString(),
|
|
3279
|
+
});
|
|
3280
|
+
emit({ type: 'step-end', stepId, status, ...(error ? { error } : {}) });
|
|
3281
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=${status}`);
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
function findLastAgentStepIndex(workflow) {
|
|
3285
|
+
for (let i = workflow.steps.length - 1; i >= 0; i--) {
|
|
3286
|
+
const step = workflow.steps[i];
|
|
3287
|
+
if (step && stepKind(step) === 'agent')
|
|
3288
|
+
return i;
|
|
3289
|
+
}
|
|
3290
|
+
return -1;
|
|
3291
|
+
}
|
|
3292
|
+
function applyTemplate(template, task) {
|
|
3293
|
+
return template.replaceAll('{{task}}', task);
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Immediate title shown while a run is queued. The namer's `titleSummary`
|
|
3297
|
+
* replaces it once the model answers; this is the honest, permanent fallback
|
|
3298
|
+
* when no model is available (#432, spec 2026-07-17-task-auto-naming). When
|
|
3299
|
+
* the task references a PR/issue, the number leads: `469: /om-auto-review-pr`.
|
|
3300
|
+
*/
|
|
3301
|
+
export function makeRunTitle(task, workflow) {
|
|
3302
|
+
const firstLine = task.trim().split('\n')[0] ?? '';
|
|
3303
|
+
const skill = workflow.steps.find((step) => stepKind(step) === 'agent' && step.skill)?.skill?.trim();
|
|
3304
|
+
const contextual = skill && !firstLine.startsWith(`/${skill}`)
|
|
3305
|
+
? `/${skill}${firstLine ? ` ${firstLine}` : ''}`
|
|
3306
|
+
: firstLine;
|
|
3307
|
+
const refNumber = titleRefNumber(refineTaskRefs(extractTaskRefs(task), skill));
|
|
3308
|
+
// `469` or `/om-auto-review-pr 469` reads as `469: /om-auto-review-pr` — the
|
|
3309
|
+
// number leads so it survives the tasks table's narrow truncation.
|
|
3310
|
+
const skillArg = skill && contextual.startsWith(`/${skill}`) ? contextual.slice(skill.length + 1).trim() : null;
|
|
3311
|
+
const body = refNumber !== undefined && skill && (skillArg === '' || /^#?\d+$/.test(skillArg ?? ''))
|
|
3312
|
+
? `/${skill}`
|
|
3313
|
+
: contextual;
|
|
3314
|
+
const prefixed = refNumber !== undefined && !body.trimStart().replace(/^#/, '').startsWith(String(refNumber))
|
|
3315
|
+
? `${refNumber}: ${body}`
|
|
3316
|
+
: body;
|
|
3317
|
+
const chars = [...(prefixed || '(untitled task)')];
|
|
3318
|
+
return chars.length > 80 ? `${chars.slice(0, 79).join('').trimEnd()}…` : chars.join('');
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* Skill identity is context, while the Markdown body remains instructions.
|
|
3322
|
+
*
|
|
3323
|
+
* For an on-disk skill we also hand the agent the ABSOLUTE directory of the
|
|
3324
|
+
* installed copy. A run executes in an isolated worktree that has no local
|
|
3325
|
+
* `.agents/skills` (gitignored, absent in a fresh checkout), so without this
|
|
3326
|
+
* the agent cannot read the skill's companion files (`references/*.md`) — or,
|
|
3327
|
+
* worse, reads a stale copy materialized from the team-repo cache. The path
|
|
3328
|
+
* resolves against the MAIN project root (`discoverSkills(repoRoot)`), i.e. the
|
|
3329
|
+
* current `npx skills`-installed copy, so a worktree agent and the main
|
|
3330
|
+
* checkout read the exact same, up-to-date files. Team skills are omitted here:
|
|
3331
|
+
* they are materialized into the worktree separately (see the call site).
|
|
3332
|
+
*/
|
|
3333
|
+
export function skillSystemPrompt(skill) {
|
|
3334
|
+
const lines = [
|
|
3335
|
+
`Selected skill: /${skill.name}`,
|
|
3336
|
+
...(skill.description ? [`Description: ${skill.description}`] : []),
|
|
3337
|
+
];
|
|
3338
|
+
if (skill.source && skill.source !== 'team' && skill.path) {
|
|
3339
|
+
const dir = dirname(skill.path);
|
|
3340
|
+
lines.push('', `Skill files are installed on disk at: ${dir}`, `Read any file this skill references (for example references/*.md) from that absolute directory. ` +
|
|
3341
|
+
`It is the current installed copy — use it even though your working directory is a separate worktree that does not contain the skill.`);
|
|
3342
|
+
}
|
|
3343
|
+
lines.push('', 'Skill instructions:', skill.body.trim());
|
|
3344
|
+
return lines.join('\n');
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* Expand a registry-backed slash skill in one prompt string before it reaches a
|
|
3348
|
+
* backend. Claude otherwise intercepts an unknown leading slash command, and
|
|
3349
|
+
* Codex/OpenCode have no native slash-skill lookup at all (#676).
|
|
3350
|
+
*
|
|
3351
|
+
* Only a match at character zero counts, and unknown commands pass through
|
|
3352
|
+
* byte-for-byte — a backend's OWN slash commands must keep working. The caller
|
|
3353
|
+
* persists the original user text before applying this delivery-only rewrite.
|
|
3354
|
+
*
|
|
3355
|
+
* Both delivery seams route through here: live-session messages via
|
|
3356
|
+
* `expandRegistrySlashSkill`, and a continuation's opening prompt, which becomes
|
|
3357
|
+
* the session's `userPrompt` and never passes through `deliverMessage` at all
|
|
3358
|
+
* (#811).
|
|
3359
|
+
*/
|
|
3360
|
+
export function expandRegistrySlashSkillText(text, skills) {
|
|
3361
|
+
const match = /^\/([A-Za-z0-9][A-Za-z0-9._-]*)(?=\s|$)/.exec(text);
|
|
3362
|
+
if (!match)
|
|
3363
|
+
return text;
|
|
3364
|
+
const skill = skills.find((candidate) => candidate.name === match[1]);
|
|
3365
|
+
if (!skill)
|
|
3366
|
+
return text;
|
|
3367
|
+
const request = text.slice(match[0].length).trim();
|
|
3368
|
+
return request ? `${skillSystemPrompt(skill)}\n\nUser request:\n${request}` : skillSystemPrompt(skill);
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* `expandRegistrySlashSkillText` over a live chat message: only the first text
|
|
3372
|
+
* block is eligible, and an unchanged block returns the caller's array
|
|
3373
|
+
* identity untouched.
|
|
3374
|
+
*/
|
|
3375
|
+
export function expandRegistrySlashSkill(content, skills) {
|
|
3376
|
+
const textIndex = content.findIndex((block) => block.type === 'text');
|
|
3377
|
+
if (textIndex < 0)
|
|
3378
|
+
return content;
|
|
3379
|
+
const block = content[textIndex];
|
|
3380
|
+
if (!block || block.type !== 'text')
|
|
3381
|
+
return content;
|
|
3382
|
+
const text = expandRegistrySlashSkillText(block.text, skills);
|
|
3383
|
+
if (text === block.text)
|
|
3384
|
+
return content;
|
|
3385
|
+
const expanded = [...content];
|
|
3386
|
+
expanded[textIndex] = { type: 'text', text };
|
|
3387
|
+
return expanded;
|
|
3388
|
+
}
|
|
3389
|
+
//# sourceMappingURL=run.js.map
|