@open-mercato/cezar 0.9.1 → 0.9.2-pr762.1091
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 +32 -13
- 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 +9 -1
- package/dist/agent-config/catalog.js +18 -0
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/agent-config/files.d.ts +1 -1
- 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/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 +30 -0
- package/dist/agent-config/models.js.map +1 -0
- package/dist/agent-config/service.d.ts +1 -1
- package/dist/agent-config/service.js +4 -4
- package/dist/agent-config/service.js.map +1 -1
- package/dist/agent-config/validate.d.ts +1 -1
- 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 +251 -0
- package/dist/automations/types.js +158 -0
- package/dist/automations/types.js.map +1 -0
- package/dist/config.d.ts +17 -112
- package/dist/config.js +45 -4
- package/dist/config.js.map +1 -1
- package/dist/contract/agent-config.d.ts +151 -0
- package/dist/contract/agent-profiles.d.ts +332 -0
- package/dist/contract/automations.d.ts +919 -0
- package/dist/contract/events.d.ts +45 -0
- package/dist/contract/github.d.ts +467 -0
- package/dist/contract/health.d.ts +96 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.js +1640 -0
- package/dist/contract/projects.d.ts +185 -0
- package/dist/contract/repo.d.ts +234 -0
- package/dist/contract/runs.d.ts +1347 -0
- package/dist/contract/skills.d.ts +229 -0
- package/dist/contract/workflows.d.ts +212 -0
- package/dist/contract/workspace.d.ts +472 -0
- package/dist/core/agent-env.d.ts +1 -1
- 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 +59 -0
- package/dist/core/agent-profiles.js +83 -0
- package/dist/core/agent-profiles.js.map +1 -0
- package/dist/core/agent-runner.d.ts +12 -1
- package/dist/core/agent-runner.js +13 -0
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/ask.d.ts +45 -124
- package/dist/core/ask.js +103 -13
- package/dist/core/ask.js.map +1 -1
- package/dist/core/backend-detect.js +7 -2
- package/dist/core/backend-detect.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +2 -2
- package/dist/core/claude-cli-runner.js +52 -9
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/claude-ui-mapper.d.ts +1 -1
- package/dist/core/codex-app-server-runner.d.ts +1 -1
- package/dist/core/codex-app-server-runner.js +60 -3
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/codex-app-server-transport.d.ts +6 -1
- package/dist/core/codex-app-server-transport.js +12 -3
- package/dist/core/codex-app-server-transport.js.map +1 -1
- package/dist/core/codex-model-catalog.d.ts +1 -1
- package/dist/core/codex-ui-mapper.d.ts +5 -1
- package/dist/core/codex-ui-mapper.js +74 -17
- package/dist/core/codex-ui-mapper.js.map +1 -1
- package/dist/core/model-identity.d.ts +13 -6
- package/dist/core/model-identity.js +32 -17
- package/dist/core/model-identity.js.map +1 -1
- package/dist/core/model-presets.d.ts +2 -2
- package/dist/core/model-presets.js +1 -1
- package/dist/core/opencode-server-runner.d.ts +2 -2
- package/dist/core/opencode-ui-mapper.d.ts +4 -1
- package/dist/core/opencode-ui-mapper.js +45 -7
- package/dist/core/opencode-ui-mapper.js.map +1 -1
- package/dist/core/provider-auth.d.ts +154 -0
- package/dist/core/provider-auth.js +488 -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 +1 -1
- package/dist/core/runner-model-catalog.d.ts +1 -1
- 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 +1 -1
- package/dist/core/ui-events.d.ts +1 -1
- package/dist/core/ui-events.js +1 -1
- 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/git-diff-base.d.ts +60 -0
- package/dist/git-diff-base.js +54 -0
- package/dist/git-diff-base.js.map +1 -0
- package/dist/git-worktree.d.ts +37 -6
- package/dist/git-worktree.js +39 -9
- package/dist/git-worktree.js.map +1 -1
- package/dist/handoff.d.ts +2 -2
- package/dist/handoff.js +1 -1
- package/dist/index.js +65 -8
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +69 -6
- package/dist/paths.js +86 -9
- package/dist/paths.js.map +1 -1
- package/dist/planner.d.ts +2 -2
- package/dist/planner.js +7 -1
- package/dist/planner.js.map +1 -1
- 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 +9 -23
- package/dist/release/snapshot.js +9 -22
- package/dist/release/snapshot.js.map +1 -1
- package/dist/release/stable.d.ts +15 -29
- package/dist/release/stable.js +14 -27
- package/dist/release/stable.js.map +1 -1
- package/dist/runs/auto-name.d.ts +1 -1
- package/dist/runs/auto-name.js +6 -1
- package/dist/runs/auto-name.js.map +1 -1
- package/dist/runs/retention.d.ts +1 -1
- 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 +169 -343
- package/dist/runs/store.js +223 -34
- package/dist/runs/store.js.map +1 -1
- package/dist/runs/ui-event-sink.d.ts +1 -1
- 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 +6 -5
- package/dist/server/capabilities.js +11 -0
- package/dist/server/capabilities.js.map +1 -1
- package/dist/server/forge/github.d.ts +58 -151
- package/dist/server/forge/github.js +530 -6
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/index.d.ts +10 -3
- package/dist/server/forge/index.js +14 -1
- package/dist/server/forge/index.js.map +1 -1
- package/dist/server/forge/types.d.ts +86 -1
- package/dist/server/git-changes.d.ts +8 -8
- package/dist/server/git-changes.js +14 -17
- package/dist/server/git-changes.js.map +1 -1
- package/dist/server/git.d.ts +2 -0
- package/dist/server/git.js +9 -0
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +3 -3
- package/dist/server/github.js +1 -1
- package/dist/server/github.js.map +1 -1
- package/dist/server/open-in-terminal.d.ts +8 -1
- package/dist/server/open-in-terminal.js +16 -8
- package/dist/server/open-in-terminal.js.map +1 -1
- package/dist/server/pr.d.ts +2 -2
- package/dist/server/project-context.d.ts +20 -3
- package/dist/server/project-context.js +30 -1
- package/dist/server/project-context.js.map +1 -1
- package/dist/server/provider-action-gate.d.ts +8 -0
- package/dist/server/provider-action-gate.js +54 -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 +12078 -47
- package/dist/server/server.js +2700 -910
- package/dist/server/server.js.map +1 -1
- 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 +2 -2
- package/dist/server/ws.js +2 -2
- package/dist/server/ws.js.map +1 -1
- package/dist/server-install/engine.d.ts +1 -1
- package/dist/server-install/platforms/macosx-ngrok.d.ts +1 -1
- package/dist/server-install/platforms/ubuntu-vps.d.ts +16 -1
- package/dist/server-install/platforms/ubuntu-vps.js +83 -1
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
- package/dist/server-install/state.d.ts +1 -1
- package/dist/server-install/steps.d.ts +2 -2
- package/dist/server-install/strategies.d.ts +1 -1
- package/dist/server-install/types.d.ts +38 -424
- package/dist/server-install/ui.d.ts +1 -1
- package/dist/skills-banner.d.ts +7 -6
- package/dist/skills-banner.js +7 -6
- package/dist/skills-banner.js.map +1 -1
- package/dist/skills-remote.d.ts +2 -2
- 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 +2 -0
- package/dist/skills.js +7 -2
- package/dist/skills.js.map +1 -1
- package/dist/todos.d.ts +2 -28
- package/dist/todos.js +1 -1
- package/dist/ui-state.d.ts +16 -2
- package/dist/ui-state.js +14 -1
- package/dist/ui-state.js.map +1 -1
- package/dist/workflows/load.d.ts +1 -1
- package/dist/workflows/run.d.ts +245 -12
- package/dist/workflows/run.js +1150 -135
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +59 -224
- package/dist/workflows/types.js +14 -0
- package/dist/workflows/types.js.map +1 -1
- package/dist/workspace/agent-accounts.d.ts +149 -0
- package/dist/workspace/agent-accounts.js +303 -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 +82 -206
- package/dist/workspace/config.js +183 -14
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/migrations.d.ts +1 -1
- package/dist/workspace/migrations.js +19 -4
- package/dist/workspace/migrations.js.map +1 -1
- package/dist/workspace/projects.d.ts +8 -2
- package/dist/workspace/projects.js +9 -3
- package/dist/workspace/projects.js.map +1 -1
- package/dist/workspace/semaphore.d.ts +45 -0
- package/dist/workspace/semaphore.js +45 -1
- package/dist/workspace/semaphore.js.map +1 -1
- package/dist/workspace/ui-state.d.ts +13 -3
- package/dist/workspace/ui-state.js +15 -5
- package/dist/workspace/ui-state.js.map +1 -1
- package/package.json +17 -39
- package/scripts/inline-contract.mjs +112 -0
- package/scripts/mock-claude.mjs +59 -1
- package/scripts/sync-readme.mjs +20 -0
- package/web/dist/assets/alert-dialog-DgGXiX0-.js +1 -0
- package/web/dist/assets/arrow-down-CVWn39DB.js +1 -0
- package/web/dist/assets/arrow-left-CytehkvJ.js +1 -0
- package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
- package/web/dist/assets/centered-state-BWu9EZeH.js +43 -0
- package/web/dist/assets/chevron-right-Bn2cprQo.js +1 -0
- package/web/dist/assets/{chunk-BO2N2NFS-DtrdTCWa.js → chunk-BO2N2NFS-DE6qKn3r.js} +8 -8
- package/web/dist/assets/circle-check-C9dg0Lwg.js +1 -0
- package/web/dist/assets/circle-x-HJMlr00g.js +1 -0
- package/web/dist/assets/collapsible-CW__roLv.js +1 -0
- package/web/dist/assets/commit-list-D-ZJT7xL.js +1 -0
- package/web/dist/assets/compare-variants-B_P9w6Pa.js +1 -0
- package/web/dist/assets/{core-DePtBHcl.js → core-BCsw8oQw.js} +1 -1
- package/web/dist/assets/diff-CutJnvjv.js +3 -0
- package/web/dist/assets/diff-stat-DFXP90Vc.js +1 -0
- package/web/dist/assets/{diff-view-DCeSegKk.js → diff-view-BWuBSVFT.js} +4 -4
- package/web/dist/assets/dropdown-menu-D2A2pGjJ.js +1 -0
- package/web/dist/assets/editable-title-C6a64Vwp.js +1 -0
- package/web/dist/assets/{ellipsis-vertical-CNnSLn5a.js → ellipsis-vertical-vx7lL0mm.js} +1 -1
- package/web/dist/assets/{file-D4GBh1ao.js → file-DAIQGWQp.js} +1 -1
- package/web/dist/assets/folder-BvhNOUoH.js +1 -0
- package/web/dist/assets/{git-pull-request-DnR8Xh4_.js → git-pull-request-CymF90c4.js} +1 -1
- package/web/dist/assets/git-toolbar-Db-_fahh.js +1 -0
- package/web/dist/assets/github-C3d-_ewX.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/image-preview-DsqvjJhH.js +1 -0
- package/web/dist/assets/index-cLI08SdE.css +2 -0
- package/web/dist/assets/index-e2WSiiVD.js +6 -0
- package/web/dist/assets/lib-BQXq3kEf.js +1 -0
- package/web/dist/assets/lib-BxQXEXDF.js +1 -0
- package/web/dist/assets/markdown-v5x5LE3s.js +2 -0
- package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
- package/web/dist/assets/new-task-form-DAqGlabw.js +1 -0
- package/web/dist/assets/pill-AwEgoqpU.js +1 -0
- package/web/dist/assets/project-router-CLFUa8om.js +1 -0
- package/web/dist/assets/prompt-templates-DhV3gn9A.js +15 -0
- package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
- package/web/dist/assets/{refresh-cw-BT8E96Tm.js → refresh-cw-D3EzHdHY.js} +1 -1
- package/web/dist/assets/repo-git-BidLXJPd.js +1 -0
- package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
- package/web/dist/assets/run-diff-D7nsnTW9.js +3 -0
- package/web/dist/assets/run-header-CTsQn0Xg.js +1 -0
- package/web/dist/assets/{search-x-BDjaMrzG.js → search-x-B5IWjGTy.js} +1 -1
- package/web/dist/assets/skill-empty-hint-Cc3J-Vw9.js +1 -0
- package/web/dist/assets/skills-BiWfHmCR.js +1 -0
- package/web/dist/assets/skills-eu1MYWhp.js +1 -0
- package/web/dist/assets/sparkles-CfUE1-C7.js +1 -0
- package/web/dist/assets/{square-terminal-Ckjjn0vb.js → square-terminal-D2wOSwur.js} +1 -1
- package/web/dist/assets/tab-link-gfQ-aL-o.js +1 -0
- package/web/dist/assets/task-changes-5CfU8f_H.js +1 -0
- package/web/dist/assets/task-commits-Dav8UN6F.js +1 -0
- package/web/dist/assets/task-files-BxmqQ-gr.js +2 -0
- package/web/dist/assets/task-thread-DPZQ_MH3.js +9 -0
- package/web/dist/assets/textarea-DsfeVQQ3.js +1 -0
- package/web/dist/assets/thread-loading-u9UysgWm.js +1 -0
- package/web/dist/assets/{trash-2-DAyZ0VSi.js → trash-2-DgRtM_8B.js} +1 -1
- package/web/dist/assets/{triangle-alert-Uy8rokUh.js → triangle-alert-rNmHpkue.js} +1 -1
- package/web/dist/assets/{upload-CDdyPPo_.js → upload-3tpzQ7VY.js} +1 -1
- package/web/dist/assets/use-desktop-D5epLJpa.js +1 -0
- package/web/dist/assets/use-submit-shortcut-CNJod3_0.js +1 -0
- package/web/dist/assets/utils-CNU1Tppw.js +64 -0
- package/web/dist/assets/workflows-DK43e7Ep.js +11 -0
- package/web/dist/assets/zoomable-image-DJH8H5T5.js +1 -0
- package/web/dist/index.html +33 -13
- package/scripts/dev.mjs +0 -74
- package/scripts/release-snapshot.mjs +0 -130
- package/scripts/release.mjs +0 -119
- package/web/dist/assets/arrow-left-CnSdp92h.js +0 -1
- package/web/dist/assets/bundle-mjs-BegPhL5c.js +0 -1
- package/web/dist/assets/centered-state-DOsTcJZF.js +0 -43
- package/web/dist/assets/commit-list-BrxBEzRK.js +0 -1
- package/web/dist/assets/compare-variants-Bv2c9ORF.js +0 -1
- package/web/dist/assets/dist-B6XQOhgM.js +0 -1
- package/web/dist/assets/git-toolbar-pIC1ubPM.js +0 -1
- package/web/dist/assets/github-DTTSG0ht.js +0 -1
- package/web/dist/assets/highlighted-body-OFNGDK62-BuFJwunK.js +0 -1
- package/web/dist/assets/image-preview-DEhW0TFz.js +0 -1
- package/web/dist/assets/index-Cijmlz7q.js +0 -30
- package/web/dist/assets/index-DzroxqBG.css +0 -2
- package/web/dist/assets/lib-DPEZDBUN.js +0 -1
- package/web/dist/assets/lib-uHpeUPzK.js +0 -1
- package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +0 -1
- package/web/dist/assets/open-mercato-toBr6SOa.svg +0 -11
- package/web/dist/assets/project-router-BMx4e8m-.js +0 -1
- package/web/dist/assets/repo-git-BusYfa3t.js +0 -1
- package/web/dist/assets/run-diff-wOJ3odA-.js +0 -3
- package/web/dist/assets/run-header-Cg5incil.js +0 -1
- package/web/dist/assets/skill-empty-hint-DTUSp6Vl.js +0 -1
- package/web/dist/assets/skills-PpP2owbM.js +0 -1
- package/web/dist/assets/tab-link-F3uQrdm2.js +0 -1
- package/web/dist/assets/task-changes-iD9kS79I.js +0 -1
- package/web/dist/assets/task-commits-COkLaUri.js +0 -1
- package/web/dist/assets/task-files-Ckttz7XH.js +0 -2
- package/web/dist/assets/task-thread-CQD91VPt.js +0 -9
- package/web/dist/assets/textarea-Bj_LCreg.js +0 -1
- package/web/dist/assets/use-desktop-DUh0STzq.js +0 -3
- package/web/dist/assets/utils-B8s5qIcK.js +0 -1
- package/web/dist/assets/workflows-BFk8C0co.js +0 -11
- package/web/dist/assets/zoomable-image-PEDOqyLQ.js +0 -1
- /package/web/{open-mercato.svg → dist/open-mercato.svg} +0 -0
package/dist/workflows/run.js
CHANGED
|
@@ -2,29 +2,36 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { parseAskMarkerResult, stripAskMarker, } from '../core/ask.js';
|
|
6
6
|
import { onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
|
|
7
|
+
import { parseUsageLimit } from '../core/usage-limit.js';
|
|
7
8
|
import { createRunner } from '../core/runner-factory.js';
|
|
8
9
|
import { modelConflictsWithRunner } from '../core/model-presets.js';
|
|
10
|
+
import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
|
|
9
11
|
import { ModelIdentityError, formatModelIdentity, normalizeModelForBackend, } from '../core/model-identity.js';
|
|
10
12
|
import { HANDOFF_ONLY_INSTRUCTIONS, HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, followupsEnabled, handoffPath, seedHandoffFile, } from '../handoff.js';
|
|
11
13
|
import { todosPath } from '../todos.js';
|
|
12
14
|
import { discoverSkills } from '../skills.js';
|
|
13
15
|
import { materializeSkillDir } from '../skills-remote.js';
|
|
14
16
|
import { seedAgentConfigLocalLayer } from '../agent-config/seed.js';
|
|
17
|
+
import { readAgentModelProvider } from '../agent-config/models.js';
|
|
15
18
|
import { loadConfig, resolveWorktreeRetention } from '../config.js';
|
|
16
19
|
import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeShortstat } from '../git-worktree.js';
|
|
17
|
-
import { getRepoInfo } from '../server/git.js';
|
|
20
|
+
import { getHeadCommit, getRepoInfo } from '../server/git.js';
|
|
18
21
|
import { loadWorkflows } from './load.js';
|
|
19
22
|
import { reclaimWorktrees, rematerializeReclaimedWorktree } from '../runs/retention.js';
|
|
20
23
|
import { extractTaskRefs, refineTaskRefs, titleRefNumber } from '../runs/task-refs.js';
|
|
21
24
|
import { parseTaskMarkers, stripTaskMarkers } from '../runs/task-markers.js';
|
|
22
25
|
import { autoNamingActive, generateRunName, liveTitleUpdatesEnabled, postValidateTitle } from '../runs/auto-name.js';
|
|
23
26
|
import { reviewGateEnabled } from '../runs/review-gate.js';
|
|
27
|
+
import { resolveProfileEnvForRoot } from '../workspace/agent-profiles.js';
|
|
24
28
|
import { WorkspaceSemaphore } from '../workspace/semaphore.js';
|
|
25
29
|
import { UiEventSink } from '../runs/ui-event-sink.js';
|
|
26
30
|
import { chainStepNote, DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
|
|
27
31
|
const CHECK_OUTPUT_CAP = 20_000;
|
|
32
|
+
async function configuredModelProvider(backend, repoRoot) {
|
|
33
|
+
return readAgentModelProvider(backend, repoRoot).catch(() => undefined);
|
|
34
|
+
}
|
|
28
35
|
/** An interactive session that hears nothing from the user closes itself. */
|
|
29
36
|
export const IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
30
37
|
/**
|
|
@@ -77,6 +84,18 @@ function emitAskRequested(sink, ask) {
|
|
|
77
84
|
sink.handle({ type: 'ask.requested', requestId, questions: ask.questions });
|
|
78
85
|
return requestId;
|
|
79
86
|
}
|
|
87
|
+
/** A persisted, non-fatal explanation for protocol-shaped text that could not
|
|
88
|
+
* become an ask card. Never include the raw payload in this diagnostic. */
|
|
89
|
+
function askMarkerRejection(result) {
|
|
90
|
+
if (result.kind === 'invalid-json') {
|
|
91
|
+
return 'structured question ignored — CEZ:ASK payload is not valid JSON';
|
|
92
|
+
}
|
|
93
|
+
if (result.kind !== 'invalid-structure')
|
|
94
|
+
return undefined;
|
|
95
|
+
const issue = result.issues[0];
|
|
96
|
+
const location = issue?.path.length ? ` at ${issue.path.join('.')}` : '';
|
|
97
|
+
return `structured question ignored — CEZ:ASK payload failed validation${location}${issue ? `: ${issue.message}` : ''}`;
|
|
98
|
+
}
|
|
80
99
|
/** Periodic "cezar autosave" commit in the task worktree (spec 006). */
|
|
81
100
|
export const AUTOSAVE_INTERVAL_MS = 90_000;
|
|
82
101
|
/** The periodic autosave timer is opt-in (#471): off, a task branch carries only the
|
|
@@ -86,9 +105,108 @@ export const AUTOSAVE_INTERVAL_MS = 90_000;
|
|
|
86
105
|
export function periodicAutosaveEnabled(env = process.env) {
|
|
87
106
|
return env.CEZ_AUTOSAVE === '1';
|
|
88
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Explicitly opt out of the repository-root lease for runs that execute in the
|
|
110
|
+
* current checkout (`worktree: false`). This is intentionally unsafe: concurrent
|
|
111
|
+
* agents may overwrite each other's files or Git state. Isolated worktree runs
|
|
112
|
+
* are unaffected.
|
|
113
|
+
*/
|
|
114
|
+
export function repositoryRootLockDisabled(env = process.env) {
|
|
115
|
+
return env.CEZ_DISABLE_REPO_LOCK === '1';
|
|
116
|
+
}
|
|
89
117
|
/** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
|
|
90
118
|
const MAX_AUTO_CONTINUES = 40;
|
|
91
119
|
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.';
|
|
120
|
+
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.';
|
|
121
|
+
/**
|
|
122
|
+
* Auto-resume after a provider usage limit (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
123
|
+
*
|
|
124
|
+
* The wait is the provider's own reset instant plus this grace: resuming AT the boundary races the
|
|
125
|
+
* provider's clock (and its rounding), and one failed resume costs the whole window over again.
|
|
126
|
+
* Thirty seconds is cheap next to five hours and long enough to be past any sane skew.
|
|
127
|
+
*/
|
|
128
|
+
export const AUTO_RESUME_GRACE_MS = 30_000;
|
|
129
|
+
/**
|
|
130
|
+
* Consecutive automatic resumes allowed without a human turn. A resume can only fire after a real
|
|
131
|
+
* reset instant, so this is not a throttle — it is the backstop for the pathological case (a
|
|
132
|
+
* provider that answers "limit reached, retry now" in a loop), and it is deliberately generous
|
|
133
|
+
* enough to sit through a couple of days of five-hour windows.
|
|
134
|
+
*/
|
|
135
|
+
export const MAX_AUTO_RESUMES = 12;
|
|
136
|
+
/**
|
|
137
|
+
* How long a missed deadline stays worth acting on. The promise is "we pick this up when the
|
|
138
|
+
* window reopens" — kept across a restart or an overnight close, which is the case the feature
|
|
139
|
+
* exists for. A day later it is no longer that promise: the user has moved on, and a task
|
|
140
|
+
* springing back to life is a surprise rather than a service. Such a deadline is retired with a
|
|
141
|
+
* note instead of fired, so the only tasks a sweep can revive are ones someone is still waiting on.
|
|
142
|
+
*/
|
|
143
|
+
export const AUTO_RESUME_MISSED_WINDOW_MS = 24 * 60 * 60_000;
|
|
144
|
+
/**
|
|
145
|
+
* How often the queue checks that it is not wedged.
|
|
146
|
+
*
|
|
147
|
+
* A hold is the only thing in the engine that can make an idle queue CORRECT, so it is also the
|
|
148
|
+
* only thing that can make a wedged one look correct. This tick is the way out: cheap (a few
|
|
149
|
+
* in-memory checks), unref'd, and it only ever acts when idling has no justification left.
|
|
150
|
+
*/
|
|
151
|
+
export const QUEUE_WATCHDOG_MS = 60_000;
|
|
152
|
+
/** Shared empty holds for the common "nothing is held" pump — avoids allocating per sweep. */
|
|
153
|
+
const NO_HOLDS = { deadline: new Set(), inFlight: new Set() };
|
|
154
|
+
/**
|
|
155
|
+
* May this run start, given what its account is holding?
|
|
156
|
+
*
|
|
157
|
+
* The two kinds of hold bind different work, and getting that wrong has produced a bug in each
|
|
158
|
+
* direction (spec 2026-08-03-auto-resume-after-usage-limit):
|
|
159
|
+
*
|
|
160
|
+
* - a `deadline` hold means the window is KNOWN shut until an instant, so it blocks everything
|
|
161
|
+
* on that account — resumes included. Exempting them let four resumes fire at once and
|
|
162
|
+
* re-limit one after another, which is the stampede wearing a different hat.
|
|
163
|
+
* - an `inFlight` hold means a resume is testing the window right now and nothing is proven, so
|
|
164
|
+
* it blocks fresh work but not other resumes. Blocking those deadlocked a live workspace.
|
|
165
|
+
*/
|
|
166
|
+
function accountHeldFor(run, holds, fallbackRunner) {
|
|
167
|
+
const key = runAccountKey(run, fallbackRunner);
|
|
168
|
+
if (holds.deadline.has(key))
|
|
169
|
+
return true;
|
|
170
|
+
return holds.inFlight.has(key) && !resumeInFlight(run);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Which agent ACCOUNT a run's work runs on — the thing a provider usage limit actually closes
|
|
174
|
+
* (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
175
|
+
*
|
|
176
|
+
* Backend plus agent account, because those are the two axes a limit is scoped to: a Claude
|
|
177
|
+
* limit must never stall a Codex task, and a second Claude login is a second budget. A record
|
|
178
|
+
* that names no runner has not started yet and will take the configured default, which is what
|
|
179
|
+
* `fallbackRunner` carries; a run that HAS started always carries its resolved runner (execute
|
|
180
|
+
* persists it), and only started runs can be holding.
|
|
181
|
+
*/
|
|
182
|
+
export function runAccountKey(run, fallbackRunner) {
|
|
183
|
+
return `${run.runner ?? fallbackRunner}:${run.agentProfile ?? 'default'}`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Is this run an automatic resume that has not completed a turn yet?
|
|
187
|
+
*
|
|
188
|
+
* Such a run is the work the reopened window is FOR, so the hold must never apply to it — not
|
|
189
|
+
* its own, and not another resume's. Two resumes that hold each other is a deadlock the queue
|
|
190
|
+
* cannot recover from: both sit `queued` with a counter and no deadline, each waiting for the
|
|
191
|
+
* other to prove a window neither will ever get to test. That is the shape a live run produced
|
|
192
|
+
* — two scheduled tasks fired, both went `queued`, and nothing in the workspace moved again.
|
|
193
|
+
*
|
|
194
|
+
* The hold exists to stop NEW work walking into a closed window. A resume is not new work.
|
|
195
|
+
*/
|
|
196
|
+
function resumeInFlight(run) {
|
|
197
|
+
return (run.autoResumeAttempts !== undefined && (run.status === 'queued' || run.status === 'running'));
|
|
198
|
+
}
|
|
199
|
+
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.';
|
|
200
|
+
/**
|
|
201
|
+
* The wake instant as a human reads it — local, to the SECOND, with the zone named. The
|
|
202
|
+
* transcript line is what someone scanning a stalled task actually reads, and "18:41" is not
|
|
203
|
+
* enough to tell a wait that is nearly over from one that just started; the machine-readable ISO
|
|
204
|
+
* copy lives on `RunRecord.autoResumeAt`. Server-side formatting is honest here because cezar is
|
|
205
|
+
* local-first: the process and the browser reading it are the same machine.
|
|
206
|
+
*/
|
|
207
|
+
function formatWakeInstant(at) {
|
|
208
|
+
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long' }).format(at);
|
|
209
|
+
}
|
|
92
210
|
/**
|
|
93
211
|
* The effective "extra" system prompt for a run (spec §protocol v2, R2 2.3):
|
|
94
212
|
* the per-run override (`POST /api/runs` `systemPrompt`) REPLACES the
|
|
@@ -171,6 +289,7 @@ const VARIANT_HINTS = {
|
|
|
171
289
|
B: 'Approach hint: prefer the minimal, surgical change.',
|
|
172
290
|
C: 'Approach hint: prefer a thorough, structural approach.',
|
|
173
291
|
};
|
|
292
|
+
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.';
|
|
174
293
|
/**
|
|
175
294
|
* The mini workflow engine: executes a `WorkflowDef` against a repo, one step
|
|
176
295
|
* at a time, persisting every event to the RunStore (which the SSE endpoints
|
|
@@ -200,7 +319,13 @@ export class RunManager {
|
|
|
200
319
|
// timeout already bounds how long a session can sit open. Invariant:
|
|
201
320
|
// `waiting ⊆ active` — always cleared together via dropActive().
|
|
202
321
|
waiting = new Set();
|
|
322
|
+
/** Durable monitoring subset. Only the configured number receives the waiting-slot exemption. */
|
|
323
|
+
monitoring = new Set();
|
|
203
324
|
pendingJobs = new Map();
|
|
325
|
+
/** Interrupted agent turns recovered after a process restart. Unlike an
|
|
326
|
+
* explicit user Continue, these are bulk scheduler work and must re-enter
|
|
327
|
+
* through `pump()` so both workspace and per-project caps are honored. */
|
|
328
|
+
pendingContinuations = new Map();
|
|
204
329
|
/** Per-run image counter behind `pasted-<n>` / `screenshot-<n>` (#472). Lives on
|
|
205
330
|
* the manager rather than the `ActiveRun` so a *queued* run — which has no
|
|
206
331
|
* `ActiveRun` at all — can persist attachments. Seeded lazily from disk. */
|
|
@@ -208,6 +333,12 @@ export class RunManager {
|
|
|
208
333
|
/** Messages that landed in the dequeue → session-open gap (#472), flushed as
|
|
209
334
|
* ordinary follow-up turns the moment the session opens. In-memory only. */
|
|
210
335
|
deferredMessages = new Map();
|
|
336
|
+
/** Armed usage-limit resumes, keyed by run id (spec
|
|
337
|
+
* 2026-08-03-auto-resume-after-usage-limit). The DEADLINE itself lives on the record
|
|
338
|
+
* (`autoResumeAt`) — this map holds only the process-local timer, so a restart rebuilds it
|
|
339
|
+
* from the record rather than losing the wait. Runs here are `failed` and therefore NOT in
|
|
340
|
+
* `active`, which is why the timer cannot live on an `ActiveRun` like the monitoring one. */
|
|
341
|
+
autoResumeTimers = new Map();
|
|
211
342
|
pumping = false;
|
|
212
343
|
/** A pump that arrived while one was in flight — replayed by `pump()`'s own
|
|
213
344
|
* loop so a slot freed mid-sweep is never a lost wakeup. */
|
|
@@ -226,6 +357,13 @@ export class RunManager {
|
|
|
226
357
|
/** Unsubscribe handle for the constructor's `onUsage` subscription — released
|
|
227
358
|
* by dispose() so a torn-down manager stops receiving sampler ticks. */
|
|
228
359
|
offUsage;
|
|
360
|
+
/** The stalled-queue watchdog (see `rescueStalledQueue`). */
|
|
361
|
+
queueWatchdog;
|
|
362
|
+
/** Set by the watchdog for exactly one sweep: ignore the usage-limit hold and make progress. */
|
|
363
|
+
forceNextPump = false;
|
|
364
|
+
/** Runs the watchdog started despite the hold. The spawn-time gate (`requeueWhileHeld`) would
|
|
365
|
+
* otherwise hand them straight back and the rescue would undo itself in a millisecond. */
|
|
366
|
+
forceStarted = new Set();
|
|
229
367
|
/** The workspace-wide parallel-cap semaphore + cached resource config
|
|
230
368
|
* (spec 2026-07-20, step 2.5). Boot constructs ONE and every manager shares
|
|
231
369
|
* it; the private fallback keeps single-manager callers and tests working. */
|
|
@@ -242,10 +380,13 @@ export class RunManager {
|
|
|
242
380
|
busySlots: () => this.busySlots(),
|
|
243
381
|
pump: () => this.pump(),
|
|
244
382
|
oldestQueuedAt: () => this.oldestQueuedAt(),
|
|
383
|
+
accountHolds: () => this.accountHolds(),
|
|
245
384
|
});
|
|
246
385
|
// Memory guard (#memory-guard): the shared process-tree sampler already ticks ~every 2 s for
|
|
247
386
|
// the runs table; piggyback on it to enforce the per-task memory ceiling.
|
|
248
387
|
this.offUsage = onUsage((snapshot) => void this.enforceMemoryLimit(snapshot));
|
|
388
|
+
this.queueWatchdog = setInterval(() => void this.rescueStalledQueue(), QUEUE_WATCHDOG_MS);
|
|
389
|
+
this.queueWatchdog.unref?.();
|
|
249
390
|
}
|
|
250
391
|
/**
|
|
251
392
|
* Release everything this manager owns without touching run records
|
|
@@ -260,17 +401,23 @@ export class RunManager {
|
|
|
260
401
|
dispose() {
|
|
261
402
|
this.offUsage();
|
|
262
403
|
this.offSemaphore();
|
|
263
|
-
|
|
404
|
+
clearInterval(this.queueWatchdog);
|
|
405
|
+
for (const [runId, state] of this.active) {
|
|
264
406
|
this.clearIdleTimer(state);
|
|
407
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
265
408
|
this.clearAutosaveTimer(state);
|
|
266
409
|
state.releaseRepoRoot?.();
|
|
267
410
|
state.releaseRepoRoot = undefined;
|
|
268
411
|
}
|
|
412
|
+
for (const timer of this.autoResumeTimers.values())
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
this.autoResumeTimers.clear();
|
|
269
415
|
this.active.clear();
|
|
270
416
|
this.waiting.clear();
|
|
271
417
|
this.starting.clear();
|
|
272
418
|
this.queue.length = 0;
|
|
273
419
|
this.pendingJobs.clear();
|
|
420
|
+
this.pendingContinuations.clear();
|
|
274
421
|
this.memoryPausing.clear();
|
|
275
422
|
this.lastNamerKey.clear();
|
|
276
423
|
}
|
|
@@ -338,13 +485,49 @@ export class RunManager {
|
|
|
338
485
|
CEZ_TODOS_FILE: generateFollowups ? todosPath(this.dataDir) : '',
|
|
339
486
|
};
|
|
340
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* `agentEnv` plus the agent-account variable for the profile this STEP runs under (spec
|
|
490
|
+
* 2026-07-29-agent-profiles), and the id it resolved to so the caller can record it.
|
|
491
|
+
*
|
|
492
|
+
* Resolved per step, not per run, because a workflow can mix backends: an override naming a
|
|
493
|
+
* Claude account says nothing about which Codex account a codex step should use. Resolution
|
|
494
|
+
* order, most specific first:
|
|
495
|
+
*
|
|
496
|
+
* 1. the step's ALREADY-RECORDED `profileId` — a resume or Continue must reattach to the
|
|
497
|
+
* account that created the session, whatever the project has since been switched to;
|
|
498
|
+
* 2. the run's composer override, but only for steps on the run's own runner;
|
|
499
|
+
* 3. the project's stored selection, and failing that the discovered default.
|
|
500
|
+
*
|
|
501
|
+
* Read fresh every time. `~/.cezar/config.json` is shared by every cezar process on this
|
|
502
|
+
* machine, so a cached snapshot is a staleness bug, and one small JSON read is free next to
|
|
503
|
+
* spawning a CLI. Never throws: an unreadable home degrades to the default profile, which is
|
|
504
|
+
* exactly the behaviour that predates profiles.
|
|
505
|
+
*/
|
|
506
|
+
async agentEnvForStep(runId, backend, options = {}) {
|
|
507
|
+
const run = this.store.getRun(runId);
|
|
508
|
+
const profileId = options.recordedProfileId
|
|
509
|
+
?? (backend === (run?.runner ?? 'claude') ? run?.agentProfile : undefined);
|
|
510
|
+
const resolved = await resolveProfileEnvForRoot(this.repoRoot, backend, profileId);
|
|
511
|
+
return {
|
|
512
|
+
env: { ...this.agentEnv(runId, options.generateFollowups), ...resolved.env },
|
|
513
|
+
profileId: resolved.profile.id,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
341
516
|
startRun(workflow, input, group) {
|
|
517
|
+
// Sanitize at the manager boundary so CLI runs, workflows, variants, and
|
|
518
|
+
// direct callers cannot bypass the HTTP policy.
|
|
519
|
+
const effectiveInput = agentModelsLocked(this.repoRoot)
|
|
520
|
+
? { ...input, model: undefined }
|
|
521
|
+
: input;
|
|
342
522
|
const run = this.store.createRun({
|
|
343
523
|
title: makeRunTitle(input.task, workflow) + (group ? ` (${group.variant})` : ''),
|
|
344
524
|
workflow: workflow.name,
|
|
345
525
|
task: input.task,
|
|
346
|
-
model:
|
|
526
|
+
model: effectiveInput.model,
|
|
347
527
|
runner: input.runner,
|
|
528
|
+
// The composer's per-task account (spec 2026-07-29-agent-profiles). Persisted at creation
|
|
529
|
+
// so a queued run picks it up at dequeue and every later resume reads the same answer.
|
|
530
|
+
agentProfile: input.agentProfile,
|
|
348
531
|
// The global inbox is the ceiling on the per-run flag (#471). Enforced here rather than
|
|
349
532
|
// at the HTTP route because `cezar run`, the inbox's own "▶ Run" and variants all reach
|
|
350
533
|
// startRun directly — a route-level gate would leave those writing todos.json.
|
|
@@ -354,6 +537,9 @@ export class RunManager {
|
|
|
354
537
|
// auto-nudge reads `input.autonomous` (`execute`), but the record is the
|
|
355
538
|
// only source those after-the-fact consumers have.
|
|
356
539
|
autonomous: input.autonomous === true,
|
|
540
|
+
// Persist the explicit opt-out so queued-run restart recovery and the
|
|
541
|
+
// session Git routes can distinguish it from a removed isolated worktree.
|
|
542
|
+
worktree: !group && input.worktree === false ? false : undefined,
|
|
357
543
|
groupId: group?.groupId,
|
|
358
544
|
variant: group?.variant,
|
|
359
545
|
steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
|
|
@@ -388,7 +574,7 @@ export class RunManager {
|
|
|
388
574
|
// above shows instantly; the namer's short title replaces it when (and if)
|
|
389
575
|
// the model answers. Never awaited, never fails the run.
|
|
390
576
|
void this.autoNameRun(run.id, skillHint, input.task);
|
|
391
|
-
this.pendingJobs.set(run.id, { workflow, input });
|
|
577
|
+
this.pendingJobs.set(run.id, { workflow, input: effectiveInput });
|
|
392
578
|
this.queue.push(run.id);
|
|
393
579
|
void this.pump();
|
|
394
580
|
return run;
|
|
@@ -405,7 +591,7 @@ export class RunManager {
|
|
|
405
591
|
return VARIANT_LETTERS.slice(0, Math.min(Math.max(count, 1), VARIANT_LETTERS.length)).map((variant) => {
|
|
406
592
|
const hint = VARIANT_HINTS[variant];
|
|
407
593
|
const task = hint ? `${input.task}\n\n${hint}` : input.task;
|
|
408
|
-
return this.startRun(workflow, { ...input, task }, { groupId, variant });
|
|
594
|
+
return this.startRun(workflow, { ...input, task, worktree: undefined }, { groupId, variant });
|
|
409
595
|
});
|
|
410
596
|
}
|
|
411
597
|
/**
|
|
@@ -418,7 +604,9 @@ export class RunManager {
|
|
|
418
604
|
* `maxParallel`, including when other projects saturate the cap.
|
|
419
605
|
*/
|
|
420
606
|
busySlots() {
|
|
421
|
-
|
|
607
|
+
const ordinaryWaiting = this.waiting.size - this.monitoring.size;
|
|
608
|
+
const exemptMonitoring = Math.min(this.monitoring.size, this.semaphore.maxMonitoringSessions());
|
|
609
|
+
return this.active.size + this.starting.size - ordinaryWaiting - exemptMonitoring;
|
|
422
610
|
}
|
|
423
611
|
/** Epoch ms of this manager's oldest queued run (the semaphore's fairness
|
|
424
612
|
* key when a freed slot is broadcast), or null when nothing is queued.
|
|
@@ -454,6 +642,8 @@ export class RunManager {
|
|
|
454
642
|
* root (spec 006 degradation rule), which is always the tighter bound.
|
|
455
643
|
*/
|
|
456
644
|
async pump() {
|
|
645
|
+
this.reconcileMonitoringWakeTimers();
|
|
646
|
+
this.reconcileAutoResumes();
|
|
457
647
|
// A pump requested while one is in flight can't just be dropped: the
|
|
458
648
|
// in-flight pass may already have read capacity (it awaits `getRepoInfo`
|
|
459
649
|
// before the first check), so a slot freed in that window would be lost
|
|
@@ -478,15 +668,69 @@ export class RunManager {
|
|
|
478
668
|
const capacity = () => this.semaphore.busy() < maxParallel &&
|
|
479
669
|
this.busySlots() < projectMax &&
|
|
480
670
|
(repo !== null || this.busySlots() < 1);
|
|
671
|
+
// The usage-limit hold (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
672
|
+
//
|
|
673
|
+
// A limit closes an ACCOUNT, not a run — so starting the next queued task walks it into
|
|
674
|
+
// the same wall. Measured before this gate existed: eight tasks under `maxParallel: 2`
|
|
675
|
+
// all failed within 517 ms, each spawning a CLI (and, outside worktree-opt-out mode, a
|
|
676
|
+
// worktree and a branch) only to be marked `scheduled`. The cap was respected at every
|
|
677
|
+
// instant and was no brake at all, because a doomed run lives ~200 ms.
|
|
678
|
+
//
|
|
679
|
+
// So: while any run on an account is waiting out a limit, nothing new starts on THAT
|
|
680
|
+
// account. Other accounts (a second login, a different backend) keep running — the hold
|
|
681
|
+
// is keyed, not global. The set is derived from the durable records rather than tracked
|
|
682
|
+
// separately, which is what makes it survive a restart, expire on its own, and lift the
|
|
683
|
+
// instant a user cancels a resume.
|
|
684
|
+
// The watchdog's one-shot override — read and cleared here, so a forced sweep never
|
|
685
|
+
// leaks into the next ordinary one.
|
|
686
|
+
const forced = this.forceNextPump;
|
|
687
|
+
this.forceNextPump = false;
|
|
688
|
+
const holds = this.queue.length > 0 && !forced ? this.semaphore.accountHolds() : NO_HOLDS;
|
|
689
|
+
const anyHold = holds.deadline.size > 0 || holds.inFlight.size > 0;
|
|
690
|
+
// Only pay for the config read when something is actually held: a queued record may name
|
|
691
|
+
// no runner, and then the account it would use is the configured default.
|
|
692
|
+
const defaultRunner = anyHold ? (await loadConfig(this.repoRoot)).defaultRunner : undefined;
|
|
481
693
|
while (this.queue.length > 0 && capacity()) {
|
|
482
|
-
|
|
694
|
+
// FIFO among the runs that CAN start; a held one keeps its place in the queue rather
|
|
695
|
+
// than being dequeued and re-queued (which would churn its position and its record).
|
|
696
|
+
const next = !anyHold
|
|
697
|
+
? 0
|
|
698
|
+
: this.queue.findIndex((id) => {
|
|
699
|
+
const queued = this.store.getRun(id);
|
|
700
|
+
return !queued || !accountHeldFor(queued, holds, defaultRunner ?? 'claude');
|
|
701
|
+
});
|
|
702
|
+
if (next === -1)
|
|
703
|
+
break; // everything queued is waiting on a held account
|
|
704
|
+
const runId = this.queue.splice(next, 1)[0];
|
|
483
705
|
if (!runId)
|
|
484
706
|
break;
|
|
707
|
+
// A forced sweep has to reach the spawn: the gate inside `execute` asks the same
|
|
708
|
+
// question and would send this run straight back to the queue.
|
|
709
|
+
if (forced)
|
|
710
|
+
this.forceStarted.add(runId);
|
|
485
711
|
const job = this.pendingJobs.get(runId);
|
|
712
|
+
const continuation = this.pendingContinuations.get(runId);
|
|
486
713
|
this.pendingJobs.delete(runId);
|
|
487
|
-
|
|
714
|
+
this.pendingContinuations.delete(runId);
|
|
715
|
+
if (!job && !continuation)
|
|
488
716
|
continue;
|
|
489
717
|
this.starting.add(runId);
|
|
718
|
+
if (continuation) {
|
|
719
|
+
const hydrated = this.hydrateQueuedContinuation(runId, continuation);
|
|
720
|
+
void this.runContinuation(runId, hydrated.stepId, hydrated.sessionId, hydrated.backend, hydrated.prompt, hydrated.images, hydrated.persistedImages, hydrated.persistedAttachments).catch((err) => {
|
|
721
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
722
|
+
this.store.updateRun(runId, {
|
|
723
|
+
status: 'failed',
|
|
724
|
+
error: `continue crashed: ${message}`,
|
|
725
|
+
finishedAt: new Date().toISOString(),
|
|
726
|
+
});
|
|
727
|
+
this.starting.delete(runId);
|
|
728
|
+
this.dropActive(runId);
|
|
729
|
+
});
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (!job)
|
|
733
|
+
continue;
|
|
490
734
|
// Rebuild the prompt from the store at the last instant (#472), so an edit
|
|
491
735
|
// or a stacked message that landed while the run waited is honored. Entered
|
|
492
736
|
// in the same synchronous tick as the `pendingJobs.delete` above, so no
|
|
@@ -514,6 +758,87 @@ export class RunManager {
|
|
|
514
758
|
this.pumping = false;
|
|
515
759
|
}
|
|
516
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* Make one `queued` RECORD executable again — the engine half a queued run needs but does not
|
|
763
|
+
* persist (`pendingJobs` / `pendingContinuations` are process-local, the record is not).
|
|
764
|
+
*
|
|
765
|
+
* Two callers, one path: boot recovery re-adopts everything the previous process was holding,
|
|
766
|
+
* and the queue watchdog re-adopts anything the running process has somehow lost. A queued
|
|
767
|
+
* record with no work item behind it is invisible to `pump()` and would sit there for good,
|
|
768
|
+
* which is the worst failure this engine has — the task is neither running nor failed, just
|
|
769
|
+
* silently never going to happen.
|
|
770
|
+
*
|
|
771
|
+
* A continuation is reconstructed first: its executable details are gone, but the pending
|
|
772
|
+
* `continue-N` step and the session before it are durable, which is enough. Otherwise the
|
|
773
|
+
* workflow is revived from the record. A run that can be neither is failed loudly rather than
|
|
774
|
+
* left in the queue as a ghost.
|
|
775
|
+
*/
|
|
776
|
+
async reviveQueuedRun(run, reason) {
|
|
777
|
+
const queuedContinuation = [...run.steps]
|
|
778
|
+
.reverse()
|
|
779
|
+
.find((step) => step.status === 'pending' && step.id.startsWith('continue-'));
|
|
780
|
+
const sessionStep = queuedContinuation
|
|
781
|
+
? [...run.steps].reverse().find((step) => step.id !== queuedContinuation.id && step.sessionId)
|
|
782
|
+
: undefined;
|
|
783
|
+
if (queuedContinuation && sessionStep?.sessionId) {
|
|
784
|
+
const backend = run.runner ?? 'claude';
|
|
785
|
+
const sessionBackend = sessionStep.backend ?? backend;
|
|
786
|
+
this.pendingContinuations.set(run.id, {
|
|
787
|
+
stepId: queuedContinuation.id,
|
|
788
|
+
sessionId: sessionBackend === backend ? sessionStep.sessionId : undefined,
|
|
789
|
+
backend,
|
|
790
|
+
prompt: RESTART_CONTINUATION_PROMPT,
|
|
791
|
+
images: [],
|
|
792
|
+
});
|
|
793
|
+
this.queue.push(run.id);
|
|
794
|
+
this.store.appendEvent(run.id, {
|
|
795
|
+
type: 'lifecycle',
|
|
796
|
+
message: `${reason} — interrupted continuation re-queued`,
|
|
797
|
+
});
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const workflow = await this.reviveWorkflow(run);
|
|
801
|
+
if (!workflow) {
|
|
802
|
+
this.store.updateRun(run.id, {
|
|
803
|
+
status: 'failed',
|
|
804
|
+
error: 'interrupted — workflow definition not recoverable after a restart',
|
|
805
|
+
finishedAt: new Date().toISOString(),
|
|
806
|
+
});
|
|
807
|
+
this.store.appendEvent(run.id, {
|
|
808
|
+
type: 'lifecycle',
|
|
809
|
+
message: `${reason} — workflow definition not recoverable, task failed`,
|
|
810
|
+
});
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Re-apply the inbox ceiling (#471). `execute()` gates again at spawn time, so the agent is
|
|
814
|
+
// safe either way — but a run queued while the inbox was on and recovered after it was
|
|
815
|
+
// switched off would otherwise keep echoing `generateFollowups: true` on a run that
|
|
816
|
+
// demonstrably produced none. Normalize the record, the way startRun does.
|
|
817
|
+
const generateFollowups = followupsEnabled() ? run.generateFollowups : false;
|
|
818
|
+
if (generateFollowups !== run.generateFollowups) {
|
|
819
|
+
this.store.updateRun(run.id, { generateFollowups });
|
|
820
|
+
}
|
|
821
|
+
this.pendingJobs.set(run.id, {
|
|
822
|
+
workflow,
|
|
823
|
+
// Folded through the same helper `pump()` uses (#472) so a restart carries the stack.
|
|
824
|
+
// Idempotent: hydration always composes from `run.task` + the stack, never from an
|
|
825
|
+
// already-folded `input.task`, so re-hydrating at dequeue yields the same string.
|
|
826
|
+
input: this.hydrateQueuedInput(run.id, {
|
|
827
|
+
task: run.task,
|
|
828
|
+
model: run.model,
|
|
829
|
+
runner: run.runner,
|
|
830
|
+
generateFollowups,
|
|
831
|
+
// Re-thread autonomy (#489): the rebuilt input feeds `execute`, whose mid-run auto-nudge
|
|
832
|
+
// reads `input.autonomous`. Without this a recovered autonomous run would run
|
|
833
|
+
// non-autonomously and later wrongly park at `review`.
|
|
834
|
+
autonomous: run.autonomous,
|
|
835
|
+
// Preserve an explicit worktree opt-out across a queued restart.
|
|
836
|
+
worktree: run.worktree,
|
|
837
|
+
}),
|
|
838
|
+
});
|
|
839
|
+
this.queue.push(run.id);
|
|
840
|
+
this.store.appendEvent(run.id, { type: 'lifecycle', message: `${reason} — task re-queued` });
|
|
841
|
+
}
|
|
517
842
|
/**
|
|
518
843
|
* Startup recovery (#367) — re-adopt runs that were live when the previous
|
|
519
844
|
* cezar process exited (requires the store opened with `keepLive`):
|
|
@@ -532,49 +857,7 @@ export class RunManager {
|
|
|
532
857
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
533
858
|
for (const run of live) {
|
|
534
859
|
if (run.status === 'queued') {
|
|
535
|
-
|
|
536
|
-
if (workflow) {
|
|
537
|
-
// Re-apply the inbox ceiling (#471). `execute()` gates again at spawn time, so the
|
|
538
|
-
// agent is safe either way — but a run queued while the inbox was on and recovered
|
|
539
|
-
// after it was switched off would otherwise keep echoing `generateFollowups: true`
|
|
540
|
-
// on a run that demonstrably produced none. Normalize the record, the way startRun
|
|
541
|
-
// does, so the stored answer matches what actually happens.
|
|
542
|
-
const generateFollowups = followupsEnabled() ? run.generateFollowups : false;
|
|
543
|
-
if (generateFollowups !== run.generateFollowups) {
|
|
544
|
-
this.store.updateRun(run.id, { generateFollowups });
|
|
545
|
-
}
|
|
546
|
-
this.pendingJobs.set(run.id, {
|
|
547
|
-
workflow,
|
|
548
|
-
// Folded through the same helper `pump()` uses (#472) so a restart
|
|
549
|
-
// carries the stack. Idempotent: hydration always composes from
|
|
550
|
-
// `run.task` + the stack, never from an already-folded `input.task`,
|
|
551
|
-
// so re-hydrating at dequeue yields the same string, not a doubled one.
|
|
552
|
-
input: this.hydrateQueuedInput(run.id, {
|
|
553
|
-
task: run.task,
|
|
554
|
-
model: run.model,
|
|
555
|
-
runner: run.runner,
|
|
556
|
-
generateFollowups,
|
|
557
|
-
// Re-thread autonomy (#489): the rebuilt input feeds `execute`,
|
|
558
|
-
// whose mid-run auto-nudge reads `input.autonomous`. Without this a
|
|
559
|
-
// recovered queued autonomous run would run non-autonomously (no
|
|
560
|
-
// auto-nudge) and later wrongly park at `review`.
|
|
561
|
-
autonomous: run.autonomous,
|
|
562
|
-
}),
|
|
563
|
-
});
|
|
564
|
-
this.queue.push(run.id);
|
|
565
|
-
this.store.appendEvent(run.id, { type: 'lifecycle', message: 'cezar restarted — task re-queued' });
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
this.store.updateRun(run.id, {
|
|
569
|
-
status: 'failed',
|
|
570
|
-
error: 'interrupted — workflow definition not recoverable after a restart',
|
|
571
|
-
finishedAt: new Date().toISOString(),
|
|
572
|
-
});
|
|
573
|
-
this.store.appendEvent(run.id, {
|
|
574
|
-
type: 'lifecycle',
|
|
575
|
-
message: 'cezar restarted — workflow definition not recoverable, task failed',
|
|
576
|
-
});
|
|
577
|
-
}
|
|
860
|
+
await this.reviveQueuedRun(run, 'cezar restarted');
|
|
578
861
|
continue;
|
|
579
862
|
}
|
|
580
863
|
if (run.status === 'waiting') {
|
|
@@ -605,8 +888,8 @@ export class RunManager {
|
|
|
605
888
|
currentStepId: undefined,
|
|
606
889
|
});
|
|
607
890
|
const resumed = this.continueRun(run.id, {
|
|
608
|
-
text:
|
|
609
|
-
});
|
|
891
|
+
text: RESTART_CONTINUATION_PROMPT,
|
|
892
|
+
}, true);
|
|
610
893
|
this.store.appendEvent(run.id, {
|
|
611
894
|
type: 'lifecycle',
|
|
612
895
|
message: resumed.ok
|
|
@@ -614,14 +897,21 @@ export class RunManager {
|
|
|
614
897
|
: `cezar restarted — could not resume the interrupted task (${resumed.error ?? 'unknown'})`,
|
|
615
898
|
});
|
|
616
899
|
}
|
|
900
|
+
// Re-arm usage-limit resumes (spec 2026-08-03-auto-resume-after-usage-limit): the wait is
|
|
901
|
+
// routinely longer than a cezar session, so the deadline is durable and the timer is rebuilt
|
|
902
|
+
// from it. `pump()` reconciles again on every sweep, so this is the fast path, not the only
|
|
903
|
+
// one — see `reconcileAutoResumes`.
|
|
904
|
+
this.reconcileAutoResumes();
|
|
617
905
|
void this.pump();
|
|
618
906
|
}
|
|
619
907
|
/** The persisted definition when it looks sane, else the catalog by name. */
|
|
620
908
|
async reviveWorkflow(run) {
|
|
909
|
+
// "Looks sane" is the STORE's job now: it parses `workflowDef` against the definition schema
|
|
910
|
+
// and `.catch`es a def that no longer fits to `undefined`, so anything present here already
|
|
911
|
+
// has the `steps` array the old inline `Array.isArray` check was asking for.
|
|
621
912
|
const def = run.workflowDef;
|
|
622
|
-
if (def
|
|
913
|
+
if (def)
|
|
623
914
|
return def;
|
|
624
|
-
}
|
|
625
915
|
const { workflows } = await loadWorkflows(this.repoRoot);
|
|
626
916
|
return workflows.find((w) => w.name === run.workflow) ?? null;
|
|
627
917
|
}
|
|
@@ -632,12 +922,26 @@ export class RunManager {
|
|
|
632
922
|
if (state)
|
|
633
923
|
state.releaseRepoRoot = undefined;
|
|
634
924
|
this.waiting.delete(runId);
|
|
925
|
+
this.monitoring.delete(runId);
|
|
926
|
+
if (state)
|
|
927
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
635
928
|
this.active.delete(runId);
|
|
636
929
|
this.memoryPausing.delete(runId);
|
|
637
930
|
this.lastNamerKey.delete(runId);
|
|
931
|
+
this.forceStarted.delete(runId);
|
|
638
932
|
// The run's slot is gone from busySlots() as of the deletes above — hand it
|
|
639
933
|
// to the workspace's oldest queued run, in ANY project. Every terminal path
|
|
640
934
|
// funnels through here, so this one call covers them all.
|
|
935
|
+
// Same reasoning as retention below — every terminal path funnels through here, so the
|
|
936
|
+
// usage-limit question ("did this run stop because the account is out of window, and when
|
|
937
|
+
// does that window reopen?") is asked once, in one place, off the record the failing path
|
|
938
|
+
// has already written. Nothing to do for any other outcome.
|
|
939
|
+
//
|
|
940
|
+
// BEFORE releasing the slot, and that order is the whole point: `releaseSlot` pumps every
|
|
941
|
+
// manager, and a pump reads the hold off the records. Publishing the schedule afterwards
|
|
942
|
+
// left a window — measured as exactly one extra task — where the queue saw a free slot and
|
|
943
|
+
// an account that looked healthy, and started work that was already doomed.
|
|
944
|
+
this.scheduleAutoResumeIfLimited(runId);
|
|
641
945
|
this.releaseSlot();
|
|
642
946
|
// A run leaving the active registry is a terminal transition (done/review/
|
|
643
947
|
// failed/cancelled) — the one moment the finished-worktree count can grow.
|
|
@@ -646,6 +950,333 @@ export class RunManager {
|
|
|
646
950
|
// the lifecycle.
|
|
647
951
|
void this.enforceRetention();
|
|
648
952
|
}
|
|
953
|
+
// ---- usage-limit auto-resume (spec 2026-08-03-auto-resume-after-usage-limit) --------------
|
|
954
|
+
/**
|
|
955
|
+
* A run just failed: if the provider said "usage limit, back at T", promise to resume it at
|
|
956
|
+
* `T + AUTO_RESUME_GRACE_MS` instead of leaving the task dead until someone notices.
|
|
957
|
+
*
|
|
958
|
+
* Every refusal below is silent-but-honest — the run stays `failed` with its Continue button,
|
|
959
|
+
* which is exactly the pre-feature behavior — except the safety cap, which says so on the
|
|
960
|
+
* transcript, because a run that stops resuming itself needs to explain why.
|
|
961
|
+
*/
|
|
962
|
+
scheduleAutoResumeIfLimited(runId) {
|
|
963
|
+
if (this.autoResumeTimers.has(runId))
|
|
964
|
+
return; // already promised
|
|
965
|
+
const run = this.store.getRun(runId);
|
|
966
|
+
if (!run || run.status !== 'failed')
|
|
967
|
+
return;
|
|
968
|
+
// Archiving IS resigning from a task. Reviving one because a window happened to reopen would
|
|
969
|
+
// be the feature working against the clearest signal the user can give it.
|
|
970
|
+
if (run.archived)
|
|
971
|
+
return;
|
|
972
|
+
const limit = parseUsageLimit(run.error);
|
|
973
|
+
if (!limit)
|
|
974
|
+
return;
|
|
975
|
+
if (!this.semaphore.autoResumeOnUsageLimit())
|
|
976
|
+
return;
|
|
977
|
+
// No session to resume = nothing this feature can do; `continueRun` would refuse anyway.
|
|
978
|
+
if (!run.steps.some((step) => step.sessionId))
|
|
979
|
+
return;
|
|
980
|
+
const attempts = run.autoResumeAttempts ?? 0;
|
|
981
|
+
if (attempts >= MAX_AUTO_RESUMES) {
|
|
982
|
+
this.store.appendEvent(runId, {
|
|
983
|
+
type: 'note',
|
|
984
|
+
message: `automatic resume cap reached (${MAX_AUTO_RESUMES}) — continue this task manually`,
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const wakeAt = new Date(limit.resetAt.getTime() + AUTO_RESUME_GRACE_MS);
|
|
989
|
+
this.armAutoResume(runId, wakeAt.getTime());
|
|
990
|
+
this.store.appendEvent(runId, {
|
|
991
|
+
type: 'lifecycle',
|
|
992
|
+
message: `usage limit reached — resuming automatically at ${formatWakeInstant(wakeAt)}`,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
/** Publish the deadline on the record (the cockpit's only source) and arm the timer for it. */
|
|
996
|
+
armAutoResume(runId, deadline) {
|
|
997
|
+
this.store.updateRun(runId, { autoResumeAt: new Date(deadline).toISOString() });
|
|
998
|
+
const timer = setTimeout(() => this.fireAutoResume(runId), Math.max(0, deadline - Date.now()));
|
|
999
|
+
timer.unref?.();
|
|
1000
|
+
this.autoResumeTimers.set(runId, timer);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* The window has reopened. Re-check the record synchronously — hours may have passed, and the
|
|
1004
|
+
* user may have continued, deleted or cancelled the run in them — then hand the resume to the
|
|
1005
|
+
* ordinary queued-continuation path so it obeys both concurrency caps like any other work.
|
|
1006
|
+
*/
|
|
1007
|
+
fireAutoResume(runId) {
|
|
1008
|
+
this.autoResumeTimers.delete(runId);
|
|
1009
|
+
const run = this.store.getRun(runId);
|
|
1010
|
+
if (!run || run.status !== 'failed' || !run.autoResumeAt)
|
|
1011
|
+
return;
|
|
1012
|
+
// Belt and braces against the one gap `reconcileAutoResumes` cannot close: the setting going
|
|
1013
|
+
// off in the window between the last pump and this tick.
|
|
1014
|
+
if (!this.semaphore.autoResumeOnUsageLimit()) {
|
|
1015
|
+
this.clearAutoResume(runId);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const attempts = (run.autoResumeAttempts ?? 0) + 1;
|
|
1019
|
+
// `continueRun` retires the pending resume (timer + record fields) on the way in — this is a
|
|
1020
|
+
// resume, not a user turn, so the counter is put back straight after.
|
|
1021
|
+
const resumed = this.continueRun(runId, { text: AUTO_RESUME_PROMPT }, true);
|
|
1022
|
+
if (!resumed.ok) {
|
|
1023
|
+
// Refusals happen before `continueRun` retires anything, so the deadline is still on the
|
|
1024
|
+
// record — and a deadline in the past is a promise the cockpit keeps displaying and the
|
|
1025
|
+
// engine will never keep. Retire it here instead, and say why.
|
|
1026
|
+
this.clearAutoResume(runId);
|
|
1027
|
+
this.store.appendEvent(runId, {
|
|
1028
|
+
type: 'note',
|
|
1029
|
+
message: `automatic resume could not start — ${resumed.error ?? 'unknown'}`,
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
this.store.updateRun(runId, { autoResumeAttempts: attempts });
|
|
1034
|
+
this.store.appendEvent(runId, {
|
|
1035
|
+
type: 'lifecycle',
|
|
1036
|
+
message: `usage limit reset — resuming automatically (${attempts}/${MAX_AUTO_RESUMES})`,
|
|
1037
|
+
});
|
|
1038
|
+
// A deferred continuation only ENQUEUES itself; the queue moves when something pumps it, and
|
|
1039
|
+
// `recover()` — the other deferring caller — pumps once after its whole bulk sweep. A timer
|
|
1040
|
+
// firing on its own has no such follow-up, so without this the resumed run sits at `queued`
|
|
1041
|
+
// until some unrelated run happens to finish. This is the pump for it.
|
|
1042
|
+
void this.pump();
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Make the armed timers agree with the records and the current setting. Runs on every `pump()`
|
|
1046
|
+
* — which is where a settings change lands (a config PUT refreshes the shared semaphore, which
|
|
1047
|
+
* pumps every manager) — and once from `recover()`.
|
|
1048
|
+
*
|
|
1049
|
+
* It is a RECONCILE rather than a one-shot restore because the deadline is durable state and
|
|
1050
|
+
* the timer is not: a restart, a rebuilt project context, a manager disposed mid-wait, or a
|
|
1051
|
+
* refusal all leave a record promising a resume that no timer is holding. Rebuilding from the
|
|
1052
|
+
* record covers every one of those at once — the alternative is a hint counting down to a time
|
|
1053
|
+
* that has already passed, which is exactly the failure this method exists to make impossible.
|
|
1054
|
+
*
|
|
1055
|
+
* Cheap: an in-memory scan, and arming is skipped for every run already held.
|
|
1056
|
+
*/
|
|
1057
|
+
reconcileAutoResumes() {
|
|
1058
|
+
if (!this.semaphore.autoResumeOnUsageLimit()) {
|
|
1059
|
+
// Sweep the RECORDS, not the timer map. A record promising a resume that no timer is
|
|
1060
|
+
// holding is the exact population this method exists for, and it is also the one the
|
|
1061
|
+
// setting can be switched off in front of: cezar restarted while it was off, the config
|
|
1062
|
+
// was hand-edited, or the project context was disposed mid-wait. Retiring only the armed
|
|
1063
|
+
// timers leaves such a record with a live `autoResumeAt`, which `accountHolds()` reads as
|
|
1064
|
+
// a deadline hold — so nothing new starts on that account, `rescueStalledQueue` treats the
|
|
1065
|
+
// phantom appointment as a legitimate reason to sit still, and the cockpit shows a
|
|
1066
|
+
// `scheduled` row for a resume that will never come. `clearAutoResume` covers the armed
|
|
1067
|
+
// ones too, so this one loop is the whole cancellation.
|
|
1068
|
+
const pending = new Set([
|
|
1069
|
+
...this.autoResumeTimers.keys(),
|
|
1070
|
+
...this.store.listRuns().filter((run) => run.autoResumeAt !== undefined).map((run) => run.id),
|
|
1071
|
+
]);
|
|
1072
|
+
for (const runId of pending) {
|
|
1073
|
+
this.clearAutoResume(runId);
|
|
1074
|
+
this.store.appendEvent(runId, {
|
|
1075
|
+
type: 'note',
|
|
1076
|
+
message: 'automatic resume cancelled — auto-resume is switched off',
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
for (const run of this.store.listRuns()) {
|
|
1082
|
+
if (run.status !== 'failed' || !run.autoResumeAt)
|
|
1083
|
+
continue;
|
|
1084
|
+
if (this.autoResumeTimers.has(run.id))
|
|
1085
|
+
continue;
|
|
1086
|
+
const deadline = Date.parse(run.autoResumeAt);
|
|
1087
|
+
// A deadline that is unreadable, belongs to a run that has spent its cap, or belongs to a
|
|
1088
|
+
// task the user has archived is retired rather than re-armed: it can only mislead. One
|
|
1089
|
+
// that has just passed arms at zero — the window is open, which is the point.
|
|
1090
|
+
if (run.archived
|
|
1091
|
+
|| !Number.isFinite(deadline)
|
|
1092
|
+
|| (run.autoResumeAttempts ?? 0) >= MAX_AUTO_RESUMES) {
|
|
1093
|
+
this.store.updateRun(run.id, { autoResumeAt: undefined });
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
// …and one missed by more than a day is retired loudly: reviving a task from another era
|
|
1097
|
+
// is a surprise, not a service, and this is what keeps a sweep from resurrecting every
|
|
1098
|
+
// limit-stopped task a user has long since walked away from.
|
|
1099
|
+
if (Date.now() - deadline > AUTO_RESUME_MISSED_WINDOW_MS) {
|
|
1100
|
+
this.store.updateRun(run.id, { autoResumeAt: undefined });
|
|
1101
|
+
this.store.appendEvent(run.id, {
|
|
1102
|
+
type: 'note',
|
|
1103
|
+
message: 'automatic resume expired — its window reopened over a day ago; continue this task manually',
|
|
1104
|
+
});
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
this.armAutoResume(run.id, deadline);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Hand a run that has not spawned anything back to the queue, when the account it would run on
|
|
1112
|
+
* went into a usage-limit hold (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
1113
|
+
*
|
|
1114
|
+
* The dequeue-time gate in `pump()` cannot be the only one: a run can sit between dequeue and
|
|
1115
|
+
* spawn for a long time — an in-place run waiting for the exclusive repo-root lease is the
|
|
1116
|
+
* measured case — and the account can close in that gap. This is the last honest moment to
|
|
1117
|
+
* refuse, because everything after it costs a real agent turn.
|
|
1118
|
+
*
|
|
1119
|
+
* "Untouched" is the contract: the run has created no session and no worktree, so it goes back
|
|
1120
|
+
* as plain `queued` with its `startedAt` cleared, and `pump()` will pick it up when the window
|
|
1121
|
+
* reopens. Returns true when the caller must abandon the run.
|
|
1122
|
+
*/
|
|
1123
|
+
requeueWhileHeld(runId, workflow, input, runner, state) {
|
|
1124
|
+
const run = this.store.getRun(runId);
|
|
1125
|
+
if (!run || run.status === 'cancelled' || state?.cancelled)
|
|
1126
|
+
return false;
|
|
1127
|
+
// The watchdog sent this one through. Checked, never consumed: the spawn path asks this
|
|
1128
|
+
// question TWICE — here at the top of `execute`, and again after the exclusive repo-root
|
|
1129
|
+
// lease is granted — so a one-shot flag would clear at the first gate and let the second one
|
|
1130
|
+
// hand an in-place run straight back, re-wedging the queue the rescue had just freed.
|
|
1131
|
+
// `dropActive` retires the entry on every terminal path, so the set still cleans itself up.
|
|
1132
|
+
if (this.forceStarted.has(runId))
|
|
1133
|
+
return false;
|
|
1134
|
+
if (!accountHeldFor({ ...run, runner }, this.semaphore.accountHolds(), runner))
|
|
1135
|
+
return false;
|
|
1136
|
+
state?.releaseRepoRoot?.();
|
|
1137
|
+
if (state)
|
|
1138
|
+
state.releaseRepoRoot = undefined;
|
|
1139
|
+
this.pendingJobs.set(runId, { workflow, input });
|
|
1140
|
+
this.queue.push(runId);
|
|
1141
|
+
this.store.updateRun(runId, { status: 'queued', startedAt: undefined, currentStepId: undefined });
|
|
1142
|
+
this.store.appendEvent(runId, {
|
|
1143
|
+
type: 'note',
|
|
1144
|
+
message: 'held in the queue — this agent account is waiting out a usage limit',
|
|
1145
|
+
});
|
|
1146
|
+
this.dropActive(runId);
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* The failsafe: a queue must never be able to wedge.
|
|
1151
|
+
*
|
|
1152
|
+
* Everything else in this file makes an idle queue CORRECT under some condition — a slot cap, a
|
|
1153
|
+
* repo-root lease, and now a usage-limit hold. That is also what makes a wedged queue look
|
|
1154
|
+
* correct, and the hold has already produced one in the field: two resumes fired together, each
|
|
1155
|
+
* holding the account the other was waiting on, and the whole workspace stopped with every task
|
|
1156
|
+
* `queued`. That specific bug is fixed and tested, but "the queue stopped and nothing will ever
|
|
1157
|
+
* restart it" is too expensive a failure mode to leave resting on any single fix being right.
|
|
1158
|
+
*
|
|
1159
|
+
* The test is deliberately about JUSTIFICATION rather than about any particular bug: idling is
|
|
1160
|
+
* legitimate while work is running (here or in another project), or while a real appointment is
|
|
1161
|
+
* still ahead — a scheduled resume that will fire and pump on its own. Anything else is a
|
|
1162
|
+
* queue with work in it, nothing running anywhere, and no event coming to wake it. That gets one
|
|
1163
|
+
* forced sweep, which starts work under the ordinary caps and lets the account's real state
|
|
1164
|
+
* re-assert itself: if the window truly is shut, that task meets the limit and re-establishes an
|
|
1165
|
+
* honest hold, with a real deadline behind it this time.
|
|
1166
|
+
*
|
|
1167
|
+
* Public so a test can drive the wedge directly instead of waiting out the interval.
|
|
1168
|
+
*/
|
|
1169
|
+
async rescueStalledQueue(now = Date.now()) {
|
|
1170
|
+
// First, the worst shape: a record that says `queued` while the engine holds no job, no
|
|
1171
|
+
// continuation and no queue entry for it. `pump()` cannot see such a run — it iterates the
|
|
1172
|
+
// queue, and this one is not in it — so nothing will ever start it. Re-adopt it through the
|
|
1173
|
+
// same path boot recovery uses.
|
|
1174
|
+
for (const run of this.store.listRuns()) {
|
|
1175
|
+
if (run.status !== 'queued')
|
|
1176
|
+
continue;
|
|
1177
|
+
if (this.active.has(run.id) || this.starting.has(run.id))
|
|
1178
|
+
continue;
|
|
1179
|
+
if (this.pendingJobs.has(run.id) || this.pendingContinuations.has(run.id))
|
|
1180
|
+
continue;
|
|
1181
|
+
if (this.queue.includes(run.id))
|
|
1182
|
+
continue;
|
|
1183
|
+
console.warn(`[cez] queue watchdog: re-adopting queued run ${run.id} the engine had lost`);
|
|
1184
|
+
await this.reviveQueuedRun(run, 'queue watchdog');
|
|
1185
|
+
}
|
|
1186
|
+
if (this.queue.length === 0)
|
|
1187
|
+
return;
|
|
1188
|
+
if (this.busySlots() > 0 || this.starting.size > 0)
|
|
1189
|
+
return;
|
|
1190
|
+
if (this.semaphore.busy() > 0)
|
|
1191
|
+
return;
|
|
1192
|
+
// A future deadline is a real reason to sit still: that timer will fire and pump.
|
|
1193
|
+
for (const run of this.store.listRuns()) {
|
|
1194
|
+
if (run.status !== 'failed' || !run.autoResumeAt)
|
|
1195
|
+
continue;
|
|
1196
|
+
const deadline = Date.parse(run.autoResumeAt);
|
|
1197
|
+
if (Number.isFinite(deadline) && deadline > now)
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (this.semaphore.accountHolds().inFlight.size === 0) {
|
|
1201
|
+
// Not the hold, then — some other wakeup went missing. An ordinary pump is the whole fix,
|
|
1202
|
+
// and it is idempotent, so this stays quiet.
|
|
1203
|
+
void this.pump();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
console.warn('[cez] queue watchdog: work is queued, nothing is running, and the usage-limit hold has no'
|
|
1207
|
+
+ ' deadline behind it — starting the next task anyway');
|
|
1208
|
+
this.forceNextPump = true;
|
|
1209
|
+
void this.pump();
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* The accounts this project is currently holding: one key per run parked on a usage-limit
|
|
1213
|
+
* resume that has not come due yet (spec 2026-08-03-auto-resume-after-usage-limit).
|
|
1214
|
+
*
|
|
1215
|
+
* Published to the shared semaphore so the hold spans PROJECTS — one Claude account can be
|
|
1216
|
+
* driving tasks in three repos, and a limit closes it for all of them. Derived from the
|
|
1217
|
+
* records on every ask rather than tracked as state: a deadline that passes, a resume that
|
|
1218
|
+
* fires, a cancel, an archive and a delete all lift the hold with no bookkeeping.
|
|
1219
|
+
*
|
|
1220
|
+
* Deliberately excludes a deadline that has already passed — that run is about to resume, and
|
|
1221
|
+
* holding the queue for it would only stall the very work the window reopened for.
|
|
1222
|
+
*/
|
|
1223
|
+
accountHolds(now = Date.now()) {
|
|
1224
|
+
const deadline = new Set();
|
|
1225
|
+
const inFlight = new Set();
|
|
1226
|
+
for (const run of this.store.listRuns()) {
|
|
1227
|
+
// A holding run always carries the runner it actually ran on, so the fallback is unused
|
|
1228
|
+
// here — it is spelled out rather than `!` so a future record shape degrades, not throws.
|
|
1229
|
+
const key = () => runAccountKey(run, run.runner ?? 'claude');
|
|
1230
|
+
if (run.status === 'failed' && run.autoResumeAt) {
|
|
1231
|
+
const at = Date.parse(run.autoResumeAt);
|
|
1232
|
+
if (Number.isFinite(at) && at > now)
|
|
1233
|
+
deadline.add(key());
|
|
1234
|
+
}
|
|
1235
|
+
else if (resumeInFlight(run)) {
|
|
1236
|
+
inFlight.add(key());
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return { deadline, inFlight };
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* The PER-TASK off switch (`DELETE /api/v1/runs/:id/auto-resume`, and the archive route):
|
|
1243
|
+
* stop resuming THIS task, without touching the workspace setting or any other task.
|
|
1244
|
+
*
|
|
1245
|
+
* Idempotent — a run with nothing pending answers the same way, because "this task will not
|
|
1246
|
+
* resume itself" is equally true either way. Returns false only when the run does not exist,
|
|
1247
|
+
* which is the route's 404.
|
|
1248
|
+
*/
|
|
1249
|
+
cancelAutoResume(runId) {
|
|
1250
|
+
const run = this.store.getRun(runId);
|
|
1251
|
+
if (!run)
|
|
1252
|
+
return false;
|
|
1253
|
+
const pending = run.autoResumeAt !== undefined || this.autoResumeTimers.has(runId);
|
|
1254
|
+
this.clearAutoResume(runId);
|
|
1255
|
+
if (pending) {
|
|
1256
|
+
this.store.appendEvent(runId, {
|
|
1257
|
+
type: 'note',
|
|
1258
|
+
message: 'automatic resume cancelled for this task',
|
|
1259
|
+
});
|
|
1260
|
+
// This run may have been the last thing holding its account's queue — nothing else will
|
|
1261
|
+
// notice, since the hold is derived and its release is not an event.
|
|
1262
|
+
void this.pump();
|
|
1263
|
+
}
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
/** Retire a pending resume — timer, deadline and counter. The counter goes too because every
|
|
1267
|
+
* caller is a fresh epoch: a human Continue, or a resume that re-stamps its own count. */
|
|
1268
|
+
clearAutoResume(runId) {
|
|
1269
|
+
const timer = this.autoResumeTimers.get(runId);
|
|
1270
|
+
if (timer)
|
|
1271
|
+
clearTimeout(timer);
|
|
1272
|
+
this.autoResumeTimers.delete(runId);
|
|
1273
|
+
const run = this.store.getRun(runId);
|
|
1274
|
+
if (!run)
|
|
1275
|
+
return;
|
|
1276
|
+
if (run.autoResumeAt !== undefined || run.autoResumeAttempts !== undefined) {
|
|
1277
|
+
this.store.updateRun(runId, { autoResumeAt: undefined, autoResumeAttempts: undefined });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
649
1280
|
/** Reclaim finished worktrees beyond the keep-limit (#483) — directory only,
|
|
650
1281
|
* `cez/<id8>` branch kept. Best-effort; a failure never affects run
|
|
651
1282
|
* lifecycle. `review`/live runs are excluded by the selector. */
|
|
@@ -723,6 +1354,7 @@ export class RunManager {
|
|
|
723
1354
|
if (queuedAt >= 0) {
|
|
724
1355
|
this.queue.splice(queuedAt, 1);
|
|
725
1356
|
this.pendingJobs.delete(runId);
|
|
1357
|
+
this.pendingContinuations.delete(runId);
|
|
726
1358
|
this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
|
|
727
1359
|
this.store.appendEvent(runId, { type: 'lifecycle', message: 'cancelled while queued' });
|
|
728
1360
|
return true;
|
|
@@ -763,35 +1395,13 @@ export class RunManager {
|
|
|
763
1395
|
.map((part) => part.trim())
|
|
764
1396
|
.filter((part) => part.length > 0)
|
|
765
1397
|
.join('\n\n');
|
|
766
|
-
const readImages = (urls, kind) => {
|
|
767
|
-
const images = [];
|
|
768
|
-
for (const url of urls) {
|
|
769
|
-
const name = url.split('/').pop();
|
|
770
|
-
if (!name || name.includes('..') || name.includes('/') || name.includes('\\'))
|
|
771
|
-
continue;
|
|
772
|
-
try {
|
|
773
|
-
const data = readFileSync(join(this.dataDir, 'runs', `${runId}-images`, name));
|
|
774
|
-
images.push({
|
|
775
|
-
type: 'image',
|
|
776
|
-
source: { type: 'base64', media_type: mediaTypeFor(name), data: data.toString('base64') },
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
catch {
|
|
780
|
-
// Degrade, never fail the boot (AGENTS.md): the user deleted `.ai/cezar/`
|
|
781
|
-
// or the file is unreadable — start with the text and say which image went.
|
|
782
|
-
this.store.appendEvent(runId, {
|
|
783
|
-
type: 'note',
|
|
784
|
-
message: `${kind} attachment ${name} could not be read — starting without it`,
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
return images;
|
|
789
|
-
};
|
|
790
1398
|
// Keep the original in-memory blocks for a live process (including the
|
|
791
1399
|
// best-effort case where persistence failed). Recovery has no such copy,
|
|
792
1400
|
// so rebuild it from the durable task-image URLs.
|
|
793
|
-
const images = input.images?.length
|
|
794
|
-
|
|
1401
|
+
const images = input.images?.length
|
|
1402
|
+
? input.images
|
|
1403
|
+
: this.readPersistedImages(runId, run.taskImages ?? [], 'task').blocks;
|
|
1404
|
+
const stackedImages = this.readPersistedImages(runId, stack.flatMap((m) => m.images ?? []), 'queued').blocks;
|
|
795
1405
|
return {
|
|
796
1406
|
...input,
|
|
797
1407
|
task,
|
|
@@ -799,15 +1409,67 @@ export class RunManager {
|
|
|
799
1409
|
...(stackedImages.length ? { stackedImages } : { stackedImages: undefined }),
|
|
800
1410
|
};
|
|
801
1411
|
}
|
|
1412
|
+
/** Apply edits and messages made while a restart continuation waits for
|
|
1413
|
+
* capacity. The durable record remains the source of truth, just as it is for
|
|
1414
|
+
* an ordinary queued workflow (#472), so a second restart reconstructs and
|
|
1415
|
+
* hydrates the same amendments instead of dropping them. */
|
|
1416
|
+
hydrateQueuedContinuation(runId, continuation) {
|
|
1417
|
+
const run = this.store.getRun(runId);
|
|
1418
|
+
if (!run) {
|
|
1419
|
+
return { ...continuation, persistedImages: [], persistedAttachments: [] };
|
|
1420
|
+
}
|
|
1421
|
+
const stack = run.queuedMessages ?? [];
|
|
1422
|
+
const amendedTask = [run.task, ...stack.map((message) => message.text)]
|
|
1423
|
+
.map((part) => part.trim())
|
|
1424
|
+
.filter((part) => part.length > 0)
|
|
1425
|
+
.join('\n\n');
|
|
1426
|
+
const prompt = amendedTask
|
|
1427
|
+
? `${continuation.prompt}\n\nCurrent task and queued updates:\n\n${amendedTask}`
|
|
1428
|
+
: continuation.prompt;
|
|
1429
|
+
const persisted = this.readPersistedImages(runId, stack.flatMap((message) => message.images ?? []), 'queued');
|
|
1430
|
+
return {
|
|
1431
|
+
...continuation,
|
|
1432
|
+
prompt,
|
|
1433
|
+
persistedImages: persisted.blocks,
|
|
1434
|
+
persistedAttachments: persisted.attachments,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
readPersistedImages(runId, urls, kind) {
|
|
1438
|
+
const blocks = [];
|
|
1439
|
+
const attachments = [];
|
|
1440
|
+
for (const url of urls) {
|
|
1441
|
+
const name = url.split('/').pop();
|
|
1442
|
+
if (!name || name.includes('..') || name.includes('/') || name.includes('\\'))
|
|
1443
|
+
continue;
|
|
1444
|
+
const path = join(this.dataDir, 'runs', `${runId}-images`, name);
|
|
1445
|
+
try {
|
|
1446
|
+
const data = readFileSync(path);
|
|
1447
|
+
blocks.push({
|
|
1448
|
+
type: 'image',
|
|
1449
|
+
source: { type: 'base64', media_type: mediaTypeFor(name), data: data.toString('base64') },
|
|
1450
|
+
});
|
|
1451
|
+
attachments.push({ name, url, path });
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
// Degrade, never fail the boot (AGENTS.md): the user deleted `.ai/cezar/`
|
|
1455
|
+
// or the file is unreadable — start with the text and say which image went.
|
|
1456
|
+
this.store.appendEvent(runId, {
|
|
1457
|
+
type: 'note',
|
|
1458
|
+
message: `${kind} attachment ${name} could not be read — starting without it`,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return { blocks, attachments };
|
|
1463
|
+
}
|
|
802
1464
|
/**
|
|
803
1465
|
* Still waiting for a slot? Checked against the engine's own queue rather than
|
|
804
1466
|
* the record's `status` (#472): the record is written by `execute()` a tick
|
|
805
1467
|
* after `pump()` dequeues, so a status read can see `queued` for a run that has
|
|
806
|
-
* already started.
|
|
807
|
-
* the authoritative answer for "can this prompt still be amended".
|
|
1468
|
+
* already started. The pending maps are deleted synchronously at dequeue, so
|
|
1469
|
+
* they are the authoritative answer for "can this prompt still be amended".
|
|
808
1470
|
*/
|
|
809
1471
|
isQueued(runId) {
|
|
810
|
-
return this.pendingJobs.has(runId);
|
|
1472
|
+
return this.pendingJobs.has(runId) || this.pendingContinuations.has(runId);
|
|
811
1473
|
}
|
|
812
1474
|
/** Split `ContentBlock[]` into the persisted shape a stacked message holds. */
|
|
813
1475
|
toQueuedMessage(runId, content) {
|
|
@@ -987,6 +1649,18 @@ export class RunManager {
|
|
|
987
1649
|
* then offers "Continue" instead.
|
|
988
1650
|
*/
|
|
989
1651
|
sendMessage(runId, content) {
|
|
1652
|
+
const delivered = this.deliverMessage(runId, content, true);
|
|
1653
|
+
if (delivered) {
|
|
1654
|
+
const state = this.active.get(runId);
|
|
1655
|
+
if (state)
|
|
1656
|
+
state.monitoringWakeups = 0;
|
|
1657
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: undefined });
|
|
1658
|
+
}
|
|
1659
|
+
return delivered;
|
|
1660
|
+
}
|
|
1661
|
+
/** Shared live-session delivery. Synthetic scheduler prompts reuse lifecycle
|
|
1662
|
+
* bookkeeping without masquerading as user-authored transcript messages. */
|
|
1663
|
+
deliverMessage(runId, content, userAuthored) {
|
|
990
1664
|
const state = this.active.get(runId);
|
|
991
1665
|
if (!state?.session?.open || state.cancelled)
|
|
992
1666
|
return false;
|
|
@@ -997,27 +1671,32 @@ export class RunManager {
|
|
|
997
1671
|
// Persist the attached images so the thread can render them (not just count them) — the same
|
|
998
1672
|
// on-disk store + `/images/` route the agent's own screenshots use. `pasted` prefix marks
|
|
999
1673
|
// these as user attachments (vs. agent tool screenshots) on disk (#357).
|
|
1000
|
-
const persisted = content
|
|
1674
|
+
const persisted = userAuthored ? content
|
|
1001
1675
|
.filter((b) => b.type === 'image')
|
|
1002
1676
|
.map((b) => this.persistImage(runId, b.source.media_type, b.source.data, 'pasted'))
|
|
1003
|
-
.filter((saved) => saved !== null);
|
|
1677
|
+
.filter((saved) => saved !== null) : [];
|
|
1004
1678
|
const images = persisted.map((saved) => saved.url);
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1679
|
+
if (userAuthored) {
|
|
1680
|
+
this.store.appendEvent(runId, {
|
|
1681
|
+
type: 'user-message',
|
|
1682
|
+
stepId: state.currentStepId,
|
|
1683
|
+
text,
|
|
1684
|
+
imageCount: content.filter((b) => b.type === 'image').length,
|
|
1685
|
+
images,
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1012
1688
|
// Tell the agent where the pasted files live on disk (#357): the base64 blocks below still
|
|
1013
1689
|
// ride along so the model can *view* them, but a real path is what lets it *operate* on them
|
|
1014
1690
|
// (save, `cp`, attach to a GitHub issue/PR) — and it's the only usable reference on backends
|
|
1015
1691
|
// (codex, opencode) that drop image blocks entirely before reaching the model.
|
|
1016
|
-
const
|
|
1692
|
+
const expanded = userAuthored ? expandRegistrySlashSkill(content, state.skills ?? []) : content;
|
|
1693
|
+
const deliverable = persisted.length ? [...expanded, pastedAttachmentsNote(persisted)] : expanded;
|
|
1017
1694
|
const delivered = state.session.sendMessage(deliverable);
|
|
1018
1695
|
if (delivered) {
|
|
1019
1696
|
this.clearIdleTimer(state);
|
|
1697
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
1020
1698
|
this.waiting.delete(runId); // resumed — the run counts against slots again
|
|
1699
|
+
this.monitoring.delete(runId);
|
|
1021
1700
|
// Clear any `monitoring` activity — the agent is actively working again
|
|
1022
1701
|
// (spec 2026-07-18-subagent-monitoring-status, #490).
|
|
1023
1702
|
this.store.updateRun(runId, { status: 'running', activity: undefined });
|
|
@@ -1054,7 +1733,13 @@ export class RunManager {
|
|
|
1054
1733
|
* behaves exactly like an interactive step: `waiting` after each turn,
|
|
1055
1734
|
* messages via sendMessage, closed by finish/idle/cancel.
|
|
1056
1735
|
*/
|
|
1057
|
-
continueRun(runId, opts = {}
|
|
1736
|
+
continueRun(runId, opts = {},
|
|
1737
|
+
/** Restart recovery may discover several interrupted tasks at once. Those
|
|
1738
|
+
* continuations are queued; an explicit user Continue remains immediate. */
|
|
1739
|
+
deferForCapacity = false) {
|
|
1740
|
+
if (agentModelsLocked(this.repoRoot) && opts.model?.trim()) {
|
|
1741
|
+
return { ok: false, error: AGENT_MODELS_LOCKED_ERROR };
|
|
1742
|
+
}
|
|
1058
1743
|
if (this.active.has(runId))
|
|
1059
1744
|
return { ok: false, error: 'run is still active' };
|
|
1060
1745
|
const run = this.store.getRun(runId);
|
|
@@ -1105,10 +1790,34 @@ export class RunManager {
|
|
|
1105
1790
|
: {}),
|
|
1106
1791
|
});
|
|
1107
1792
|
}
|
|
1793
|
+
// Everything that could refuse this continuation has now passed, so a pending usage-limit
|
|
1794
|
+
// resume is superseded either way: this IS that resume (it re-stamps its own counter), or a
|
|
1795
|
+
// human got there first — and then the counter starts over, because the cap only exists to
|
|
1796
|
+
// bound UNATTENDED resumes.
|
|
1797
|
+
this.clearAutoResume(runId);
|
|
1108
1798
|
const continuations = run.steps.filter((s) => s.id.startsWith('continue-')).length;
|
|
1109
1799
|
const stepId = `continue-${continuations + 1}`;
|
|
1110
1800
|
this.store.addStep(runId, { id: stepId, name: 'Continue', kind: 'agent' });
|
|
1111
|
-
|
|
1801
|
+
const prompt = opts.text?.trim() || 'Continue.';
|
|
1802
|
+
const images = opts.images ?? [];
|
|
1803
|
+
if (deferForCapacity) {
|
|
1804
|
+
this.pendingContinuations.set(runId, {
|
|
1805
|
+
stepId,
|
|
1806
|
+
sessionId: resume ? sessionStep.sessionId : undefined,
|
|
1807
|
+
backend: targetRunner,
|
|
1808
|
+
prompt,
|
|
1809
|
+
images,
|
|
1810
|
+
});
|
|
1811
|
+
this.queue.push(runId);
|
|
1812
|
+
this.store.updateRun(runId, {
|
|
1813
|
+
status: 'queued',
|
|
1814
|
+
error: undefined,
|
|
1815
|
+
finishedAt: undefined,
|
|
1816
|
+
currentStepId: undefined,
|
|
1817
|
+
});
|
|
1818
|
+
return { ok: true };
|
|
1819
|
+
}
|
|
1820
|
+
void this.runContinuation(runId, stepId, resume ? sessionStep.sessionId : undefined, targetRunner, prompt, images).catch((err) => {
|
|
1112
1821
|
const message = err instanceof Error ? err.message : String(err);
|
|
1113
1822
|
this.store.updateRun(runId, {
|
|
1114
1823
|
status: 'failed',
|
|
@@ -1123,7 +1832,11 @@ export class RunManager {
|
|
|
1123
1832
|
/** Screenshots pasted into the follow-up composer — delivered with the
|
|
1124
1833
|
* reopened session's opening message, exactly like a live-session
|
|
1125
1834
|
* message's attachments. */
|
|
1126
|
-
images = []
|
|
1835
|
+
images = [],
|
|
1836
|
+
/** Queued-message screenshots were persisted when they were enqueued and
|
|
1837
|
+
* reconstructed at dequeue. Keep them separate from fresh `images` so
|
|
1838
|
+
* opening a recovered continuation does not persist duplicate files. */
|
|
1839
|
+
persistedImages = [], persistedAttachments = []) {
|
|
1127
1840
|
// Continuation runs in the task's worktree when it still exists (spec
|
|
1128
1841
|
// 006) — the resumed session sees exactly what the original run left.
|
|
1129
1842
|
// Retention (#483) may have reclaimed this run's worktree directory while
|
|
@@ -1141,20 +1854,29 @@ export class RunManager {
|
|
|
1141
1854
|
: this.repoRoot;
|
|
1142
1855
|
const state = { cancelled: false, interrupt: () => undefined, cwd };
|
|
1143
1856
|
this.active.set(runId, state);
|
|
1857
|
+
this.starting.delete(runId);
|
|
1144
1858
|
if (state.cwd === this.repoRoot) {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
if (!(await this.acquireRepoRoot(runId, state))) {
|
|
1150
|
-
this.store.updateRun(runId, {
|
|
1151
|
-
status: 'cancelled',
|
|
1152
|
-
finishedAt: new Date().toISOString(),
|
|
1153
|
-
currentStepId: undefined,
|
|
1859
|
+
if (repositoryRootLockDisabled()) {
|
|
1860
|
+
this.store.appendEvent(runId, {
|
|
1861
|
+
type: 'note',
|
|
1862
|
+
message: 'repository-root lock disabled by CEZ_DISABLE_REPO_LOCK=1 (shared checkout is unsafe)',
|
|
1154
1863
|
});
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1864
|
+
}
|
|
1865
|
+
else {
|
|
1866
|
+
this.store.appendEvent(runId, {
|
|
1867
|
+
type: 'note',
|
|
1868
|
+
message: 'waiting for exclusive access to the repository working tree',
|
|
1869
|
+
});
|
|
1870
|
+
if (!(await this.acquireRepoRoot(runId, state))) {
|
|
1871
|
+
this.store.updateRun(runId, {
|
|
1872
|
+
status: 'cancelled',
|
|
1873
|
+
finishedAt: new Date().toISOString(),
|
|
1874
|
+
currentStepId: undefined,
|
|
1875
|
+
});
|
|
1876
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
|
|
1877
|
+
this.dropActive(runId);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1158
1880
|
}
|
|
1159
1881
|
}
|
|
1160
1882
|
this.armAutosave(state);
|
|
@@ -1180,19 +1902,22 @@ export class RunManager {
|
|
|
1180
1902
|
// images rather than a bare count, and handed to the agent BOTH as base64 blocks (so it can
|
|
1181
1903
|
// view them) and as absolute paths appended to the prompt (so it can operate on them — and
|
|
1182
1904
|
// because codex/opencode drop image blocks before they reach the model).
|
|
1183
|
-
const
|
|
1905
|
+
const freshAttachments = images
|
|
1184
1906
|
.filter((b) => b.type === 'image')
|
|
1185
1907
|
.map((b) => this.persistImage(runId, b.source.media_type, b.source.data, 'pasted'))
|
|
1186
1908
|
.filter((saved) => saved !== null);
|
|
1909
|
+
const openingImages = [...images, ...persistedImages];
|
|
1910
|
+
const attachments = [...freshAttachments, ...persistedAttachments];
|
|
1187
1911
|
this.store.appendEvent(runId, {
|
|
1188
1912
|
type: 'user-message',
|
|
1189
1913
|
stepId,
|
|
1190
1914
|
text: prompt,
|
|
1191
|
-
imageCount:
|
|
1915
|
+
imageCount: openingImages.filter((b) => b.type === 'image').length,
|
|
1192
1916
|
...(attachments.length ? { images: attachments.map((saved) => saved.url) } : {}),
|
|
1193
1917
|
});
|
|
1194
1918
|
let stepCost = 0;
|
|
1195
1919
|
let turnText = '';
|
|
1920
|
+
let sessionError;
|
|
1196
1921
|
const sink = this.makeUiSink(runId, stepId);
|
|
1197
1922
|
const onEvent = (event) => {
|
|
1198
1923
|
if (event.type === 'image') {
|
|
@@ -1209,6 +1934,13 @@ export class RunManager {
|
|
|
1209
1934
|
return;
|
|
1210
1935
|
}
|
|
1211
1936
|
this.store.appendEvent(runId, { ...event, stepId });
|
|
1937
|
+
if (event.type === 'error') {
|
|
1938
|
+
sessionError ??= event.message;
|
|
1939
|
+
state.session?.interrupt();
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (sessionError)
|
|
1943
|
+
return;
|
|
1212
1944
|
if (event.type === 'session') {
|
|
1213
1945
|
this.store.updateStep(runId, stepId, { sessionId: event.sessionId, backend });
|
|
1214
1946
|
}
|
|
@@ -1229,9 +1961,13 @@ export class RunManager {
|
|
|
1229
1961
|
const done = sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
1230
1962
|
// `CEZ:ASK` → the user is genuinely blocked; wins over `CEZ:MONITORING`
|
|
1231
1963
|
// (a pending question is always attention), loses to `CEZ:DONE` (#473).
|
|
1232
|
-
const
|
|
1964
|
+
const askResult = sessionOpen && !done ? parseAskMarkerResult(turnText) : undefined;
|
|
1965
|
+
const ask = askResult?.kind === 'valid' ? askResult.request : null;
|
|
1966
|
+
const askRejection = askResult ? askMarkerRejection(askResult) : undefined;
|
|
1233
1967
|
const monitoring = sessionOpen && !done && !ask && MONITORING_MARKER_RE.test(turnText.trimEnd());
|
|
1234
1968
|
turnText = '';
|
|
1969
|
+
if (askRejection)
|
|
1970
|
+
this.store.appendEvent(runId, { type: 'note', message: askRejection, stepId });
|
|
1235
1971
|
if (done) {
|
|
1236
1972
|
// Goal achieved (agent contract, #347) — same as in runAgentStep.
|
|
1237
1973
|
this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
|
|
@@ -1267,16 +2003,30 @@ export class RunManager {
|
|
|
1267
2003
|
if (monitoring) {
|
|
1268
2004
|
this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
|
|
1269
2005
|
this.store.updateStep(runId, stepId, { status: 'running' });
|
|
2006
|
+
this.monitoring.add(runId);
|
|
2007
|
+
this.clearIdleTimer(state);
|
|
2008
|
+
this.armMonitoringWakeTimer(runId, state);
|
|
1270
2009
|
}
|
|
1271
2010
|
else {
|
|
1272
2011
|
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
1273
2012
|
this.store.updateStep(runId, stepId, { status: 'waiting' });
|
|
2013
|
+
this.monitoring.delete(runId);
|
|
2014
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
1274
2015
|
}
|
|
1275
2016
|
this.waiting.add(runId);
|
|
1276
|
-
|
|
2017
|
+
if (!monitoring)
|
|
2018
|
+
this.armIdleTimer(runId, state);
|
|
1277
2019
|
this.releaseSlot();
|
|
1278
2020
|
}
|
|
1279
2021
|
}
|
|
2022
|
+
// A turn that completed is the ONLY evidence the provider's window actually reopened, so
|
|
2023
|
+
// it is what retires the consecutive-resume counter — which in turn releases the account
|
|
2024
|
+
// hold for every other task queued behind it (spec
|
|
2025
|
+
// 2026-08-03-auto-resume-after-usage-limit). `settleSuccess` does the same for a run that
|
|
2026
|
+
// finishes outright; this covers the far more common "parked for the user" ending.
|
|
2027
|
+
if (this.store.getRun(runId)?.autoResumeAttempts !== undefined) {
|
|
2028
|
+
this.store.updateRun(runId, { autoResumeAttempts: undefined });
|
|
2029
|
+
}
|
|
1280
2030
|
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
|
|
1281
2031
|
}
|
|
1282
2032
|
};
|
|
@@ -1291,7 +2041,7 @@ export class RunManager {
|
|
|
1291
2041
|
// instead of `opus`). Fail loud here too rather than let the backend pick a default.
|
|
1292
2042
|
let continueModel;
|
|
1293
2043
|
try {
|
|
1294
|
-
const normalized = normalizeModelForBackend(continueBackend, record?.model);
|
|
2044
|
+
const normalized = normalizeModelForBackend(continueBackend, agentModelsLocked(this.repoRoot) ? undefined : record?.model, { configuredProvider: await configuredModelProvider(continueBackend, state.cwd) });
|
|
1295
2045
|
continueModel = normalized?.backendModel;
|
|
1296
2046
|
this.store.updateRun(runId, {
|
|
1297
2047
|
modelIdentity: normalized ? formatModelIdentity(normalized.identity) : undefined,
|
|
@@ -1320,18 +2070,31 @@ export class RunManager {
|
|
|
1320
2070
|
this.dropActive(runId);
|
|
1321
2071
|
return;
|
|
1322
2072
|
}
|
|
2073
|
+
// Resuming reattaches to a session that lives inside ONE account's config dir, so the
|
|
2074
|
+
// continuation must run under the account that created it — not whatever the project has
|
|
2075
|
+
// been switched to since. The owning step is the one carrying this session id.
|
|
2076
|
+
const resumedProfileId = sessionId === undefined
|
|
2077
|
+
? undefined
|
|
2078
|
+
: record?.steps.find((s) => s.sessionId === sessionId)?.profileId;
|
|
2079
|
+
const continueProfile = await this.agentEnvForStep(runId, continueBackend, {
|
|
2080
|
+
generateFollowups,
|
|
2081
|
+
recordedProfileId: resumedProfileId,
|
|
2082
|
+
});
|
|
2083
|
+
this.store.updateStep(runId, stepId, { profileId: continueProfile.profileId });
|
|
1323
2084
|
const runner = createRunner(continueBackend);
|
|
2085
|
+
state.currentStepId = stepId;
|
|
2086
|
+
this.beginUsageInvocation(runId, state, stepId);
|
|
1324
2087
|
const session = runner.startSession({
|
|
1325
2088
|
// The Continue step is a fresh agent session on the same run — the
|
|
1326
2089
|
// run's extra system prompt (already resolved at execute time and
|
|
1327
2090
|
// echoed on the record) rides along with the handoff contract.
|
|
1328
2091
|
systemPrompt: composeSystemPrompt(record?.systemPrompt, generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS),
|
|
1329
2092
|
userPrompt: attachments.length ? `${prompt}\n\n${pastedAttachmentsText(attachments)}` : prompt,
|
|
1330
|
-
...(
|
|
2093
|
+
...(openingImages.length ? { images: openingImages } : {}),
|
|
1331
2094
|
cwd: state.cwd,
|
|
1332
2095
|
allowedTools: DEFAULT_ALLOWED_TOOLS,
|
|
1333
2096
|
additionalDirectories: [join(this.dataDir, 'runs')],
|
|
1334
|
-
env:
|
|
2097
|
+
env: continueProfile.env,
|
|
1335
2098
|
model: continueModel,
|
|
1336
2099
|
sessionId,
|
|
1337
2100
|
resume: sessionId !== undefined,
|
|
@@ -1340,13 +2103,14 @@ export class RunManager {
|
|
|
1340
2103
|
state.session = session;
|
|
1341
2104
|
state.sessionEverOpened = true;
|
|
1342
2105
|
this.flushDeferred(runId);
|
|
1343
|
-
state.currentStepId = stepId;
|
|
1344
2106
|
state.interrupt = () => session.interrupt();
|
|
1345
2107
|
if (session.pid !== undefined)
|
|
1346
2108
|
registerRunProcess(runId, session.pid);
|
|
1347
2109
|
const finishedAt = () => new Date().toISOString();
|
|
1348
2110
|
try {
|
|
1349
2111
|
await session.result;
|
|
2112
|
+
if (sessionError)
|
|
2113
|
+
throw new Error(sessionError);
|
|
1350
2114
|
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
1351
2115
|
if (state.cancelled) {
|
|
1352
2116
|
this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
|
|
@@ -1399,6 +2163,12 @@ export class RunManager {
|
|
|
1399
2163
|
// the config default. Per-step `runner` can still override it below.
|
|
1400
2164
|
const config = await loadConfig(this.repoRoot);
|
|
1401
2165
|
const taskBackend = input.runner ?? config.defaultRunner;
|
|
2166
|
+
// The account may have gone into a usage-limit hold since this run was dequeued — the queue
|
|
2167
|
+
// gate cannot be the only one, because dequeue is not the moment of no return. Nothing has
|
|
2168
|
+
// happened yet here, so the run goes back to the queue untouched (spec
|
|
2169
|
+
// 2026-08-03-auto-resume-after-usage-limit).
|
|
2170
|
+
if (this.requeueWhileHeld(runId, workflow, input, taskBackend))
|
|
2171
|
+
return;
|
|
1402
2172
|
// Extra system prompt (R2 2.3): POST override > config default; echoed on
|
|
1403
2173
|
// the record so the UI/API can show what the run actually used.
|
|
1404
2174
|
const extraSystemPrompt = resolveExtraSystemPrompt(input.systemPrompt, config.systemPrompt);
|
|
@@ -1408,7 +2178,7 @@ export class RunManager {
|
|
|
1408
2178
|
// can still override below); the authoritative fail-loud gate is at spawn.
|
|
1409
2179
|
let modelIdentity;
|
|
1410
2180
|
try {
|
|
1411
|
-
const normalized = normalizeModelForBackend(taskBackend, input.model);
|
|
2181
|
+
const normalized = normalizeModelForBackend(taskBackend, agentModelsLocked(this.repoRoot) ? undefined : input.model, { configuredProvider: await configuredModelProvider(taskBackend, this.repoRoot) });
|
|
1412
2182
|
modelIdentity = normalized ? formatModelIdentity(normalized.identity) : undefined;
|
|
1413
2183
|
}
|
|
1414
2184
|
catch {
|
|
@@ -1429,11 +2199,20 @@ export class RunManager {
|
|
|
1429
2199
|
// established; only explicit opt-out and non-Git modes run in place.
|
|
1430
2200
|
const repo = await getRepoInfo(this.repoRoot);
|
|
1431
2201
|
if (repo && input.worktree === false) {
|
|
1432
|
-
// Composer opt-out: run in the repo working tree, no branch/worktree
|
|
1433
|
-
//
|
|
2202
|
+
// Composer opt-out: run in the repo working tree, no branch/worktree. The
|
|
2203
|
+
// repository-root lease serializes these runs so workflows cannot overlap.
|
|
2204
|
+
// Pin the starting commit: the session's Changes and Commits views use it
|
|
2205
|
+
// as their stable lower bound while reading the current working copy.
|
|
2206
|
+
const startingCommit = await getHeadCommit(repo.root);
|
|
2207
|
+
if (startingCommit)
|
|
2208
|
+
this.store.updateRun(runId, { baseBranch: startingCommit });
|
|
1434
2209
|
emit({ type: 'note', message: 'worktree off — running in the repo working tree' });
|
|
1435
2210
|
}
|
|
1436
2211
|
else if (repo) {
|
|
2212
|
+
emit({
|
|
2213
|
+
type: 'note',
|
|
2214
|
+
message: `worktree on — using an isolated task worktree (${input.worktree === true ? 'explicit request' : 'default'})`,
|
|
2215
|
+
});
|
|
1437
2216
|
// Fork from the configured base branch (config.json `baseBranch`, e.g.
|
|
1438
2217
|
// `develop`) — also the target of the eventual draft PR. Unresolvable
|
|
1439
2218
|
// (typo, not fetched) → note + the currently checked-out branch.
|
|
@@ -1493,14 +2272,31 @@ export class RunManager {
|
|
|
1493
2272
|
emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
|
|
1494
2273
|
}
|
|
1495
2274
|
if (state.cwd === this.repoRoot) {
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
2275
|
+
if (repositoryRootLockDisabled()) {
|
|
2276
|
+
emit({
|
|
2277
|
+
type: 'note',
|
|
2278
|
+
message: 'repository-root lock disabled by CEZ_DISABLE_REPO_LOCK=1 (shared checkout is unsafe)',
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
else {
|
|
2282
|
+
emit({
|
|
2283
|
+
type: 'note',
|
|
2284
|
+
message: 'waiting for exclusive access to the repository working tree',
|
|
2285
|
+
});
|
|
2286
|
+
// A cancel during the wait leaves the lease ungranted; the step loop
|
|
2287
|
+
// below breaks on `cancelled` before touching the tree and settles the
|
|
2288
|
+
// run through the usual path.
|
|
2289
|
+
await this.acquireRepoRoot(runId, state);
|
|
2290
|
+
}
|
|
2291
|
+
// THE window that matters for an in-place run. Waiting for the exclusive tree can take
|
|
2292
|
+
// minutes, and a run parked on that lease holds no slot (#347) — so the queue keeps
|
|
2293
|
+
// advancing behind it and the dequeue-time gate is long past. Measured with five in-place
|
|
2294
|
+
// tasks and `maxParallel: 2`: four of them started. Re-ask here, where the very next thing
|
|
2295
|
+
// is a spawn, and hand the run back to the queue if the account closed meanwhile. This
|
|
2296
|
+
// check also covers the explicit lock-bypass path, where the account may close while the
|
|
2297
|
+
// run is preparing its first step.
|
|
2298
|
+
if (this.requeueWhileHeld(runId, workflow, input, taskBackend, state))
|
|
2299
|
+
return;
|
|
1504
2300
|
}
|
|
1505
2301
|
// Handoff journal (spec 007) — seeded after the worktree exists so the
|
|
1506
2302
|
// header can name the branch. Idempotent: an existing file stays as-is.
|
|
@@ -1508,6 +2304,7 @@ export class RunManager {
|
|
|
1508
2304
|
if (seeded)
|
|
1509
2305
|
seedHandoffFile(this.dataDir, seeded);
|
|
1510
2306
|
const skills = await discoverSkills(this.repoRoot);
|
|
2307
|
+
state.skills = skills;
|
|
1511
2308
|
const retriesUsed = new Map();
|
|
1512
2309
|
let checkFailure = null;
|
|
1513
2310
|
let runError = null;
|
|
@@ -1687,6 +2484,7 @@ export class RunManager {
|
|
|
1687
2484
|
const startTokens = stepRecord?.tokensUsed ?? 0;
|
|
1688
2485
|
let stepCost = stepRecord?.costUsd ?? 0;
|
|
1689
2486
|
let turnText = '';
|
|
2487
|
+
let sessionError;
|
|
1690
2488
|
const sink = this.makeUiSink(runId, step.id);
|
|
1691
2489
|
const onEvent = (event) => {
|
|
1692
2490
|
if (event.type === 'image') {
|
|
@@ -1703,6 +2501,13 @@ export class RunManager {
|
|
|
1703
2501
|
return;
|
|
1704
2502
|
}
|
|
1705
2503
|
emit({ ...event, stepId: step.id });
|
|
2504
|
+
if (event.type === 'error') {
|
|
2505
|
+
sessionError ??= event.message;
|
|
2506
|
+
state.session?.interrupt();
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
if (sessionError)
|
|
2510
|
+
return;
|
|
1706
2511
|
if (event.type === 'session') {
|
|
1707
2512
|
// Codex/OpenCode mint their own session id — persist it so resume works.
|
|
1708
2513
|
this.store.updateStep(runId, step.id, { sessionId: event.sessionId, backend });
|
|
@@ -1723,13 +2528,17 @@ export class RunManager {
|
|
|
1723
2528
|
const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
1724
2529
|
// `CEZ:ASK` → the user is blocked; wins over `CEZ:MONITORING`, loses to
|
|
1725
2530
|
// `CEZ:DONE` (#473).
|
|
1726
|
-
const
|
|
2531
|
+
const askResult = interactive && sessionOpen && !done ? parseAskMarkerResult(turnText) : undefined;
|
|
2532
|
+
const ask = askResult?.kind === 'valid' ? askResult.request : null;
|
|
2533
|
+
const askRejection = askResult ? askMarkerRejection(askResult) : undefined;
|
|
1727
2534
|
const monitoring = interactive &&
|
|
1728
2535
|
sessionOpen &&
|
|
1729
2536
|
!done &&
|
|
1730
2537
|
!ask &&
|
|
1731
2538
|
MONITORING_MARKER_RE.test(turnText.trimEnd());
|
|
1732
2539
|
turnText = '';
|
|
2540
|
+
if (askRejection)
|
|
2541
|
+
emit({ type: 'note', stepId: step.id, message: askRejection });
|
|
1733
2542
|
if (done) {
|
|
1734
2543
|
// Goal achieved (agent contract, #347): close the session instead
|
|
1735
2544
|
// of parking at `waiting` — the run completes and frees its slot.
|
|
@@ -1752,15 +2561,25 @@ export class RunManager {
|
|
|
1752
2561
|
if (monitoring) {
|
|
1753
2562
|
this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
|
|
1754
2563
|
this.store.updateStep(runId, step.id, { status: 'running' });
|
|
2564
|
+
this.monitoring.add(runId);
|
|
2565
|
+
this.clearIdleTimer(state);
|
|
2566
|
+
this.armMonitoringWakeTimer(runId, state);
|
|
1755
2567
|
}
|
|
1756
2568
|
else {
|
|
1757
2569
|
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
1758
2570
|
this.store.updateStep(runId, step.id, { status: 'waiting' });
|
|
2571
|
+
this.monitoring.delete(runId);
|
|
2572
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
1759
2573
|
}
|
|
1760
2574
|
this.waiting.add(runId);
|
|
1761
|
-
|
|
2575
|
+
if (!monitoring)
|
|
2576
|
+
this.armIdleTimer(runId, state);
|
|
1762
2577
|
this.releaseSlot(); // the freed slot can start a queued run right away — in any project
|
|
1763
2578
|
}
|
|
2579
|
+
// The window is proven open — see the twin in `runContinuation`.
|
|
2580
|
+
if (this.store.getRun(runId)?.autoResumeAttempts !== undefined) {
|
|
2581
|
+
this.store.updateRun(runId, { autoResumeAttempts: undefined });
|
|
2582
|
+
}
|
|
1764
2583
|
// Cez's own heartbeat — the handoff stays current even when the
|
|
1765
2584
|
// agent forgets to write (spec 007).
|
|
1766
2585
|
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
|
|
@@ -1773,7 +2592,7 @@ export class RunManager {
|
|
|
1773
2592
|
// instead of letting the backend silently substitute its default.
|
|
1774
2593
|
let backendModel;
|
|
1775
2594
|
try {
|
|
1776
|
-
const normalized = normalizeModelForBackend(stepBackend, step.model ?? input.model);
|
|
2595
|
+
const normalized = normalizeModelForBackend(stepBackend, agentModelsLocked(this.repoRoot) ? undefined : step.model ?? input.model, { configuredProvider: await configuredModelProvider(stepBackend, state.cwd) });
|
|
1777
2596
|
backendModel = normalized?.backendModel;
|
|
1778
2597
|
// Persist the identity of what ACTUALLY runs (#405, review M1). The run-start echo
|
|
1779
2598
|
// (line ~993) is best-effort from `taskBackend`/`input.model`; a per-step `runner`/`model`
|
|
@@ -1788,8 +2607,17 @@ export class RunManager {
|
|
|
1788
2607
|
return err.message;
|
|
1789
2608
|
throw err;
|
|
1790
2609
|
}
|
|
2610
|
+
// Which agent account this step spawns under, and — recorded on the step before the spawn —
|
|
2611
|
+
// which one its session belongs to. `sessionId` and `profileId` are a pair: a resume that
|
|
2612
|
+
// reads the wrong account's config dir finds no session and silently starts a fresh one.
|
|
2613
|
+
const stepProfile = await this.agentEnvForStep(runId, stepBackend, {
|
|
2614
|
+
generateFollowups: followupsEnabled() && input.generateFollowups !== false,
|
|
2615
|
+
});
|
|
2616
|
+
this.store.updateStep(runId, step.id, { profileId: stepProfile.profileId });
|
|
1791
2617
|
const runner = createRunner(stepBackend);
|
|
1792
2618
|
let session;
|
|
2619
|
+
state.currentStepId = step.id;
|
|
2620
|
+
this.beginUsageInvocation(runId, state, step.id);
|
|
1793
2621
|
try {
|
|
1794
2622
|
session = runner.startSession({
|
|
1795
2623
|
// Skill body, then the run's extra prompt (POST override or config
|
|
@@ -1804,7 +2632,7 @@ export class RunManager {
|
|
|
1804
2632
|
bashAllowlist: step.bashAllowlist,
|
|
1805
2633
|
// The handoff file lives outside the worktree — grant access.
|
|
1806
2634
|
additionalDirectories: [join(this.dataDir, 'runs')],
|
|
1807
|
-
env:
|
|
2635
|
+
env: stepProfile.env,
|
|
1808
2636
|
model: backendModel,
|
|
1809
2637
|
sessionId,
|
|
1810
2638
|
// Interactive sessions have no wall clock — the idle timer rules.
|
|
@@ -1815,6 +2643,7 @@ export class RunManager {
|
|
|
1815
2643
|
});
|
|
1816
2644
|
}
|
|
1817
2645
|
catch (err) {
|
|
2646
|
+
state.currentStepId = undefined;
|
|
1818
2647
|
return err instanceof Error ? err.message : String(err);
|
|
1819
2648
|
}
|
|
1820
2649
|
state.session = session;
|
|
@@ -1826,6 +2655,10 @@ export class RunManager {
|
|
|
1826
2655
|
registerRunProcess(runId, session.pid);
|
|
1827
2656
|
try {
|
|
1828
2657
|
const result = await session.result;
|
|
2658
|
+
if (sessionError) {
|
|
2659
|
+
sink.sessionEnded('error', sessionError);
|
|
2660
|
+
return sessionError;
|
|
2661
|
+
}
|
|
1829
2662
|
// v2 counterpart of v1's `done` (spec: the mappers leave session-close
|
|
1830
2663
|
// events to the RunManager — only it knows how the session settled).
|
|
1831
2664
|
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
@@ -1840,6 +2673,9 @@ export class RunManager {
|
|
|
1840
2673
|
finally {
|
|
1841
2674
|
this.recordUsagePeaks(runId);
|
|
1842
2675
|
this.clearIdleTimer(state);
|
|
2676
|
+
this.monitoring.delete(runId);
|
|
2677
|
+
this.waiting.delete(runId);
|
|
2678
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
1843
2679
|
state.session = undefined;
|
|
1844
2680
|
state.currentStepId = undefined;
|
|
1845
2681
|
state.interrupt = () => undefined;
|
|
@@ -1863,16 +2699,90 @@ export class RunManager {
|
|
|
1863
2699
|
/** Native backend asks arrive before turn-end. Persist and park immediately
|
|
1864
2700
|
* so the cockpit shows attention and the run releases its workspace slot. */
|
|
1865
2701
|
handleRunnerUiEvent(runId, state, sink, event) {
|
|
2702
|
+
this.recordUsageUiEvent(runId, state, event);
|
|
1866
2703
|
sink.handle(event);
|
|
1867
2704
|
if (event.type !== 'ask.requested' || state.cancelled)
|
|
1868
2705
|
return;
|
|
1869
2706
|
this.clearIdleTimer(state);
|
|
2707
|
+
this.monitoring.delete(runId);
|
|
2708
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
1870
2709
|
this.waiting.add(runId);
|
|
1871
2710
|
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
1872
2711
|
if (state.currentStepId)
|
|
1873
2712
|
this.store.updateStep(runId, state.currentStepId, { status: 'waiting' });
|
|
1874
2713
|
this.releaseSlot();
|
|
1875
2714
|
}
|
|
2715
|
+
/** Persist the invocation checkpoint before launching a runner. A throw or
|
|
2716
|
+
* process exit before `turn.started` therefore leaves a durable mismatch. */
|
|
2717
|
+
beginUsageInvocation(runId, state, stepId) {
|
|
2718
|
+
const step = this.store.getRun(runId)?.steps.find((candidate) => candidate.id === stepId);
|
|
2719
|
+
if (!step)
|
|
2720
|
+
return;
|
|
2721
|
+
const epoch = (step.usageInvocationEpoch ?? 0) + 1;
|
|
2722
|
+
this.persistUsageCheckpoint(runId, stepId, {
|
|
2723
|
+
usageInvocationEpoch: epoch,
|
|
2724
|
+
usageInvocationsStarted: (step.usageInvocationsStarted ?? 0) + 1,
|
|
2725
|
+
});
|
|
2726
|
+
state.usageInvocation = {
|
|
2727
|
+
stepId,
|
|
2728
|
+
epoch,
|
|
2729
|
+
observed: false,
|
|
2730
|
+
startedTurns: new Set(),
|
|
2731
|
+
recordedTurns: new Set(),
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
/** Fold backend-neutral completed-turn usage into the current step exactly
|
|
2735
|
+
* once. Invocation/turn counters are written before the event reaches the
|
|
2736
|
+
* NDJSON sink so crashes cannot preserve a falsely complete subtotal. */
|
|
2737
|
+
recordUsageUiEvent(runId, state, event) {
|
|
2738
|
+
const invocation = state.usageInvocation;
|
|
2739
|
+
if (!invocation)
|
|
2740
|
+
return;
|
|
2741
|
+
const step = this.store.getRun(runId)?.steps.find((candidate) => candidate.id === invocation.stepId);
|
|
2742
|
+
if (!step)
|
|
2743
|
+
return;
|
|
2744
|
+
if (event.type === 'turn.started') {
|
|
2745
|
+
if (invocation.startedTurns.has(event.turnId))
|
|
2746
|
+
return;
|
|
2747
|
+
invocation.startedTurns.add(event.turnId);
|
|
2748
|
+
const firstObservedTurn = !invocation.observed;
|
|
2749
|
+
invocation.observed = true;
|
|
2750
|
+
this.persistUsageCheckpoint(runId, invocation.stepId, {
|
|
2751
|
+
usageTurnsStarted: (step.usageTurnsStarted ?? 0) + 1,
|
|
2752
|
+
...(firstObservedTurn
|
|
2753
|
+
? { usageInvocationsObserved: (step.usageInvocationsObserved ?? 0) + 1 }
|
|
2754
|
+
: {}),
|
|
2755
|
+
});
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
if (event.type !== 'turn.completed')
|
|
2759
|
+
return;
|
|
2760
|
+
if (!invocation.startedTurns.has(event.turnId) || invocation.recordedTurns.has(event.turnId))
|
|
2761
|
+
return;
|
|
2762
|
+
const input = event.usage?.input;
|
|
2763
|
+
const output = event.usage?.output;
|
|
2764
|
+
if (typeof input !== 'number' ||
|
|
2765
|
+
!Number.isFinite(input) ||
|
|
2766
|
+
input < 0 ||
|
|
2767
|
+
typeof output !== 'number' ||
|
|
2768
|
+
!Number.isFinite(output) ||
|
|
2769
|
+
output < 0) {
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
invocation.recordedTurns.add(event.turnId);
|
|
2773
|
+
this.persistUsageCheckpoint(runId, invocation.stepId, {
|
|
2774
|
+
inputTokens: (step.inputTokens ?? 0) + input,
|
|
2775
|
+
outputTokens: (step.outputTokens ?? 0) + output,
|
|
2776
|
+
usageTurnsRecorded: (step.usageTurnsRecorded ?? 0) + 1,
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
/** Usage completeness is a crash boundary, unlike high-frequency token
|
|
2780
|
+
* snapshots: the checkpoint must reach `runs.json` before the runner starts
|
|
2781
|
+
* or the matching UI event is persisted and forwarded. */
|
|
2782
|
+
persistUsageCheckpoint(runId, stepId, patch) {
|
|
2783
|
+
this.store.updateStep(runId, stepId, patch);
|
|
2784
|
+
this.store.flush();
|
|
2785
|
+
}
|
|
1876
2786
|
/**
|
|
1877
2787
|
* Turn-end bookkeeping (#389), shared by `runAgentStep` and
|
|
1878
2788
|
* `runContinuation` — called (fire-and-forget) from every `turn-end` event:
|
|
@@ -1939,7 +2849,12 @@ export class RunManager {
|
|
|
1939
2849
|
// deliberately NEVER a title source; see maybeRefreshTitle below. The
|
|
1940
2850
|
// one exception is an explicit CEZ:TITLE declaration (applied above).
|
|
1941
2851
|
if (run.worktreePath && existsSync(run.worktreePath)) {
|
|
1942
|
-
|
|
2852
|
+
// `taskBranch` is what keeps this number *this task's* (#751): a review/QA run
|
|
2853
|
+
// repoints the worktree onto the branch under review, and without the branch to
|
|
2854
|
+
// compare HEAD against, the stat would claim that whole branch's diff.
|
|
2855
|
+
const stat = await worktreeShortstat(run.worktreePath, run.baseBranch ?? 'HEAD', {
|
|
2856
|
+
taskBranch: run.branch,
|
|
2857
|
+
});
|
|
1943
2858
|
if (stat)
|
|
1944
2859
|
this.store.updateRun(runId, { diffStat: stat });
|
|
1945
2860
|
else
|
|
@@ -2044,6 +2959,10 @@ export class RunManager {
|
|
|
2044
2959
|
status: review ? 'review' : 'done',
|
|
2045
2960
|
finishedAt: new Date().toISOString(),
|
|
2046
2961
|
currentStepId: undefined,
|
|
2962
|
+
// A run that got all the way to a settled turn is not in a limit loop, so the resume
|
|
2963
|
+
// counter starts over — otherwise a task that legitimately met the limit once a week would
|
|
2964
|
+
// creep toward the cap forever and stop resuming for no reason anyone could see.
|
|
2965
|
+
autoResumeAttempts: undefined,
|
|
2047
2966
|
});
|
|
2048
2967
|
this.store.appendEvent(runId, {
|
|
2049
2968
|
type: 'lifecycle',
|
|
@@ -2096,7 +3015,10 @@ export class RunManager {
|
|
|
2096
3015
|
throw err;
|
|
2097
3016
|
}
|
|
2098
3017
|
this.queuedImageSeq.set(runId, seq);
|
|
2099
|
-
|
|
3018
|
+
// Versioned, because that is the only surface served now. The cockpit still upgrades
|
|
3019
|
+
// the unversioned URLs sitting in OLD transcripts when it renders them
|
|
3020
|
+
// (`resolveApiUrl`), but a URL minted today must be fetchable as written.
|
|
3021
|
+
return { name, url: `/api/v1/runs/${runId}/images/${name}`, path };
|
|
2100
3022
|
}
|
|
2101
3023
|
return null;
|
|
2102
3024
|
}
|
|
@@ -2123,6 +3045,68 @@ export class RunManager {
|
|
|
2123
3045
|
state.idleTimer = undefined;
|
|
2124
3046
|
}
|
|
2125
3047
|
}
|
|
3048
|
+
reconcileMonitoringWakeTimers() {
|
|
3049
|
+
for (const runId of this.monitoring) {
|
|
3050
|
+
const state = this.active.get(runId);
|
|
3051
|
+
if (state)
|
|
3052
|
+
this.armMonitoringWakeTimer(runId, state);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
armMonitoringWakeTimer(runId, state) {
|
|
3056
|
+
const minutes = this.semaphore.monitoringWakeIntervalMinutes();
|
|
3057
|
+
if (minutes === null) {
|
|
3058
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
if ((state.monitoringWakeups ?? 0) >= MAX_AUTO_CONTINUES) {
|
|
3062
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
3063
|
+
if (!this.store.getRun(runId)?.monitoringWakeCapReached) {
|
|
3064
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: true });
|
|
3065
|
+
this.store.appendEvent(runId, {
|
|
3066
|
+
type: 'note',
|
|
3067
|
+
message: `automatic monitoring wake-up cap reached (${MAX_AUTO_CONTINUES}); session remains parked`,
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
if (state.monitoringWakeTimer && state.monitoringWakeIntervalMinutes === minutes)
|
|
3073
|
+
return;
|
|
3074
|
+
this.clearMonitoringWakeTimer(state, runId);
|
|
3075
|
+
state.monitoringWakeIntervalMinutes = minutes;
|
|
3076
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: undefined });
|
|
3077
|
+
const deadline = Date.now() + minutes * 60_000;
|
|
3078
|
+
this.store.updateRun(runId, { monitoringWakeAt: new Date(deadline).toISOString() });
|
|
3079
|
+
state.monitoringWakeTimer = setTimeout(() => {
|
|
3080
|
+
state.monitoringWakeTimer = undefined;
|
|
3081
|
+
this.store.updateRun(runId, { monitoringWakeAt: undefined });
|
|
3082
|
+
if (!this.monitoring.has(runId) || !state.session?.open || state.cancelled)
|
|
3083
|
+
return;
|
|
3084
|
+
const wakeups = state.monitoringWakeups ?? 0;
|
|
3085
|
+
if (wakeups >= MAX_AUTO_CONTINUES) {
|
|
3086
|
+
this.store.updateRun(runId, { monitoringWakeCapReached: true });
|
|
3087
|
+
this.store.appendEvent(runId, {
|
|
3088
|
+
type: 'note',
|
|
3089
|
+
message: `automatic monitoring wake-up cap reached (${MAX_AUTO_CONTINUES}); session remains parked`,
|
|
3090
|
+
});
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
state.monitoringWakeups = wakeups + 1;
|
|
3094
|
+
this.store.appendEvent(runId, {
|
|
3095
|
+
type: 'note',
|
|
3096
|
+
message: `automatic monitoring wake-up (${state.monitoringWakeups}/${MAX_AUTO_CONTINUES})`,
|
|
3097
|
+
});
|
|
3098
|
+
this.deliverMessage(runId, [{ type: 'text', text: MONITORING_WAKE_NUDGE }], false);
|
|
3099
|
+
}, Math.max(0, deadline - Date.now()));
|
|
3100
|
+
state.monitoringWakeTimer.unref?.();
|
|
3101
|
+
}
|
|
3102
|
+
clearMonitoringWakeTimer(state, runId) {
|
|
3103
|
+
if (state.monitoringWakeTimer)
|
|
3104
|
+
clearTimeout(state.monitoringWakeTimer);
|
|
3105
|
+
state.monitoringWakeTimer = undefined;
|
|
3106
|
+
state.monitoringWakeIntervalMinutes = undefined;
|
|
3107
|
+
if (runId)
|
|
3108
|
+
this.store.updateRun(runId, { monitoringWakeAt: undefined });
|
|
3109
|
+
}
|
|
2126
3110
|
/** Autosave-commit the worktree every 90 s while the run lives (spec 006).
|
|
2127
3111
|
* Opt-in via CEZ_AUTOSAVE=1 (#471) — see periodicAutosaveEnabled. */
|
|
2128
3112
|
armAutosave(state) {
|
|
@@ -2244,4 +3228,35 @@ export function skillSystemPrompt(skill) {
|
|
|
2244
3228
|
lines.push('', 'Skill instructions:', skill.body.trim());
|
|
2245
3229
|
return lines.join('\n');
|
|
2246
3230
|
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Expand a registry-backed slash skill in a live chat message before it reaches
|
|
3233
|
+
* a backend. Claude otherwise intercepts an unknown leading slash command, and
|
|
3234
|
+
* Codex/OpenCode have no native slash-skill lookup at all (#676).
|
|
3235
|
+
*
|
|
3236
|
+
* Only the first text block is eligible, only at character zero, and unknown
|
|
3237
|
+
* commands pass through byte-for-byte. The caller persists the original user
|
|
3238
|
+
* content before applying this delivery-only rewrite.
|
|
3239
|
+
*/
|
|
3240
|
+
export function expandRegistrySlashSkill(content, skills) {
|
|
3241
|
+
const textIndex = content.findIndex((block) => block.type === 'text');
|
|
3242
|
+
if (textIndex < 0)
|
|
3243
|
+
return content;
|
|
3244
|
+
const block = content[textIndex];
|
|
3245
|
+
if (!block || block.type !== 'text')
|
|
3246
|
+
return content;
|
|
3247
|
+
const match = /^\/([A-Za-z0-9][A-Za-z0-9._-]*)(?=\s|$)/.exec(block.text);
|
|
3248
|
+
if (!match)
|
|
3249
|
+
return content;
|
|
3250
|
+
const skill = skills.find((candidate) => candidate.name === match[1]);
|
|
3251
|
+
if (!skill)
|
|
3252
|
+
return content;
|
|
3253
|
+
const request = block.text.slice(match[0].length).trim();
|
|
3254
|
+
const replacement = {
|
|
3255
|
+
type: 'text',
|
|
3256
|
+
text: request ? `${skillSystemPrompt(skill)}\n\nUser request:\n${request}` : skillSystemPrompt(skill),
|
|
3257
|
+
};
|
|
3258
|
+
const expanded = [...content];
|
|
3259
|
+
expanded[textIndex] = replacement;
|
|
3260
|
+
return expanded;
|
|
3261
|
+
}
|
|
2247
3262
|
//# sourceMappingURL=run.js.map
|