toolroll 0.5.0
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 +1113 -0
- package/THIRD_PARTY_NOTICES.md +449 -0
- package/dist/accent-colors.d.ts +48 -0
- package/dist/accent-colors.js +122 -0
- package/dist/action-ledger.d.ts +26 -0
- package/dist/action-ledger.js +75 -0
- package/dist/agent-fence.d.ts +42 -0
- package/dist/agent-fence.js +183 -0
- package/dist/agentconfig.d.ts +216 -0
- package/dist/agentconfig.js +454 -0
- package/dist/api-tokens.d.ts +16 -0
- package/dist/api-tokens.js +27 -0
- package/dist/approval-policy.d.ts +69 -0
- package/dist/approval-policy.js +137 -0
- package/dist/approval-rules-ui.d.ts +25 -0
- package/dist/approval-rules-ui.js +39 -0
- package/dist/assignment-adapters.d.ts +180 -0
- package/dist/assignment-adapters.js +239 -0
- package/dist/assignment-brief.d.ts +71 -0
- package/dist/assignment-brief.js +129 -0
- package/dist/assignment-delivery.d.ts +56 -0
- package/dist/assignment-delivery.js +160 -0
- package/dist/assignment-presentation.d.ts +26 -0
- package/dist/assignment-presentation.js +80 -0
- package/dist/assignment-status.d.ts +62 -0
- package/dist/assignment-status.js +152 -0
- package/dist/assignment-ui.d.ts +71 -0
- package/dist/assignment-ui.js +103 -0
- package/dist/assignment.d.ts +222 -0
- package/dist/assignment.js +399 -0
- package/dist/attest.d.ts +56 -0
- package/dist/attest.js +153 -0
- package/dist/backend.d.ts +97 -0
- package/dist/backend.js +166 -0
- package/dist/backup-ui.d.ts +22 -0
- package/dist/backup-ui.js +59 -0
- package/dist/backup.d.ts +84 -0
- package/dist/backup.js +421 -0
- package/dist/beads.d.ts +34 -0
- package/dist/beads.js +135 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +22 -0
- package/dist/board.d.ts +150 -0
- package/dist/board.js +210 -0
- package/dist/boot-identity.d.ts +63 -0
- package/dist/boot-identity.js +99 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +2295 -0
- package/dist/browser/workspace.css +4 -0
- package/dist/browser/workspace.js +225 -0
- package/dist/browser-crew.d.ts +11 -0
- package/dist/browser-crew.js +50 -0
- package/dist/browser-shell.d.ts +8 -0
- package/dist/browser-shell.js +60 -0
- package/dist/browser-workspace.d.ts +780 -0
- package/dist/browser-workspace.js +47 -0
- package/dist/budget-alerts.d.ts +19 -0
- package/dist/budget-alerts.js +46 -0
- package/dist/builder.d.ts +370 -0
- package/dist/builder.js +3262 -0
- package/dist/capscan.d.ts +35 -0
- package/dist/capscan.js +113 -0
- package/dist/chat-acceptance.d.ts +71 -0
- package/dist/chat-acceptance.js +120 -0
- package/dist/chat-actions.d.ts +265 -0
- package/dist/chat-actions.js +1273 -0
- package/dist/chat-channel.d.ts +109 -0
- package/dist/chat-channel.js +652 -0
- package/dist/chat-continuity.d.ts +32 -0
- package/dist/chat-continuity.js +317 -0
- package/dist/chat-controls.d.ts +113 -0
- package/dist/chat-controls.js +53 -0
- package/dist/chat-delivery-state.d.ts +159 -0
- package/dist/chat-delivery-state.js +309 -0
- package/dist/chat-delivery.d.ts +38 -0
- package/dist/chat-delivery.js +693 -0
- package/dist/chat-display.d.ts +1 -0
- package/dist/chat-display.js +18 -0
- package/dist/chat-evidence.d.ts +123 -0
- package/dist/chat-evidence.js +207 -0
- package/dist/chat-flow.d.ts +44 -0
- package/dist/chat-flow.js +215 -0
- package/dist/chat-inbox.d.ts +57 -0
- package/dist/chat-inbox.js +123 -0
- package/dist/chat-polish.d.ts +16 -0
- package/dist/chat-polish.js +135 -0
- package/dist/chat-review.d.ts +46 -0
- package/dist/chat-review.js +98 -0
- package/dist/chat-rooms.d.ts +123 -0
- package/dist/chat-rooms.js +216 -0
- package/dist/chat-task-actions.d.ts +44 -0
- package/dist/chat-task-actions.js +79 -0
- package/dist/check-progress.d.ts +51 -0
- package/dist/check-progress.js +290 -0
- package/dist/child-database.d.ts +12 -0
- package/dist/child-database.js +30 -0
- package/dist/claim.d.ts +688 -0
- package/dist/claim.js +1740 -0
- package/dist/cli.d.ts +137 -0
- package/dist/cli.js +1461 -0
- package/dist/codex-limits.d.ts +15 -0
- package/dist/codex-limits.js +145 -0
- package/dist/coding-context.d.ts +55 -0
- package/dist/coding-context.js +92 -0
- package/dist/coding-handoff.d.ts +57 -0
- package/dist/coding-handoff.js +277 -0
- package/dist/coding-provider.d.ts +72 -0
- package/dist/coding-provider.js +424 -0
- package/dist/coding-shipping-ui.d.ts +4 -0
- package/dist/coding-shipping-ui.js +12 -0
- package/dist/coding-types.d.ts +62 -0
- package/dist/coding-types.js +1 -0
- package/dist/coding-ui.d.ts +17 -0
- package/dist/coding-ui.js +364 -0
- package/dist/coding-update.d.ts +15 -0
- package/dist/coding-update.js +149 -0
- package/dist/coding-workspace.d.ts +124 -0
- package/dist/coding-workspace.js +768 -0
- package/dist/container-state.d.ts +5 -0
- package/dist/container-state.js +62 -0
- package/dist/containment.d.ts +197 -0
- package/dist/containment.js +559 -0
- package/dist/contest.d.ts +318 -0
- package/dist/contest.js +753 -0
- package/dist/control-setup.d.ts +35 -0
- package/dist/control-setup.js +40 -0
- package/dist/control-ui.d.ts +29 -0
- package/dist/control-ui.js +53 -0
- package/dist/controller-service.d.ts +1 -0
- package/dist/controller-service.js +19 -0
- package/dist/controller-supervisor.d.ts +30 -0
- package/dist/controller-supervisor.js +104 -0
- package/dist/converse.d.ts +304 -0
- package/dist/converse.js +849 -0
- package/dist/coordinator-proposals.d.ts +29 -0
- package/dist/coordinator-proposals.js +137 -0
- package/dist/coordinator.d.ts +186 -0
- package/dist/coordinator.js +447 -0
- package/dist/credentials-ui.d.ts +25 -0
- package/dist/credentials-ui.js +48 -0
- package/dist/daemon.d.ts +209 -0
- package/dist/daemon.js +604 -0
- package/dist/decision.d.ts +120 -0
- package/dist/decision.js +388 -0
- package/dist/demo.d.ts +55 -0
- package/dist/demo.js +992 -0
- package/dist/desktop-access.d.ts +28 -0
- package/dist/desktop-access.js +88 -0
- package/dist/desktop-bundle.d.ts +16 -0
- package/dist/desktop-bundle.js +74 -0
- package/dist/desktop-host.d.ts +54 -0
- package/dist/desktop-host.js +508 -0
- package/dist/desktop-update-gate.d.ts +12 -0
- package/dist/desktop-update-gate.js +91 -0
- package/dist/desktop-update-recovery.d.ts +14 -0
- package/dist/desktop-update-recovery.js +243 -0
- package/dist/desktop-update.d.ts +110 -0
- package/dist/desktop-update.js +740 -0
- package/dist/discord-api.d.ts +20 -0
- package/dist/discord-api.js +138 -0
- package/dist/discord-chat.d.ts +16 -0
- package/dist/discord-chat.js +381 -0
- package/dist/discord-settings.d.ts +7 -0
- package/dist/discord-settings.js +75 -0
- package/dist/discord.d.ts +10 -0
- package/dist/discord.js +169 -0
- package/dist/discover.d.ts +75 -0
- package/dist/discover.js +150 -0
- package/dist/dispatch.d.ts +100 -0
- package/dist/dispatch.js +311 -0
- package/dist/dispose.d.ts +161 -0
- package/dist/dispose.js +803 -0
- package/dist/email-settings.d.ts +34 -0
- package/dist/email-settings.js +50 -0
- package/dist/envelope.d.ts +47 -0
- package/dist/envelope.js +105 -0
- package/dist/evidence-pack.d.ts +185 -0
- package/dist/evidence-pack.js +258 -0
- package/dist/evidence.d.ts +345 -0
- package/dist/evidence.js +782 -0
- package/dist/exec.d.ts +285 -0
- package/dist/exec.js +1841 -0
- package/dist/exhaustion.d.ts +85 -0
- package/dist/exhaustion.js +141 -0
- package/dist/export-ui.d.ts +4 -0
- package/dist/export-ui.js +17 -0
- package/dist/export.d.ts +48 -0
- package/dist/export.js +305 -0
- package/dist/flow-actions.d.ts +77 -0
- package/dist/flow-actions.js +203 -0
- package/dist/flow-code.d.ts +50 -0
- package/dist/flow-code.js +159 -0
- package/dist/flow-draft.d.ts +38 -0
- package/dist/flow-draft.js +80 -0
- package/dist/flow-engine.d.ts +66 -0
- package/dist/flow-engine.js +416 -0
- package/dist/flow-insights.d.ts +95 -0
- package/dist/flow-insights.js +149 -0
- package/dist/flow-live.d.ts +24 -0
- package/dist/flow-live.js +150 -0
- package/dist/flow-people.d.ts +24 -0
- package/dist/flow-people.js +91 -0
- package/dist/flow-replies.d.ts +48 -0
- package/dist/flow-replies.js +113 -0
- package/dist/flow-scripts.d.ts +38 -0
- package/dist/flow-scripts.js +73 -0
- package/dist/flow-secrets.d.ts +13 -0
- package/dist/flow-secrets.js +45 -0
- package/dist/flow-sort.d.ts +82 -0
- package/dist/flow-sort.js +153 -0
- package/dist/flow-steps.d.ts +40 -0
- package/dist/flow-steps.js +408 -0
- package/dist/flow-triggers.d.ts +244 -0
- package/dist/flow-triggers.js +959 -0
- package/dist/flows-ui.d.ts +20 -0
- package/dist/flows-ui.js +175 -0
- package/dist/flows.d.ts +229 -0
- package/dist/flows.js +968 -0
- package/dist/fonts.d.ts +19 -0
- package/dist/fonts.js +19 -0
- package/dist/gaps.d.ts +35 -0
- package/dist/gaps.js +101 -0
- package/dist/gate-failure.d.ts +40 -0
- package/dist/gate-failure.js +66 -0
- package/dist/git.d.ts +54 -0
- package/dist/git.js +94 -0
- package/dist/google-mail.d.ts +58 -0
- package/dist/google-mail.js +151 -0
- package/dist/grant.d.ts +136 -0
- package/dist/grant.js +238 -0
- package/dist/graph.d.ts +164 -0
- package/dist/graph.js +383 -0
- package/dist/guides.d.ts +22 -0
- package/dist/guides.js +363 -0
- package/dist/held.d.ts +150 -0
- package/dist/held.js +799 -0
- package/dist/invoke.d.ts +112 -0
- package/dist/invoke.js +780 -0
- package/dist/issues.d.ts +41 -0
- package/dist/issues.js +131 -0
- package/dist/job-object-helper.ps1 +252 -0
- package/dist/jsonl-discriminants.d.ts +11 -0
- package/dist/jsonl-discriminants.js +93 -0
- package/dist/keys.d.ts +104 -0
- package/dist/keys.js +229 -0
- package/dist/kits-ui.d.ts +17 -0
- package/dist/kits-ui.js +46 -0
- package/dist/kits.d.ts +86 -0
- package/dist/kits.js +215 -0
- package/dist/knowledge-cli.d.ts +101 -0
- package/dist/knowledge-cli.js +92 -0
- package/dist/knowledge-ui.d.ts +24 -0
- package/dist/knowledge-ui.js +40 -0
- package/dist/lead-context.d.ts +8 -0
- package/dist/lead-context.js +56 -0
- package/dist/lead-follow.d.ts +22 -0
- package/dist/lead-follow.js +167 -0
- package/dist/lead-status.d.ts +75 -0
- package/dist/lead-status.js +285 -0
- package/dist/ledger-chain.d.ts +46 -0
- package/dist/ledger-chain.js +187 -0
- package/dist/ledger-csv.d.ts +4 -0
- package/dist/ledger-csv.js +11 -0
- package/dist/ledger-view.d.ts +24 -0
- package/dist/ledger-view.js +58 -0
- package/dist/limits-ui.d.ts +26 -0
- package/dist/limits-ui.js +67 -0
- package/dist/link.d.ts +72 -0
- package/dist/link.js +217 -0
- package/dist/live.d.ts +96 -0
- package/dist/live.js +366 -0
- package/dist/liveness.d.ts +30 -0
- package/dist/liveness.js +42 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +25 -0
- package/dist/mailbox.d.ts +57 -0
- package/dist/mailbox.js +150 -0
- package/dist/maintenance.d.ts +11 -0
- package/dist/maintenance.js +35 -0
- package/dist/mate-cli.d.ts +52 -0
- package/dist/mate-cli.js +345 -0
- package/dist/mate-contract.d.ts +10 -0
- package/dist/mate-contract.js +30 -0
- package/dist/mate-doors.d.ts +69 -0
- package/dist/mate-doors.js +548 -0
- package/dist/mate-progress.d.ts +29 -0
- package/dist/mate-progress.js +121 -0
- package/dist/mate-tools.d.ts +94 -0
- package/dist/mate-tools.js +1878 -0
- package/dist/mate.d.ts +92 -0
- package/dist/mate.js +435 -0
- package/dist/mcp-connect.d.ts +103 -0
- package/dist/mcp-connect.js +252 -0
- package/dist/mcp.d.ts +27 -0
- package/dist/mcp.js +651 -0
- package/dist/memory-cli.d.ts +466 -0
- package/dist/memory-cli.js +111 -0
- package/dist/memory-pass.d.ts +160 -0
- package/dist/memory-pass.js +409 -0
- package/dist/metrics.d.ts +3 -0
- package/dist/metrics.js +56 -0
- package/dist/mobile-viewport.d.ts +3 -0
- package/dist/mobile-viewport.js +37 -0
- package/dist/model-catalog.d.ts +118 -0
- package/dist/model-catalog.js +376 -0
- package/dist/models-cli.d.ts +102 -0
- package/dist/models-cli.js +66 -0
- package/dist/models-ui.d.ts +34 -0
- package/dist/models-ui.js +68 -0
- package/dist/modes.d.ts +80 -0
- package/dist/modes.js +182 -0
- package/dist/monitoring-settings.d.ts +44 -0
- package/dist/monitoring-settings.js +173 -0
- package/dist/monitoring-ui.d.ts +20 -0
- package/dist/monitoring-ui.js +51 -0
- package/dist/monitoring.d.ts +34 -0
- package/dist/monitoring.js +221 -0
- package/dist/names.d.ts +67 -0
- package/dist/names.js +102 -0
- package/dist/observations.d.ts +40 -0
- package/dist/observations.js +211 -0
- package/dist/oidc.d.ts +71 -0
- package/dist/oidc.js +182 -0
- package/dist/onboard.d.ts +108 -0
- package/dist/onboard.js +325 -0
- package/dist/openrouter-models.d.ts +30 -0
- package/dist/openrouter-models.js +120 -0
- package/dist/operate.d.ts +118 -0
- package/dist/operate.js +11321 -0
- package/dist/peek-cli.d.ts +97 -0
- package/dist/peek-cli.js +215 -0
- package/dist/peek.d.ts +134 -0
- package/dist/peek.js +578 -0
- package/dist/phase-routing.d.ts +307 -0
- package/dist/phase-routing.js +658 -0
- package/dist/plan-auto.d.ts +14 -0
- package/dist/plan-auto.js +92 -0
- package/dist/plan.d.ts +186 -0
- package/dist/plan.js +401 -0
- package/dist/planner-source.d.ts +251 -0
- package/dist/planner-source.js +460 -0
- package/dist/planner.d.ts +75 -0
- package/dist/planner.js +992 -0
- package/dist/policy-ui.d.ts +20 -0
- package/dist/policy-ui.js +41 -0
- package/dist/policy.d.ts +111 -0
- package/dist/policy.js +231 -0
- package/dist/prepared-evidence.d.ts +16 -0
- package/dist/prepared-evidence.js +96 -0
- package/dist/pricing.d.ts +45 -0
- package/dist/pricing.js +77 -0
- package/dist/principal.d.ts +42 -0
- package/dist/principal.js +82 -0
- package/dist/probe.d.ts +46 -0
- package/dist/probe.js +79 -0
- package/dist/process-custody.d.ts +17 -0
- package/dist/process-custody.js +91 -0
- package/dist/process-liveness.d.ts +10 -0
- package/dist/process-liveness.js +81 -0
- package/dist/process-recovery-anchor.d.ts +75 -0
- package/dist/process-recovery-anchor.js +188 -0
- package/dist/process-recovery-coalition.d.ts +25 -0
- package/dist/process-recovery-coalition.js +105 -0
- package/dist/process-recovery-eligibility.d.ts +178 -0
- package/dist/process-recovery-eligibility.js +170 -0
- package/dist/process-recovery-native.d.ts +172 -0
- package/dist/process-recovery-native.js +592 -0
- package/dist/process-recovery-provenance.d.ts +163 -0
- package/dist/process-recovery-provenance.js +292 -0
- package/dist/process-recovery-services.d.ts +56 -0
- package/dist/process-recovery-services.js +191 -0
- package/dist/process-recovery-settlement.d.ts +32 -0
- package/dist/process-recovery-settlement.js +132 -0
- package/dist/process-recovery.d.ts +41 -0
- package/dist/process-recovery.js +91 -0
- package/dist/process-tree.d.ts +41 -0
- package/dist/process-tree.js +264 -0
- package/dist/project-access.d.ts +14 -0
- package/dist/project-access.js +34 -0
- package/dist/project-cli.d.ts +18 -0
- package/dist/project-cli.js +104 -0
- package/dist/project-delete-ui.d.ts +22 -0
- package/dist/project-delete-ui.js +48 -0
- package/dist/project-delete.d.ts +55 -0
- package/dist/project-delete.js +413 -0
- package/dist/project-knowledge.d.ts +111 -0
- package/dist/project-knowledge.js +241 -0
- package/dist/project-learning.d.ts +100 -0
- package/dist/project-learning.js +438 -0
- package/dist/project-memory.d.ts +81 -0
- package/dist/project-memory.js +264 -0
- package/dist/project-skills.d.ts +152 -0
- package/dist/project-skills.js +660 -0
- package/dist/project-tools.d.ts +219 -0
- package/dist/project-tools.js +796 -0
- package/dist/project.d.ts +61 -0
- package/dist/project.js +125 -0
- package/dist/prompt.d.ts +22 -0
- package/dist/prompt.js +72 -0
- package/dist/proof.d.ts +513 -0
- package/dist/proof.js +1140 -0
- package/dist/proposal.d.ts +82 -0
- package/dist/proposal.js +210 -0
- package/dist/provider-connection.d.ts +22 -0
- package/dist/provider-connection.js +98 -0
- package/dist/provider-limits.d.ts +35 -0
- package/dist/provider-limits.js +95 -0
- package/dist/provider.d.ts +279 -0
- package/dist/provider.js +935 -0
- package/dist/publish.d.ts +107 -0
- package/dist/publish.js +650 -0
- package/dist/pulls.d.ts +118 -0
- package/dist/pulls.js +240 -0
- package/dist/push.d.ts +95 -0
- package/dist/push.js +353 -0
- package/dist/quality.d.ts +8 -0
- package/dist/quality.js +6 -0
- package/dist/recipe-ui.d.ts +18 -0
- package/dist/recipe-ui.js +152 -0
- package/dist/recipes.d.ts +81 -0
- package/dist/recipes.js +332 -0
- package/dist/remote.d.ts +67 -0
- package/dist/remote.js +122 -0
- package/dist/render.d.ts +64 -0
- package/dist/render.js +587 -0
- package/dist/report-summary.d.ts +13 -0
- package/dist/report-summary.js +15 -0
- package/dist/repos.d.ts +65 -0
- package/dist/repos.js +277 -0
- package/dist/repository-context-ui.d.ts +2 -0
- package/dist/repository-context-ui.js +10 -0
- package/dist/repository-context.d.ts +67 -0
- package/dist/repository-context.js +376 -0
- package/dist/restart-certification.d.ts +134 -0
- package/dist/restart-certification.js +226 -0
- package/dist/result-actions.d.ts +28 -0
- package/dist/result-actions.js +270 -0
- package/dist/result-completion.d.ts +18 -0
- package/dist/result-completion.js +66 -0
- package/dist/result-review.d.ts +213 -0
- package/dist/result-review.js +430 -0
- package/dist/retention-ui.d.ts +12 -0
- package/dist/retention-ui.js +39 -0
- package/dist/retention.d.ts +72 -0
- package/dist/retention.js +288 -0
- package/dist/review-context.d.ts +243 -0
- package/dist/review-context.js +1043 -0
- package/dist/review-evidence.d.ts +33 -0
- package/dist/review-evidence.js +56 -0
- package/dist/reviewer.d.ts +97 -0
- package/dist/reviewer.js +202 -0
- package/dist/routine.d.ts +228 -0
- package/dist/routine.js +764 -0
- package/dist/runner.d.ts +266 -0
- package/dist/runner.js +399 -0
- package/dist/scan.d.ts +32 -0
- package/dist/scan.js +92 -0
- package/dist/scope.d.ts +695 -0
- package/dist/scope.js +1176 -0
- package/dist/scout-report.d.ts +42 -0
- package/dist/scout-report.js +108 -0
- package/dist/scout.d.ts +69 -0
- package/dist/scout.js +351 -0
- package/dist/serve.d.ts +313 -0
- package/dist/serve.js +21776 -0
- package/dist/session-brief.d.ts +8 -0
- package/dist/session-brief.js +24 -0
- package/dist/session-cli.d.ts +13 -0
- package/dist/session-cli.js +284 -0
- package/dist/session-contract.d.ts +183 -0
- package/dist/session-contract.js +122 -0
- package/dist/session-http.d.ts +19 -0
- package/dist/session-http.js +154 -0
- package/dist/session-server.d.ts +12 -0
- package/dist/session-server.js +37 -0
- package/dist/session-service.d.ts +22 -0
- package/dist/session-service.js +157 -0
- package/dist/setup-guide.d.ts +39 -0
- package/dist/setup-guide.js +93 -0
- package/dist/sign-in-guard.d.ts +45 -0
- package/dist/sign-in-guard.js +74 -0
- package/dist/skills-ui.d.ts +11 -0
- package/dist/skills-ui.js +58 -0
- package/dist/skills.d.ts +97 -0
- package/dist/skills.js +276 -0
- package/dist/slack-api.d.ts +55 -0
- package/dist/slack-api.js +229 -0
- package/dist/slack-chat.d.ts +28 -0
- package/dist/slack-chat.js +413 -0
- package/dist/slack-settings.d.ts +7 -0
- package/dist/slack-settings.js +75 -0
- package/dist/slack-state.d.ts +13 -0
- package/dist/slack-state.js +9 -0
- package/dist/slack.d.ts +13 -0
- package/dist/slack.js +167 -0
- package/dist/spend-ui.d.ts +28 -0
- package/dist/spend-ui.js +95 -0
- package/dist/spend.d.ts +142 -0
- package/dist/spend.js +313 -0
- package/dist/sqlite-runtime.d.ts +3 -0
- package/dist/sqlite-runtime.js +6 -0
- package/dist/sso-settings.d.ts +33 -0
- package/dist/sso-settings.js +103 -0
- package/dist/sso-ui.d.ts +22 -0
- package/dist/sso-ui.js +39 -0
- package/dist/storage.d.ts +15 -0
- package/dist/storage.js +84 -0
- package/dist/store.d.ts +7754 -0
- package/dist/store.js +23279 -0
- package/dist/structured-output.d.ts +58 -0
- package/dist/structured-output.js +103 -0
- package/dist/style-asset.d.ts +7 -0
- package/dist/style-asset.js +41 -0
- package/dist/subscription-chat.d.ts +46 -0
- package/dist/subscription-chat.js +275 -0
- package/dist/summary.d.ts +33 -0
- package/dist/summary.js +71 -0
- package/dist/supervisor.mjs +297 -0
- package/dist/surface.d.ts +63 -0
- package/dist/surface.js +352 -0
- package/dist/sync.d.ts +94 -0
- package/dist/sync.js +279 -0
- package/dist/task-composer.d.ts +13 -0
- package/dist/task-composer.js +52 -0
- package/dist/task-control.d.ts +165 -0
- package/dist/task-control.js +214 -0
- package/dist/task-outcome-cli.d.ts +10 -0
- package/dist/task-outcome-cli.js +92 -0
- package/dist/task-text.d.ts +30 -0
- package/dist/task-text.js +41 -0
- package/dist/team-cli.d.ts +15 -0
- package/dist/team-cli.js +618 -0
- package/dist/team-contract.d.ts +92 -0
- package/dist/team-contract.js +1 -0
- package/dist/team-http.d.ts +17 -0
- package/dist/team-http.js +144 -0
- package/dist/team-leads.d.ts +81 -0
- package/dist/team-leads.js +565 -0
- package/dist/team-runtime.d.ts +27 -0
- package/dist/team-runtime.js +264 -0
- package/dist/team-ui.d.ts +3 -0
- package/dist/team-ui.js +9 -0
- package/dist/team-updates.d.ts +8 -0
- package/dist/team-updates.js +57 -0
- package/dist/teammate-admin.d.ts +53 -0
- package/dist/teammate-admin.js +111 -0
- package/dist/teammate-desk.d.ts +60 -0
- package/dist/teammate-desk.js +154 -0
- package/dist/teammate-memory.d.ts +64 -0
- package/dist/teammate-memory.js +196 -0
- package/dist/teammate-question.d.ts +59 -0
- package/dist/teammate-question.js +175 -0
- package/dist/teammate-tools.d.ts +108 -0
- package/dist/teammate-tools.js +271 -0
- package/dist/teammate-week.d.ts +68 -0
- package/dist/teammate-week.js +132 -0
- package/dist/teammate-work.d.ts +76 -0
- package/dist/teammate-work.js +280 -0
- package/dist/teammates-ui.d.ts +20 -0
- package/dist/teammates-ui.js +166 -0
- package/dist/teammates.d.ts +180 -0
- package/dist/teammates.js +253 -0
- package/dist/teams-api.d.ts +33 -0
- package/dist/teams-api.js +204 -0
- package/dist/teams-chat.d.ts +26 -0
- package/dist/teams-chat.js +215 -0
- package/dist/teams-settings.d.ts +12 -0
- package/dist/teams-settings.js +57 -0
- package/dist/teams.d.ts +21 -0
- package/dist/teams.js +124 -0
- package/dist/telegram-flow.d.ts +49 -0
- package/dist/telegram-flow.js +110 -0
- package/dist/telegram-mate.d.ts +137 -0
- package/dist/telegram-mate.js +707 -0
- package/dist/telegram-progress.d.ts +14 -0
- package/dist/telegram-progress.js +129 -0
- package/dist/telegram-settings.d.ts +9 -0
- package/dist/telegram-settings.js +36 -0
- package/dist/telegram-status.d.ts +75 -0
- package/dist/telegram-status.js +265 -0
- package/dist/telegram-team.d.ts +85 -0
- package/dist/telegram-team.js +359 -0
- package/dist/telegram.d.ts +230 -0
- package/dist/telegram.js +1931 -0
- package/dist/templates.d.ts +65 -0
- package/dist/templates.js +118 -0
- package/dist/tool-launcher.d.ts +1 -0
- package/dist/tool-launcher.js +101 -0
- package/dist/tools-ui.d.ts +30 -0
- package/dist/tools-ui.js +40 -0
- package/dist/transitions-recipes.d.ts +1 -0
- package/dist/transitions-recipes.js +203 -0
- package/dist/tree-proof.d.ts +34 -0
- package/dist/tree-proof.js +58 -0
- package/dist/verification-evidence.d.ts +44 -0
- package/dist/verification-evidence.js +238 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +10 -0
- package/dist/webhooks.d.ts +91 -0
- package/dist/webhooks.js +283 -0
- package/dist/work-index.d.ts +68 -0
- package/dist/work-index.js +384 -0
- package/dist/work-summary.d.ts +57 -0
- package/dist/work-summary.js +116 -0
- package/dist/workspace-motion.d.ts +4 -0
- package/dist/workspace-motion.js +133 -0
- package/dist/workspace-revision.d.ts +19 -0
- package/dist/workspace-revision.js +92 -0
- package/dist/workspace-ui.d.ts +204 -0
- package/dist/workspace-ui.js +411 -0
- package/dist/worktree-notices.d.ts +13 -0
- package/dist/worktree-notices.js +17 -0
- package/dist/worktree.d.ts +249 -0
- package/dist/worktree.js +720 -0
- package/package.json +122 -0
- package/scripts/canary-assertions.mjs +42 -0
- package/scripts/crash-canary.mjs +253 -0
- package/scripts/fixtures/crash-process.mjs +74 -0
- package/scripts/fixtures/pilot-scenarios.mjs +85 -0
- package/scripts/fixtures/restart-service.mjs +27 -0
- package/scripts/launchd-certification.mjs +143 -0
- package/scripts/pilot.mjs +121 -0
- package/scripts/proof-preflight.mjs +140 -0
- package/scripts/provider-canary.mjs +379 -0
- package/scripts/recovery-canary.mjs +135 -0
- package/scripts/restart-certification.mjs +98 -0
package/dist/claim.js
ADDED
|
@@ -0,0 +1,1740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claim, and the fence around it.
|
|
3
|
+
*
|
|
4
|
+
* This is the record §4 calls the one that matters, and the reason is a
|
|
5
|
+
* specific 3am failure. A runner takes a task, starts work, and then stops
|
|
6
|
+
* being reachable — the machine sleeps, the process is OOM-killed, the network
|
|
7
|
+
* partitions. The scheduler cannot tell "dead" from "slow", so it waits for the
|
|
8
|
+
* lease to expire and gives the task to someone else. Then the first runner
|
|
9
|
+
* wakes up, finishes the work it was doing, and reports success for a task that
|
|
10
|
+
* another runner is now halfway through.
|
|
11
|
+
*
|
|
12
|
+
* Both runners are behaving correctly. Without a fence, the control plane
|
|
13
|
+
* believes the last one to speak.
|
|
14
|
+
*
|
|
15
|
+
* So a lease carries two things. `lease_id` is immutable and identifies one
|
|
16
|
+
* grant of one task to one runner. `lease_generation` counts how many times
|
|
17
|
+
* that task has been granted at all, and it only ever goes up. Acquiring is a
|
|
18
|
+
* compare-and-swap on the generation; every later call carries the lease it
|
|
19
|
+
* thinks it holds, and anything whose generation has been superseded is
|
|
20
|
+
* refused. The late completion above is refused not because we detected the
|
|
21
|
+
* crash — we never did — but because the world moved on without it.
|
|
22
|
+
*
|
|
23
|
+
* Losing a race here is ordinary, not an error. Several runners asking for the
|
|
24
|
+
* same task at the same moment is the system working; exactly one wins.
|
|
25
|
+
*
|
|
26
|
+
* Time is a parameter everywhere. Expiry decided by an implicit clock is
|
|
27
|
+
* untestable, and a lease is exactly the kind of thing that has to be provable
|
|
28
|
+
* rather than probable.
|
|
29
|
+
*/
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
31
|
+
import { attendedLivenessState } from "./liveness.js";
|
|
32
|
+
import { authenticate } from "./runner.js";
|
|
33
|
+
import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
|
|
34
|
+
/** attendedLivenessState over the store's ISO columns. */
|
|
35
|
+
function attendedWatchState(lastBeatAt, now, absoluteExpiry) {
|
|
36
|
+
return attendedLivenessState(lastBeatAt === null ? null : Date.parse(lastBeatAt), now.getTime(), Date.parse(absoluteExpiry));
|
|
37
|
+
}
|
|
38
|
+
import { BUILT_IN, } from "./store.js";
|
|
39
|
+
import { digestOf } from "./scope.js";
|
|
40
|
+
import { parseExecutionPlanDocument } from "./plan.js";
|
|
41
|
+
import { sealUnchangedPlanUnderMode } from "./plan-auto.js";
|
|
42
|
+
import { routeFromJson } from "./phase-routing.js";
|
|
43
|
+
import { contractChangesOf, decodePlannerSource, encodePlannerSource, canonicalContractJson, PLANNER_SOURCE_LIMITS, describeContractChanges, encodePlanContractRecord, plannerContractOf, plannerSourceDigest, } from "./planner-source.js";
|
|
44
|
+
import { storeEvidence, readVerifiedArtifact, EVIDENCE_CAPS } from "./evidence.js";
|
|
45
|
+
/** How stale a mirror's last complete sync may be before admission refuses. */
|
|
46
|
+
export const SYNC_MAX_AGE_MS = 15 * 60_000;
|
|
47
|
+
/**
|
|
48
|
+
* Long enough that an ordinary build does not lose its lease mid-thought,
|
|
49
|
+
* short enough that a dead runner's work is picked up the same night rather
|
|
50
|
+
* than at breakfast.
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_LEASE_MS = 15 * 60_000;
|
|
53
|
+
/**
|
|
54
|
+
* Take the task, if it is free.
|
|
55
|
+
*
|
|
56
|
+
* Free means no claim, or a claim whose lease expired, or one already released.
|
|
57
|
+
* A live lease belonging to somebody else is refused with who holds it and
|
|
58
|
+
* until when, because "no" without a reason is indistinguishable from a bug.
|
|
59
|
+
*/
|
|
60
|
+
export function acquire(store, taskRef, runner, options) {
|
|
61
|
+
// Replay detection is ACQUIRE-SPECIFIC (external dispatch, finding 36):
|
|
62
|
+
// the stored payload is untouched — the flag rides only the returned
|
|
63
|
+
// copy, so a second replay cannot double-stamp, and Store.replay's T→T
|
|
64
|
+
// contract holds for every other user.
|
|
65
|
+
const replayed = store.hasMutationRecord(options.mutation ?? {});
|
|
66
|
+
const result = inTransaction(store, () => acquireLocked(store, taskRef, runner, options));
|
|
67
|
+
return replayed && result.ok ? { ...result, replayed: true } : result;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The body of `acquire`, for callers already holding the transaction.
|
|
71
|
+
* `acquireIfReady` needs its readiness check and the CAS to be one atomic
|
|
72
|
+
* step, and SQLite does not nest transactions.
|
|
73
|
+
*/
|
|
74
|
+
function acquireLocked(store, taskRef, runner, options) {
|
|
75
|
+
const { now, ttlMs = DEFAULT_LEASE_MS, newLeaseId = randomUUID, mutation = {} } = options;
|
|
76
|
+
const db = store.handle;
|
|
77
|
+
// Idempotency wraps the CAS, not the other way round: a retried
|
|
78
|
+
// acquire must hand back the first answer rather than take a second lease.
|
|
79
|
+
return (store.replay(mutation, "acquire", () => {
|
|
80
|
+
const stamp = now.toISOString();
|
|
81
|
+
// THE RUNNER GATE, first leg (MCP gateway spec v6). Identity is
|
|
82
|
+
// proven INSIDE this transaction — the row re-read, the hash
|
|
83
|
+
// re-compared — so a takeover between the caller's own auth and
|
|
84
|
+
// this CAS never lets a stale process ride its successor's
|
|
85
|
+
// authority. Then the repo tuple: authority derives from
|
|
86
|
+
// task_ref.repo ONLY; a task placed nowhere cannot be authorized
|
|
87
|
+
// by a repo-scoped runner, and a runner not bound to the task's
|
|
88
|
+
// repo never even holds a claim.
|
|
89
|
+
const gate = authenticate(store, runner, options.token);
|
|
90
|
+
if (!gate.ok)
|
|
91
|
+
return { ok: false, reason: "unauthenticated", detail: gate.reason };
|
|
92
|
+
const placedRef = store.refForId(taskRef);
|
|
93
|
+
const placedRepo = placedRef?.repo ?? null;
|
|
94
|
+
if (placedRepo === null)
|
|
95
|
+
return { ok: false, reason: "unplaced" };
|
|
96
|
+
if (!gate.runner.repos.includes(placedRepo)) {
|
|
97
|
+
return { ok: false, reason: "unauthorized-repo", repo: placedRepo };
|
|
98
|
+
}
|
|
99
|
+
// THE COORDINATOR QUARANTINE in the one primitive every claim road
|
|
100
|
+
// shares (MCP spec v6, round-3 f1): a coordinator-filed task whose
|
|
101
|
+
// scope has not been sealed by the password ceremony claims for
|
|
102
|
+
// NOBODY — not the pre-approval planner road, not an attended
|
|
103
|
+
// authorization, not a raw CLI claim. One SQL predicate, so roads
|
|
104
|
+
// cannot diverge. After the seal it is ordinary in every respect.
|
|
105
|
+
if (placedRef !== null && placedRef.coordinatorCid !== null && !store.scopeSealed(placedRef.externalId)) {
|
|
106
|
+
return { ok: false, reason: "coordinator-filed" };
|
|
107
|
+
}
|
|
108
|
+
// The reservation gate lives HERE, in the one primitive every
|
|
109
|
+
// acquisition path shares (queue-columns review, finding 1): tick's
|
|
110
|
+
// acquireIfReady, the raw CLI claim, and the tournament resume all
|
|
111
|
+
// pass through this line, so a task reserved for one worker can
|
|
112
|
+
// never be taken by another, whatever the caller's snapshot said.
|
|
113
|
+
// Scheduling, not authority: nothing about WHAT may build changes.
|
|
114
|
+
const reservedFor = store.assignedRunnerOf(taskRef);
|
|
115
|
+
if (reservedFor !== null && reservedFor !== runner) {
|
|
116
|
+
return { ok: false, reason: "reserved", reservedFor };
|
|
117
|
+
}
|
|
118
|
+
// The external-mirror gate (dispatch v3 §2): every acquisition path
|
|
119
|
+
// shares this line, so a stale, closed, revoked, or plane-blocked
|
|
120
|
+
// mirror never starts a build — whatever the caller's snapshot said.
|
|
121
|
+
// An ordinary task costs one indexed lookup.
|
|
122
|
+
const mirrorWhy = store.mirrorAdmissionRefusal(taskRef, now, options.syncMaxAgeMs ?? SYNC_MAX_AGE_MS);
|
|
123
|
+
if (mirrorWhy !== null && mirrorWhy !== "not-a-mirror") {
|
|
124
|
+
return { ok: false, reason: "external", detail: mirrorWhy };
|
|
125
|
+
}
|
|
126
|
+
// The attended gate (Phase 2, v6 W10/Q4), in the one primitive every
|
|
127
|
+
// acquisition path shares — raw CLI claims included: a task with an
|
|
128
|
+
// OPEN attended authorization dispatches only to its named runner,
|
|
129
|
+
// and a task whose ONLY authority is attended dispatches only while
|
|
130
|
+
// the operator is watching, with the one attempt unspent.
|
|
131
|
+
// An authorization past its absolute expiry gates NOTHING (round-6
|
|
132
|
+
// finding 8): it is a corpse the sweep will close, and letting it
|
|
133
|
+
// keep refusing other runners on an approved task would be a
|
|
134
|
+
// permanent lock nobody signed.
|
|
135
|
+
// THE MODE BELT on the one primitive every claim road shares (Codex
|
|
136
|
+
// people round 2, finding 2): a mode-sealed approval whose signature
|
|
137
|
+
// no longer stands — revoked, expired by clock, or a dead signer —
|
|
138
|
+
// does not dispatch, CLI claim included. Live claims already taken
|
|
139
|
+
// are untouched; this fences only NEW takes.
|
|
140
|
+
if (!store.modeApprovalLive(taskRef, now)) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: "mode-ended",
|
|
144
|
+
message: "the operating mode that approved this has ended — the approval falls back to a person",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const attendedRow = store.openAuthorizationFor(taskRef);
|
|
148
|
+
const attendedOpen = attendedRow !== null && Date.parse(attendedRow.absoluteExpiry) > now.getTime() ? attendedRow : null;
|
|
149
|
+
if (attendedOpen !== null) {
|
|
150
|
+
if (attendedOpen.runner !== runner) {
|
|
151
|
+
return { ok: false, reason: "attended-held", runner: attendedOpen.runner };
|
|
152
|
+
}
|
|
153
|
+
const scopeApproved = db
|
|
154
|
+
.prepare(`SELECT 1 AS hit FROM task_scope
|
|
155
|
+
JOIN task_ref ON task_ref.id = ? AND task_scope.task_id = task_ref.external_id
|
|
156
|
+
WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL AND (COALESCE(task_scope.approval_basis, 'password') <> 'mode'
|
|
157
|
+
OR EXISTS (SELECT 1 FROM operating_mode om
|
|
158
|
+
JOIN approver signer ON signer.name = om.signed_by
|
|
159
|
+
WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
|
|
160
|
+
AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
|
|
161
|
+
AND signer.revoked_at IS NULL AND signer.role = 'approver'))`)
|
|
162
|
+
.get(taskRef, now.toISOString());
|
|
163
|
+
if (scopeApproved === undefined) {
|
|
164
|
+
const watching = attendedWatchState(attendedOpen.lastBeatAt, now, attendedOpen.absoluteExpiry);
|
|
165
|
+
if ((watching !== "live" && watching !== "grace") || attendedOpen.attemptRun !== null) {
|
|
166
|
+
return { ok: false, reason: "attended-only" };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const existing = latest(db, taskRef);
|
|
171
|
+
if (existing !== undefined && isLive(existing, stamp)) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "held",
|
|
175
|
+
by: String(existing["runner"]),
|
|
176
|
+
until: String(existing["expires_at"]),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const generation = existing === undefined ? 1 : Number(existing["lease_generation"]) + 1;
|
|
180
|
+
const claim = {
|
|
181
|
+
taskRef,
|
|
182
|
+
leaseId: newLeaseId(),
|
|
183
|
+
generation,
|
|
184
|
+
runner,
|
|
185
|
+
acquiredAt: stamp,
|
|
186
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
187
|
+
heartbeatAt: stamp,
|
|
188
|
+
};
|
|
189
|
+
// The compare-and-swap, enforced by UNIQUE (task_ref, lease_generation)
|
|
190
|
+
// rather than by anything this code does. Two runners that both read the
|
|
191
|
+
// same generation both try to write generation + 1; the database admits
|
|
192
|
+
// one. OR IGNORE turns the loser's constraint violation into a row count
|
|
193
|
+
// of zero, because losing a race is an ordinary outcome and not an error.
|
|
194
|
+
const { changes } = db
|
|
195
|
+
.prepare(`INSERT OR IGNORE INTO claim
|
|
196
|
+
(lease_id, task_ref, lease_generation, runner, acquired_at, expires_at, heartbeat_at, released_at, incarnation)
|
|
197
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`)
|
|
198
|
+
.run(claim.leaseId, taskRef, claim.generation, runner, claim.acquiredAt, claim.expiresAt, claim.heartbeatAt, options.incarnation ?? null);
|
|
199
|
+
if (Number(changes) === 0) {
|
|
200
|
+
const winner = latest(db, taskRef);
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: "held",
|
|
204
|
+
by: winner === undefined ? "unknown" : String(winner["runner"]),
|
|
205
|
+
until: winner === undefined ? stamp : String(winner["expires_at"]),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { ok: true, claim, reclaimed: existing !== undefined };
|
|
209
|
+
},
|
|
210
|
+
// Only a lease that was actually granted is worth remembering. Recording
|
|
211
|
+
// the refusal would make this key a permanent "no" for a task that is
|
|
212
|
+
// free again five minutes later.
|
|
213
|
+
result => result.ok));
|
|
214
|
+
}
|
|
215
|
+
/** The newest lease on a task, held or not. */
|
|
216
|
+
function latest(db, taskRef) {
|
|
217
|
+
return db
|
|
218
|
+
.prepare("SELECT * FROM claim WHERE task_ref = ? ORDER BY lease_generation DESC LIMIT 1")
|
|
219
|
+
.get(taskRef);
|
|
220
|
+
}
|
|
221
|
+
/** DESIGN §8 gate 6: above this many open decisions, stop dispatching the parkers.
|
|
222
|
+
* `missingCapability` remains re-exported for callers of the old claim seam. */
|
|
223
|
+
export { DEFAULT_MAX_OPEN_DECISIONS, missingCapability } from "./dispatch.js";
|
|
224
|
+
/**
|
|
225
|
+
* Take the task, if it is free *and still worth taking*.
|
|
226
|
+
*
|
|
227
|
+
* `listReady` then `acquire` is two reads of a world that moves between them:
|
|
228
|
+
* a hold placed, a blocker reopened, the task cancelled — and the CAS admits
|
|
229
|
+
* the claim anyway, because the CAS only defends against other claimants. An
|
|
230
|
+
* unattended pass has nobody watching who would notice the stale dispatch, so
|
|
231
|
+
* the readiness conditions are re-proved inside the same transaction as the
|
|
232
|
+
* acquire, against rows the write lock has already pinned.
|
|
233
|
+
*
|
|
234
|
+
* "Not ready" is a different answer from "held": held means somebody else got
|
|
235
|
+
* it, which is a race being won; not-ready means nobody should have it, and
|
|
236
|
+
* says why.
|
|
237
|
+
*/
|
|
238
|
+
export function acquireIfReady(store, taskRef, runner, options) {
|
|
239
|
+
const role = options.dispatchRole ?? "builder";
|
|
240
|
+
return inTransaction(store, () => {
|
|
241
|
+
const why = taskReadinessBlocker(store, taskRef, options.now);
|
|
242
|
+
if (why !== null)
|
|
243
|
+
return { ok: false, reason: "not-ready", message: why.message };
|
|
244
|
+
// The role's own precondition, re-read where the write lock has pinned
|
|
245
|
+
// it — never an early return past the shared gates below. A planner
|
|
246
|
+
// dispatches exactly when the operator asked and no promise exists yet;
|
|
247
|
+
// a builder dispatches exactly when the promise is approved. A
|
|
248
|
+
// mode-sealed approval additionally re-proves its signature is STILL
|
|
249
|
+
// the live mode (belt to the demotion sweep — R-REVOKE's next gate
|
|
250
|
+
// holds even in the window before an expired mode is durably closed).
|
|
251
|
+
const approvedScope = scopeApprovedForDispatch(store, taskRef, options.now);
|
|
252
|
+
if (role === "planner") {
|
|
253
|
+
const ref = store.refForId(taskRef);
|
|
254
|
+
if (ref?.plan !== "requested") {
|
|
255
|
+
return { ok: false, reason: "not-ready", message: "no plan was requested" };
|
|
256
|
+
}
|
|
257
|
+
if (approvedScope) {
|
|
258
|
+
return { ok: false, reason: "not-ready", message: "the scope is already approved — nothing left to plan" };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// The deliverable is re-read here (v34): a scout dispatches exactly on
|
|
262
|
+
// a report task with an approved scope, and a builder never on one —
|
|
263
|
+
// the caller's survey chose the role, the row proves it.
|
|
264
|
+
if (role === "scout" || role === "builder") {
|
|
265
|
+
const deliverable = store.refForId(taskRef)?.deliverable ?? "branch";
|
|
266
|
+
if (role === "scout" && deliverable !== "report") {
|
|
267
|
+
return { ok: false, reason: "not-ready", message: "this task delivers a branch — a scout has nothing to report on it" };
|
|
268
|
+
}
|
|
269
|
+
if (role === "builder" && deliverable === "report") {
|
|
270
|
+
return { ok: false, reason: "not-ready", message: "this task delivers a report — a scout, never a builder, takes it" };
|
|
271
|
+
}
|
|
272
|
+
if (role === "scout" && !approvedScope) {
|
|
273
|
+
return { ok: false, reason: "not-ready", message: "the scope is not approved" };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let attendedAuthority = false;
|
|
277
|
+
if (role !== "planner" && !approvedScope) {
|
|
278
|
+
// The authority union (v6 W1): an unapproved scope still dispatches
|
|
279
|
+
// when a LIVE attended authorization names THIS runner and its one
|
|
280
|
+
// attempt is unspent — the authorization IS the authority. Everything
|
|
281
|
+
// else about readiness still gates below and in acquireLocked.
|
|
282
|
+
const authorization = store.openAuthorizationFor(taskRef);
|
|
283
|
+
const watching = authorization === null
|
|
284
|
+
? null
|
|
285
|
+
: attendedWatchState(authorization.lastBeatAt, options.now, authorization.absoluteExpiry);
|
|
286
|
+
attendedAuthority =
|
|
287
|
+
authorization !== null &&
|
|
288
|
+
authorization.runner === runner &&
|
|
289
|
+
(watching === "live" || watching === "grace") &&
|
|
290
|
+
authorization.attemptRun === null;
|
|
291
|
+
if (!attendedAuthority) {
|
|
292
|
+
return { ok: false, reason: "not-ready", message: "the scope is not approved" };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Capabilities are re-read inside the same transaction as the CAS, like
|
|
296
|
+
// every other readiness fact: a key that expired between the survey and
|
|
297
|
+
// the take must not be dispatched on the survey's answer.
|
|
298
|
+
const gap = missingCapability(store, taskRef, options.repo ?? null, options.now);
|
|
299
|
+
if (gap !== null)
|
|
300
|
+
return { ok: false, reason: "capability", message: gap };
|
|
301
|
+
// Capacity (§8 gate 3), counted where the claim lands — two overlapping
|
|
302
|
+
// passes cannot both see a free slot that only exists once. Enforced for
|
|
303
|
+
// registered runners; a runner the store has never met is a test driving
|
|
304
|
+
// the API directly, and the CLI always registers.
|
|
305
|
+
const registered = store.getRunner(runner);
|
|
306
|
+
if (registered !== null && !attendedAuthority) {
|
|
307
|
+
// Capacity governs UNATTENDED work (v6 W2 + round-6 finding 7): an
|
|
308
|
+
// attended session's bound is one-held-per-runner, so a full
|
|
309
|
+
// unattended ledger must not refuse the operator who is watching.
|
|
310
|
+
const held = store.liveClaimCount(runner, options.now);
|
|
311
|
+
if (held >= registered.runner.capacity) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
reason: "capacity",
|
|
315
|
+
message: `${runner} holds ${held} of ${registered.runner.capacity} slot(s) — a free CPU against a full ledger is not capacity`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Quota, same gate: an exhausted credential refuses; a half-open one
|
|
320
|
+
// admits exactly this dispatch as its probe, consumed here so a racing
|
|
321
|
+
// pass cannot also treat it as open.
|
|
322
|
+
const scope = options.model ?? "";
|
|
323
|
+
// Quota is keyed to the credential that actually exhausts: the RESOLVED
|
|
324
|
+
// provider, never a fixed binary name — codex quota must not collide
|
|
325
|
+
// with claude's, nor bypass it (Codex provider review, high finding 5).
|
|
326
|
+
const quota = store.quotaState(runner, options.provider ?? "claude", scope, options.now);
|
|
327
|
+
if (quota !== null && quota.state === "exhausted") {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
reason: "quota",
|
|
331
|
+
message: `${runner}'s provider quota is exhausted (${quota.reason})${quota.resetAt === null ? "" : ` until ${quota.resetAt}`} — a free slot against an exhausted quota is not capacity`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// THE ROUTED PROVIDER'S READINESS (v47), re-read inside the same
|
|
335
|
+
// transaction: a provider THIS runner has reported unavailable never
|
|
336
|
+
// claims — no substitution, no second-best. Unknown passes; only a
|
|
337
|
+
// recorded unavailable refuses, and it says what was observed.
|
|
338
|
+
const readiness = store.runnerReadinessOf(runner, options.provider ?? "claude");
|
|
339
|
+
if (readiness !== null && readiness.state === "unavailable") {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
reason: "provider-unavailable",
|
|
343
|
+
message: `${readiness.provider} is reported unavailable on ${runner} (${readiness.reason}; observed ${readiness.observedAt}) — nothing substitutes for a routed provider`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// The attention budget (§8, gate 6), proved where every other readiness
|
|
347
|
+
// fact is proved. A phone with thirty open questions answers none of
|
|
348
|
+
// them; above the budget, tasks with a *measured* habit of parking step
|
|
349
|
+
// aside so the night keeps building what builds. First-time parkers pass
|
|
350
|
+
// — a rate nobody measured is not a rate.
|
|
351
|
+
const budget = options.maxOpenDecisions ?? DEFAULT_MAX_OPEN_DECISIONS;
|
|
352
|
+
if (store.countUnanswered() >= budget) {
|
|
353
|
+
// A planner exists to generate questions; above the budget it is
|
|
354
|
+
// refused outright — no zero-rate first-timer pass (finding 2).
|
|
355
|
+
if (role === "planner") {
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
reason: "attention-budget",
|
|
359
|
+
message: `${store.countUnanswered()} decisions already wait — a planner would only add more; answer some first`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const rate = store.refForId(taskRef)?.parkRate ?? 0;
|
|
363
|
+
if (rate > 0) {
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
reason: "attention-budget",
|
|
367
|
+
message: `${store.countUnanswered()} decisions already wait and this task parks ${Math.round(rate * 100)}% of its attempts — answer some before it may add more`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const taken = acquireLocked(store, taskRef, runner, options);
|
|
372
|
+
// The half-open probe slot is consumed only WITH the claim it admits —
|
|
373
|
+
// an attention refusal or a lost CAS must not re-arm the quota as
|
|
374
|
+
// exhausted with no probe in flight (finding 3). Same transaction, so
|
|
375
|
+
// consume-with-claim is all-or-nothing.
|
|
376
|
+
if (taken.ok && quota !== null) {
|
|
377
|
+
store.consumeHalfOpen(runner, options.provider ?? "claude", scope, options.now);
|
|
378
|
+
}
|
|
379
|
+
return taken;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The FALLBACK-ADMISSION claim (E3d, review finding 4): the one gate a
|
|
384
|
+
* pending-admission chain cycle dispatches through. It enforces everything
|
|
385
|
+
* `acquireIfReady` enforces — task state, holds, blockers, the approved
|
|
386
|
+
* scope + live-mode belt, capability, capacity, and quota — with EXACTLY
|
|
387
|
+
* ONE deliberate difference: a BACKOFF hold does not gate, because the
|
|
388
|
+
* pending admission IS the system's answer to the predecessor's failure
|
|
389
|
+
* (waiting out the exhausted entry's backoff to run a different credential
|
|
390
|
+
* would be waiting for nothing). Quota is keyed by the PINNED entry's
|
|
391
|
+
* provider, model, and auth mode — the credential that would actually
|
|
392
|
+
* spend — so an exhausted fallback credential refuses here, before any
|
|
393
|
+
* claim exists.
|
|
394
|
+
*/
|
|
395
|
+
export function acquireFallback(store, taskRef, runner, options) {
|
|
396
|
+
return inTransaction(store, () => {
|
|
397
|
+
const db = store.handle;
|
|
398
|
+
const stamp = options.now.toISOString();
|
|
399
|
+
const task = db
|
|
400
|
+
.prepare(`SELECT task.id, task.state FROM task
|
|
401
|
+
JOIN task_ref ON task_ref.external_id = task.id AND task_ref.backend = ?
|
|
402
|
+
WHERE task_ref.id = ?`)
|
|
403
|
+
.get(BUILT_IN, taskRef);
|
|
404
|
+
if (task === undefined)
|
|
405
|
+
return { ok: false, reason: "not-ready", message: "no such task" };
|
|
406
|
+
// A task the strikes already ended (state failed) or that concluded is
|
|
407
|
+
// TERMINAL: its pending cycle never spends (the reviewer's third-strike
|
|
408
|
+
// scenario) — the cycle simply waits out as an inert record.
|
|
409
|
+
if (String(task["state"]) !== "queued") {
|
|
410
|
+
return { ok: false, reason: "not-ready", message: `state is ${String(task["state"])}, not queued` };
|
|
411
|
+
}
|
|
412
|
+
// Every hold EXCEPT backoff gates: an operator hold, a decision hold,
|
|
413
|
+
// an incident hold all still mean "do not spend".
|
|
414
|
+
const hold = db
|
|
415
|
+
.prepare(`SELECT reason FROM hold
|
|
416
|
+
WHERE task_ref = ? AND owner_kind <> 'backoff' AND (until IS NULL OR until > ?) LIMIT 1`)
|
|
417
|
+
.get(taskRef, stamp);
|
|
418
|
+
if (hold !== undefined)
|
|
419
|
+
return { ok: false, reason: "not-ready", message: `held: ${String(hold["reason"])}` };
|
|
420
|
+
const blocker = db
|
|
421
|
+
.prepare(`SELECT blocker.id FROM task_edge
|
|
422
|
+
JOIN task AS blocker ON blocker.id = task_edge.blocker
|
|
423
|
+
WHERE task_edge.blocked = ? AND blocker.state <> 'done'
|
|
424
|
+
ORDER BY blocker.id LIMIT 1`)
|
|
425
|
+
.get(String(task["id"]));
|
|
426
|
+
if (blocker !== undefined)
|
|
427
|
+
return { ok: false, reason: "not-ready", message: `waiting on ${String(blocker["id"])}` };
|
|
428
|
+
// The approved scope + live-mode belt, verbatim from acquireIfReady: a
|
|
429
|
+
// chain approval IS a scope approval, and a mode-sealed one must still
|
|
430
|
+
// stand.
|
|
431
|
+
const approvedScope = db
|
|
432
|
+
.prepare(`SELECT 1 AS hit FROM task_scope
|
|
433
|
+
JOIN task_ref ON task_ref.id = ? AND task_scope.task_id = task_ref.external_id
|
|
434
|
+
WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL AND (COALESCE(task_scope.approval_basis, 'password') <> 'mode'
|
|
435
|
+
OR EXISTS (SELECT 1 FROM operating_mode om
|
|
436
|
+
JOIN approver signer ON signer.name = om.signed_by
|
|
437
|
+
WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
|
|
438
|
+
AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
|
|
439
|
+
AND signer.revoked_at IS NULL AND signer.role = 'approver'))`)
|
|
440
|
+
.get(taskRef, stamp);
|
|
441
|
+
if (approvedScope === undefined) {
|
|
442
|
+
return { ok: false, reason: "not-ready", message: "the scope is not approved" };
|
|
443
|
+
}
|
|
444
|
+
const gap = missingCapability(store, taskRef, options.repo ?? null, options.now);
|
|
445
|
+
if (gap !== null)
|
|
446
|
+
return { ok: false, reason: "capability", message: gap };
|
|
447
|
+
// The pinned entry's provider must not be one this runner reports
|
|
448
|
+
// unavailable (v47): the approved chain is the ONLY substitution road,
|
|
449
|
+
// and it still never dispatches onto a provider known to be missing.
|
|
450
|
+
const entryReadiness = store.runnerReadinessOf(runner, options.provider);
|
|
451
|
+
if (entryReadiness !== null && entryReadiness.state === "unavailable") {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
reason: "provider-unavailable",
|
|
455
|
+
message: `${entryReadiness.provider} is reported unavailable on ${runner} (${entryReadiness.reason}; observed ${entryReadiness.observedAt}) — the fallback entry cannot run here`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const registered = store.getRunner(runner);
|
|
459
|
+
if (registered !== null) {
|
|
460
|
+
const held = store.liveClaimCount(runner, options.now);
|
|
461
|
+
if (held >= registered.runner.capacity) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
reason: "capacity",
|
|
465
|
+
message: `${runner} holds ${held} of ${registered.runner.capacity} slot(s) — a free CPU against a full ledger is not capacity`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Quota, keyed by the PINNED credential that would spend.
|
|
470
|
+
const quota = store.quotaState(runner, options.provider, options.model, options.now, options.authMode);
|
|
471
|
+
if (quota !== null && quota.state === "exhausted") {
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
reason: "quota",
|
|
475
|
+
message: `${runner}'s ${options.provider} quota is exhausted (${quota.reason})${quota.resetAt === null ? "" : ` until ${quota.resetAt}`} — the fallback entry's own credential is spent`,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
// The attention budget, exactly as the ordinary road holds it (E3d
|
|
479
|
+
// verify, R4): a fallback attempt can park and add a decision like any
|
|
480
|
+
// other, so above the budget a task with a measured parking habit steps
|
|
481
|
+
// aside here too — backoff is the ONE exemption, not this.
|
|
482
|
+
const budget = options.maxOpenDecisions ?? DEFAULT_MAX_OPEN_DECISIONS;
|
|
483
|
+
if (store.countUnanswered() >= budget) {
|
|
484
|
+
const rate = store.refForId(taskRef)?.parkRate ?? 0;
|
|
485
|
+
if (rate > 0) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
reason: "attention-budget",
|
|
489
|
+
message: `${store.countUnanswered()} decisions already wait and this task parks ${Math.round(rate * 100)}% of its attempts — answer some before it may add more`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const taken = acquireLocked(store, taskRef, runner, options);
|
|
494
|
+
if (taken.ok && quota !== null) {
|
|
495
|
+
store.consumeHalfOpen(runner, options.provider, options.model, options.now, options.authMode);
|
|
496
|
+
}
|
|
497
|
+
return taken;
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* "I am still here." Extends the lease, and tells a superseded runner that it
|
|
502
|
+
* has been superseded — which is the cheapest moment for it to find out, well
|
|
503
|
+
* before it has finished work nobody will accept.
|
|
504
|
+
*/
|
|
505
|
+
export function heartbeat(store, leaseId, now, ttlMs = DEFAULT_LEASE_MS) {
|
|
506
|
+
const db = store.handle;
|
|
507
|
+
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
508
|
+
return inTransaction(store, () => {
|
|
509
|
+
const { changes } = db
|
|
510
|
+
.prepare(`UPDATE claim SET heartbeat_at = ?, expires_at = ?
|
|
511
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
512
|
+
.run(now.toISOString(), expiresAt, leaseId);
|
|
513
|
+
if (Number(changes) === 0)
|
|
514
|
+
return refusal(db, leaseId);
|
|
515
|
+
const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
|
|
516
|
+
return { ok: true, claim: readClaim(row) };
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The fence, as a predicate on the write rather than a check before it.
|
|
521
|
+
*
|
|
522
|
+
* Checking first and updating second leaves a window: a reclaim committing
|
|
523
|
+
* between the two makes the check's answer stale, and the update — matching on
|
|
524
|
+
* lease id alone — succeeds anyway. Both runners then believe they hold the
|
|
525
|
+
* task, which is the precise failure this module was written to make
|
|
526
|
+
* impossible. Putting the condition inside the statement closes it, because
|
|
527
|
+
* SQLite evaluates it against the row it is about to write.
|
|
528
|
+
*/
|
|
529
|
+
const NOT_SUPERSEDED = `NOT EXISTS (
|
|
530
|
+
SELECT 1 FROM claim AS newer
|
|
531
|
+
WHERE newer.task_ref = claim.task_ref
|
|
532
|
+
AND newer.lease_generation > claim.lease_generation
|
|
533
|
+
)`;
|
|
534
|
+
/**
|
|
535
|
+
* Why a fenced write matched nothing. Worth the extra read: "you were
|
|
536
|
+
* superseded" and "I have never heard of this lease" send a runner to very
|
|
537
|
+
* different places.
|
|
538
|
+
*/
|
|
539
|
+
function refusal(db, leaseId) {
|
|
540
|
+
const row = db.prepare("SELECT 1 AS hit FROM claim WHERE lease_id = ?").get(leaseId);
|
|
541
|
+
return { ok: false, reason: row === undefined ? "unknown" : "fenced" };
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Hand the task back.
|
|
545
|
+
*
|
|
546
|
+
* Accepted only from the lease that currently holds it. A completion arriving
|
|
547
|
+
* on a superseded lease is the failure this whole module exists for, and it is
|
|
548
|
+
* rejected without touching anything — the work is not lost, it is simply not
|
|
549
|
+
* this runner's to report, and the record says so rather than overwriting a
|
|
550
|
+
* live claim with a dead runner's opinion.
|
|
551
|
+
*/
|
|
552
|
+
export function release(store, leaseId, now) {
|
|
553
|
+
const db = store.handle;
|
|
554
|
+
return inTransaction(store, () => {
|
|
555
|
+
const { changes } = db
|
|
556
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
557
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
558
|
+
.run(now.toISOString(), leaseId);
|
|
559
|
+
if (Number(changes) === 0) {
|
|
560
|
+
// A repeat of a hand-back this same lease already made is not a fence —
|
|
561
|
+
// nobody took the task away, the runner simply said so twice because its
|
|
562
|
+
// first acknowledgement was lost. M1 asks for duplicate completion to be
|
|
563
|
+
// reconciled rather than refused, and telling an honest runner it was
|
|
564
|
+
// superseded would send it to stop when it should carry on.
|
|
565
|
+
//
|
|
566
|
+
// Deliberately not filtered by supersession. A lease that released and
|
|
567
|
+
// was then reacquired by somebody else still *completed*: its work was
|
|
568
|
+
// accepted at the time, and the honest answer to a late retry is "you
|
|
569
|
+
// already did this", not "you were fenced" — which would say its work
|
|
570
|
+
// never counted. Fencing is for a lease that never finished.
|
|
571
|
+
//
|
|
572
|
+
// But *how* the lease came to be released decides everything. Only a
|
|
573
|
+
// release the runner itself made counts as "you already did this". A
|
|
574
|
+
// lease the reaper or dead-runner recovery took back was never handed
|
|
575
|
+
// in by anybody — the world moved on without it, and the answer to its
|
|
576
|
+
// late retry is the fence.
|
|
577
|
+
return duplicateOrRefusal(db, leaseId, ["released", "completed"]);
|
|
578
|
+
}
|
|
579
|
+
const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
|
|
580
|
+
store.bumpWake();
|
|
581
|
+
return { ok: true, claim: readClaim(row) };
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Whether an already-released lease was released in a way the caller may
|
|
586
|
+
* treat as its own doing. Anything else — reaped, recovered, or provenance
|
|
587
|
+
* unknown — is the fence, because accepting it would let a reclaimed lease's
|
|
588
|
+
* late retry pass as an ordinary duplicate.
|
|
589
|
+
*/
|
|
590
|
+
function duplicateOrRefusal(db, leaseId, own) {
|
|
591
|
+
const released = db
|
|
592
|
+
.prepare("SELECT * FROM claim WHERE lease_id = ? AND released_at IS NOT NULL")
|
|
593
|
+
.get(leaseId);
|
|
594
|
+
if (released !== undefined && own.includes(String(released["released_by"] ?? ""))) {
|
|
595
|
+
return { ok: true, claim: readClaim(released), duplicate: true };
|
|
596
|
+
}
|
|
597
|
+
if (released !== undefined)
|
|
598
|
+
return { ok: false, reason: "fenced" };
|
|
599
|
+
return refusal(db, leaseId);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Release the lease and write the task's terminal state, as one step.
|
|
603
|
+
*
|
|
604
|
+
* Release-then-mark is a window: between the two, the freed task is back in
|
|
605
|
+
* the ready set, and another pass can claim it before the terminal state
|
|
606
|
+
* lands — two builders for one task, the second dispatched by our own
|
|
607
|
+
* bookkeeping. Here the state is written inside the same transaction as the
|
|
608
|
+
* fenced release, so a task is never simultaneously free and unfinished.
|
|
609
|
+
*
|
|
610
|
+
* A fenced or unknown lease writes nothing: a runner the world moved past
|
|
611
|
+
* does not get to say how the task ended. A duplicate release reports
|
|
612
|
+
* `duplicate` and also writes nothing — the first completion already said,
|
|
613
|
+
* and a retry changing the answer would make "done" negotiable.
|
|
614
|
+
*/
|
|
615
|
+
export function completeFenced(store, leaseId, state, now, mutation = {}) {
|
|
616
|
+
const db = store.handle;
|
|
617
|
+
return inTransaction(store, () => {
|
|
618
|
+
const stoppedRun = db.prepare("SELECT r.id FROM run r JOIN run_stop s ON s.run = r.id WHERE r.lease_id = ? AND r.outcome IS NULL LIMIT 1").get(leaseId);
|
|
619
|
+
if (stoppedRun !== undefined) {
|
|
620
|
+
const runId = Number(stoppedRun["id"]);
|
|
621
|
+
const run = store.getRun(runId);
|
|
622
|
+
const task = store.refForId(run.taskRef);
|
|
623
|
+
if (task !== null)
|
|
624
|
+
interruptIfStopped(store, { leaseId, runId, taskId: task.externalId, now });
|
|
625
|
+
return { ok: false, reason: "stopped" };
|
|
626
|
+
}
|
|
627
|
+
const { changes } = db
|
|
628
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
|
|
629
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
630
|
+
.run(now.toISOString(), leaseId);
|
|
631
|
+
if (Number(changes) === 0) {
|
|
632
|
+
// Only a completion this same lease already made reads as a duplicate.
|
|
633
|
+
// The DURABLE disposition (released_by) reproduces the original arm —
|
|
634
|
+
// whatever the task or the tracker did since (finding 33). A lease
|
|
635
|
+
// the reaper or recovery released was never *accepted*; a late
|
|
636
|
+
// completion after that is exactly the stale commit the fence keeps
|
|
637
|
+
// out.
|
|
638
|
+
const released = db
|
|
639
|
+
.prepare("SELECT * FROM claim WHERE lease_id = ? AND released_at IS NOT NULL")
|
|
640
|
+
.get(leaseId);
|
|
641
|
+
if (released !== undefined) {
|
|
642
|
+
const by = String(released["released_by"] ?? "");
|
|
643
|
+
if (by === "completed" || by === "disowned") {
|
|
644
|
+
return {
|
|
645
|
+
ok: true,
|
|
646
|
+
arm: by === "disowned" ? "disowned" : "completed",
|
|
647
|
+
claim: readClaim(released),
|
|
648
|
+
duplicate: true,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
return { ok: false, reason: "fenced" };
|
|
652
|
+
}
|
|
653
|
+
return refusal(db, leaseId);
|
|
654
|
+
}
|
|
655
|
+
const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
|
|
656
|
+
const claim = readClaim(row);
|
|
657
|
+
const taskId = db
|
|
658
|
+
.prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = ?")
|
|
659
|
+
.get(claim.taskRef, BUILT_IN);
|
|
660
|
+
// The completion gate (external dispatch, v4 §24): the latch is read
|
|
661
|
+
// INSIDE this transaction, on a FRESH win only. A latched mirror's
|
|
662
|
+
// completion is DISOWNED — the task is cancelled, the disposition is
|
|
663
|
+
// written durably, and the caller never sees the completed arm, so
|
|
664
|
+
// publication is impossible by construction.
|
|
665
|
+
const disowned = taskId !== undefined && !store.mirrorAllowsCompletion(String(taskId["external_id"]));
|
|
666
|
+
if (disowned) {
|
|
667
|
+
db.prepare("UPDATE claim SET released_by = 'disowned' WHERE lease_id = ?").run(leaseId);
|
|
668
|
+
}
|
|
669
|
+
if (taskId !== undefined) {
|
|
670
|
+
store.replay(mutation, "completeFenced", () => {
|
|
671
|
+
if (disowned) {
|
|
672
|
+
// Cancellation goes through the one floor — a disowned
|
|
673
|
+
// completion is a typed machine reason, not a bare state write.
|
|
674
|
+
store.applyCancellation(String(taskId["external_id"]), { kind: "machine", code: "disowned-completion" }, now, null);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
db.prepare("UPDATE task SET state = ?, updated_at = ? WHERE id = ?").run(state, now.toISOString(), String(taskId["external_id"]));
|
|
678
|
+
}
|
|
679
|
+
return true;
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
store.bumpWake();
|
|
683
|
+
return { ok: true, arm: disowned ? "disowned" : "completed", claim };
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Seal a park, fenced at the write (§7).
|
|
688
|
+
*
|
|
689
|
+
* The builder proved its lease after the agent ran; this transaction proves
|
|
690
|
+
* it again *in the same statement that releases it*, because everything a
|
|
691
|
+
* park creates — the decision, its hold, the outbox row — is only true if
|
|
692
|
+
* this lease still spoke for the task at the moment of sealing. A superseded
|
|
693
|
+
* lease creates none of it: no decision, no hold, no notification, and the
|
|
694
|
+
* run is finalized as the fenced refusal it was. A crash anywhere inside
|
|
695
|
+
* rolls all of it back together; there is no instant at which a decision
|
|
696
|
+
* exists without its hold.
|
|
697
|
+
*
|
|
698
|
+
* `released_by = 'parked'` so a late retry from this lease is answered with
|
|
699
|
+
* the fence, not "duplicate": a park hands the task to a person, and nothing
|
|
700
|
+
* the runner says afterwards is that person's answer.
|
|
701
|
+
*/
|
|
702
|
+
/**
|
|
703
|
+
* Continuation admission (Phase 2E, A4 / v3 R7): the AUTHORIZATION is the
|
|
704
|
+
* claimable unit — the finished parent task is never re-queued, its state,
|
|
705
|
+
* strikes, holds, and dependents untouched. Every shared gate still runs
|
|
706
|
+
* (acquireLocked: reservation, external mirror, the attended gates, the
|
|
707
|
+
* lease CAS); the queued-state readiness gate alone is replaced by
|
|
708
|
+
* construction. Liveness, the named runner, and the unspent attempt are
|
|
709
|
+
* re-proved HERE at claim time and again at the final dispatch proof.
|
|
710
|
+
*/
|
|
711
|
+
export function acquireContinuation(store, authorization, runner, options) {
|
|
712
|
+
const live = store.readAuthorization(authorization.id);
|
|
713
|
+
if (live === null ||
|
|
714
|
+
live.closedAt !== null ||
|
|
715
|
+
live.attemptRun !== null ||
|
|
716
|
+
live.parentRun === null ||
|
|
717
|
+
live.runner !== runner) {
|
|
718
|
+
return { ok: false, reason: "attended-only", message: "the authorization is not an open continuation for this runner" };
|
|
719
|
+
}
|
|
720
|
+
const watching = attendedWatchState(live.lastBeatAt, options.now, live.absoluteExpiry);
|
|
721
|
+
if (watching !== "live" && watching !== "grace") {
|
|
722
|
+
return { ok: false, reason: "attended-only", message: "the operator is not watching" };
|
|
723
|
+
}
|
|
724
|
+
if (store.activeTournamentTerms(live.taskRef) !== null) {
|
|
725
|
+
return { ok: false, reason: "contest-open", message: "a tournament raced onto this task — continuation waits" };
|
|
726
|
+
}
|
|
727
|
+
const blocked = store.continuationBlockOf(live.parentRun);
|
|
728
|
+
if (blocked !== null) {
|
|
729
|
+
return { ok: false, reason: "continuation-blocked", message: blocked };
|
|
730
|
+
}
|
|
731
|
+
return acquire(store, live.taskRef, runner, options);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* The HELD park (Phase 2E, v2 S1f): the decision is recorded, paged, and
|
|
735
|
+
* causally linked to the session turn that produced it — and NOTHING else
|
|
736
|
+
* ends. The lease stays; the run stays open; the process stays held. The
|
|
737
|
+
* conversation continues when the answer is injected as the next turn.
|
|
738
|
+
* The one-unresolved-per-run partial unique refuses a second open park.
|
|
739
|
+
*/
|
|
740
|
+
export function finalizeParkHeld(store, args) {
|
|
741
|
+
const { runId, taskId, decision, artifactIds, sessionTurn, now } = args;
|
|
742
|
+
return inTransaction(store, () => {
|
|
743
|
+
const held = store.heldSessionOf(runId);
|
|
744
|
+
if (held === null || held.endedAt !== null || held.state !== "open") {
|
|
745
|
+
return { ok: false, reason: "not-held" };
|
|
746
|
+
}
|
|
747
|
+
const run = store.getRun(runId);
|
|
748
|
+
if (run === null || run.outcome !== null)
|
|
749
|
+
return { ok: false, reason: "not-held" };
|
|
750
|
+
let decisionId;
|
|
751
|
+
try {
|
|
752
|
+
decisionId = store.saveDecision({
|
|
753
|
+
run: runId,
|
|
754
|
+
urgency: decision.urgency,
|
|
755
|
+
recap: decision.recap,
|
|
756
|
+
question: decision.question,
|
|
757
|
+
options: decision.options,
|
|
758
|
+
recommendation: decision.recommendation,
|
|
759
|
+
...(decision.assignee === null ? {} : { assignee: decision.assignee }),
|
|
760
|
+
...(decision.deadline === null ? {} : { deadline: decision.deadline }),
|
|
761
|
+
}, now);
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
// The partial unique: one unresolved question per run at a time.
|
|
765
|
+
return { ok: false, reason: "decision-open" };
|
|
766
|
+
}
|
|
767
|
+
store.handle.prepare("UPDATE decision SET session_turn = ? WHERE id = ?").run(sessionTurn, decisionId);
|
|
768
|
+
for (const artifact of artifactIds)
|
|
769
|
+
store.linkEvidence(decisionId, artifact);
|
|
770
|
+
store.enqueueNotification({
|
|
771
|
+
source: { run: runId },
|
|
772
|
+
dedupeKey: `decision:${decisionId}`,
|
|
773
|
+
kind: "decision",
|
|
774
|
+
subject: `${taskId} asked a question mid-session`,
|
|
775
|
+
body: `${oneLine(decision.question, 200)}\n\`toolroll decide ${decisionId}\``,
|
|
776
|
+
pushClass: "decision",
|
|
777
|
+
link: `/d/${decisionId}`,
|
|
778
|
+
}, now);
|
|
779
|
+
return { ok: true, decisionId };
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Validate the optional accepted formatting child against the open planner
|
|
784
|
+
* root. The child remains open until the root's fence settles, so a crash or
|
|
785
|
+
* takeover can never leave it masquerading as successfully delivered work.
|
|
786
|
+
*/
|
|
787
|
+
function plannerRepairChild(store, root, repairRunId) {
|
|
788
|
+
if (repairRunId === null)
|
|
789
|
+
return null;
|
|
790
|
+
if (root.role !== "planner" || root.outcome !== null || root.providerStartedAt === null) {
|
|
791
|
+
throw new Error(`run ${root.id} cannot own a structured planner correction`);
|
|
792
|
+
}
|
|
793
|
+
const accepted = store.getRun(repairRunId);
|
|
794
|
+
if (accepted === null || accepted.id === root.id || accepted.role !== "planner" || accepted.outcome !== null) {
|
|
795
|
+
throw new Error(`run ${repairRunId} is not an open planner correction for ${root.id}`);
|
|
796
|
+
}
|
|
797
|
+
const sameCustody = (run) => run.taskRef === root.taskRef &&
|
|
798
|
+
run.leaseId === root.leaseId &&
|
|
799
|
+
run.runner === root.runner &&
|
|
800
|
+
run.provider === root.provider &&
|
|
801
|
+
run.model === root.model &&
|
|
802
|
+
run.sessionId !== null &&
|
|
803
|
+
run.sessionId === root.sessionId &&
|
|
804
|
+
run.branch === root.branch &&
|
|
805
|
+
run.worktree === root.worktree &&
|
|
806
|
+
run.baseRevision === root.baseRevision &&
|
|
807
|
+
run.providerStartedAt !== null;
|
|
808
|
+
if (!sameCustody(accepted)) {
|
|
809
|
+
throw new Error(`run ${repairRunId} does not share planner ${root.id}'s exact custody and session`);
|
|
810
|
+
}
|
|
811
|
+
let cursor = accepted;
|
|
812
|
+
for (let edge = 1; edge <= 2; edge += 1) {
|
|
813
|
+
if (cursor.parentRun === root.id)
|
|
814
|
+
return accepted;
|
|
815
|
+
const parent = cursor.parentRun === null ? null : store.getRun(cursor.parentRun);
|
|
816
|
+
if (parent === null ||
|
|
817
|
+
parent.role !== "planner" ||
|
|
818
|
+
parent.outcome !== "failed" ||
|
|
819
|
+
!sameCustody(parent)) {
|
|
820
|
+
throw new Error(`run ${repairRunId} is outside planner ${root.id}'s bounded linear correction chain`);
|
|
821
|
+
}
|
|
822
|
+
cursor = parent;
|
|
823
|
+
}
|
|
824
|
+
throw new Error(`run ${repairRunId} is outside planner ${root.id}'s bounded linear correction chain`);
|
|
825
|
+
}
|
|
826
|
+
export function finalizeParkFenced(store, args) {
|
|
827
|
+
const { leaseId, runId, taskId, decision, artifactIds, now } = args;
|
|
828
|
+
const db = store.handle;
|
|
829
|
+
return inTransaction(store, () => {
|
|
830
|
+
const run = store.getRun(runId);
|
|
831
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
832
|
+
// A run that names another lease or is already finished cannot be
|
|
833
|
+
// sealed by this call — that is a caller defect, not a race to absorb.
|
|
834
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a park seals exactly one`);
|
|
835
|
+
}
|
|
836
|
+
const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
|
|
837
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
838
|
+
if (stopped !== null)
|
|
839
|
+
return { ok: false, reason: "stopped" };
|
|
840
|
+
const { changes } = db
|
|
841
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'parked'
|
|
842
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
843
|
+
.run(now.toISOString(), leaseId);
|
|
844
|
+
if (Number(changes) === 0) {
|
|
845
|
+
if (repairRun !== null)
|
|
846
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
|
|
847
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
848
|
+
return refusal(db, leaseId);
|
|
849
|
+
}
|
|
850
|
+
const decisionId = store.saveDecision({
|
|
851
|
+
run: runId,
|
|
852
|
+
urgency: decision.urgency,
|
|
853
|
+
recap: decision.recap,
|
|
854
|
+
question: decision.question,
|
|
855
|
+
options: decision.options,
|
|
856
|
+
recommendation: decision.recommendation,
|
|
857
|
+
...(decision.assignee === null ? {} : { assignee: decision.assignee }),
|
|
858
|
+
...(decision.deadline === null ? {} : { deadline: decision.deadline }),
|
|
859
|
+
}, now);
|
|
860
|
+
for (const artifact of artifactIds)
|
|
861
|
+
store.linkEvidence(decisionId, artifact);
|
|
862
|
+
// The hold is indefinite by construction. The decision's deadline is
|
|
863
|
+
// attention metadata; wiring it into `until` would dispatch the task,
|
|
864
|
+
// unanswered, the moment the deadline passed — expiry never chooses.
|
|
865
|
+
store.holdOwned({
|
|
866
|
+
taskRef: run.taskRef,
|
|
867
|
+
ownerKind: "decision",
|
|
868
|
+
ownerId: String(decisionId),
|
|
869
|
+
reason: `decision:${decisionId} — ${oneLine(decision.question, 80)}`,
|
|
870
|
+
until: null,
|
|
871
|
+
}, now);
|
|
872
|
+
store.finishRun(runId, { outcome: "parked", reason: `decision:${decisionId}`, now });
|
|
873
|
+
if (repairRun !== null) {
|
|
874
|
+
store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
|
|
875
|
+
}
|
|
876
|
+
store.enqueueNotification({
|
|
877
|
+
source: { run: runId },
|
|
878
|
+
dedupeKey: `decision:${decisionId}`,
|
|
879
|
+
kind: "decision",
|
|
880
|
+
subject: `${taskId} parked a decision`,
|
|
881
|
+
body: `${oneLine(decision.question, 200)}\n\`toolroll decide ${decisionId}\``,
|
|
882
|
+
// The push stamp (arc 3): class + machine-minted link, at enqueue
|
|
883
|
+
// or never. Fixed phrases ride the push service; this subject does not.
|
|
884
|
+
pushClass: "decision",
|
|
885
|
+
link: `/d/${decisionId}`,
|
|
886
|
+
}, now);
|
|
887
|
+
return { ok: true, decisionId };
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Seal a park whose payload never became a decision (§6's bounded repair ran
|
|
892
|
+
* out). Same fence, same atomicity, different record: an incident that stays
|
|
893
|
+
* in every brief until a person resolves it, holding the task so the next
|
|
894
|
+
* pass does not spend the same tokens hitting the same wall nightly. The
|
|
895
|
+
* malformed payload is preserved as evidence — a person may still be able to
|
|
896
|
+
* read what the agent meant.
|
|
897
|
+
*/
|
|
898
|
+
export function finalizeMalformedFenced(store, args) {
|
|
899
|
+
const { leaseId, runId, taskId, problems, now } = args;
|
|
900
|
+
const db = store.handle;
|
|
901
|
+
return inTransaction(store, () => {
|
|
902
|
+
const run = store.getRun(runId);
|
|
903
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
904
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a park seals exactly one`);
|
|
905
|
+
}
|
|
906
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
907
|
+
if (stopped !== null)
|
|
908
|
+
return { ok: false, reason: "stopped" };
|
|
909
|
+
const { changes } = db
|
|
910
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'parked'
|
|
911
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
912
|
+
.run(now.toISOString(), leaseId);
|
|
913
|
+
if (Number(changes) === 0) {
|
|
914
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
915
|
+
return refusal(db, leaseId);
|
|
916
|
+
}
|
|
917
|
+
const incidentId = store.createIncident({ run: runId, kind: "malformed-decision" }, now);
|
|
918
|
+
store.holdOwned({
|
|
919
|
+
taskRef: run.taskRef,
|
|
920
|
+
ownerKind: "incident",
|
|
921
|
+
ownerId: String(incidentId),
|
|
922
|
+
reason: `malformed-decision — the agent tried to park ${taskId} and could not say what`,
|
|
923
|
+
until: null,
|
|
924
|
+
}, now);
|
|
925
|
+
store.finishRun(runId, { outcome: "failed", reason: "malformed-decision", now });
|
|
926
|
+
store.enqueueNotification({
|
|
927
|
+
source: { run: runId },
|
|
928
|
+
dedupeKey: `malformed:${runId}`,
|
|
929
|
+
kind: "malformed-decision",
|
|
930
|
+
pushClass: "attention",
|
|
931
|
+
link: `/r/${runId}`,
|
|
932
|
+
subject: `${taskId}: the agent parked but could not say what`,
|
|
933
|
+
body: [
|
|
934
|
+
`Repair ran out. The task is held until somebody looks.`,
|
|
935
|
+
...problems.slice(0, 5).map(problem => `- ${oneLine(problem.message, 120)}`),
|
|
936
|
+
`The raw payload is preserved in run ${runId}'s evidence.`,
|
|
937
|
+
].join("\n"),
|
|
938
|
+
}, now);
|
|
939
|
+
return { ok: true, incidentId };
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
/** 1m, 2m, 4m, 8m, 16m — doubling, capped. Indexed by strikes-1. */
|
|
943
|
+
const BACKOFF_MS = [60_000, 120_000, 240_000, 480_000, 960_000];
|
|
944
|
+
export const MAX_STRIKES = 3;
|
|
945
|
+
/**
|
|
946
|
+
* Seal an operator-stopped attempt (v52): ONE fenced transaction for the
|
|
947
|
+
* claim's release as `interrupted`, the run's ending as `failed` /
|
|
948
|
+
* `interrupted` (the same words dead-runner recovery writes, so the
|
|
949
|
+
* recovered-draft road inherits the preserved work exactly as after a
|
|
950
|
+
* crash), every open run the attempt owns, the task's return to the
|
|
951
|
+
* queue under the stop's own hold, and the stop's settlement. Nothing
|
|
952
|
+
* here is a failure: no strike, no backoff, no incident, no automatic
|
|
953
|
+
* repair task, no notification page. A commit the attempt already made
|
|
954
|
+
* is kept as a reviewable artifact — `committed` records that it exists.
|
|
955
|
+
*
|
|
956
|
+
* A lease the world moved past (reaped, recovered, superseded) still
|
|
957
|
+
* ends the run as interrupted — that IS the honest ending — but touches
|
|
958
|
+
* no task state: the task belongs to whoever holds it now.
|
|
959
|
+
*/
|
|
960
|
+
export function finalizeInterruptedFenced(store, args) {
|
|
961
|
+
const { leaseId, runId, taskId, stopRun, now } = args;
|
|
962
|
+
const db = store.handle;
|
|
963
|
+
return inTransaction(store, () => {
|
|
964
|
+
const run = store.getRun(runId);
|
|
965
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
966
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — an interruption seals exactly one`);
|
|
967
|
+
}
|
|
968
|
+
const { changes } = db
|
|
969
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'interrupted'
|
|
970
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
971
|
+
.run(now.toISOString(), leaseId);
|
|
972
|
+
const fenced = Number(changes) === 0;
|
|
973
|
+
if (args.message !== undefined && args.message.trim() !== "") {
|
|
974
|
+
store.recordOutcomeFacts(runId, { handoff: args.message });
|
|
975
|
+
}
|
|
976
|
+
// Owned descendants first (a repair turn, a correction) — the parent's
|
|
977
|
+
// ending settles the stop, and every owned row ends in the same write.
|
|
978
|
+
for (const owned of store.ownedRunsOf(runId)) {
|
|
979
|
+
if (owned === runId)
|
|
980
|
+
continue;
|
|
981
|
+
const child = store.getRun(owned);
|
|
982
|
+
if (child !== null && child.outcome === null) {
|
|
983
|
+
store.finishRun(owned, { outcome: "failed", reason: "interrupted", now, stopSettlement: "interrupted" });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
store.finishRun(runId, {
|
|
987
|
+
outcome: "failed",
|
|
988
|
+
reason: "interrupted",
|
|
989
|
+
...(args.committed === undefined ? {} : { committed: args.committed }),
|
|
990
|
+
now,
|
|
991
|
+
stopSettlement: "interrupted",
|
|
992
|
+
});
|
|
993
|
+
store.settleRunStop(stopRun, "interrupted", now);
|
|
994
|
+
if (fenced)
|
|
995
|
+
return { ok: true, stopRun, requeued: false, fenced: true };
|
|
996
|
+
// Back to the queue, where the stop's hold keeps it — a paused task
|
|
997
|
+
// reads as queued-and-held, exactly like an operator pause.
|
|
998
|
+
const requeued = db
|
|
999
|
+
.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
|
|
1000
|
+
.run(now.toISOString(), taskId);
|
|
1001
|
+
store.bumpWake();
|
|
1002
|
+
return { ok: true, stopRun, requeued: Number(requeued.changes) > 0, fenced: false };
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* THE STOP FENCE at settlement (v52): when a stop applies to the run —
|
|
1007
|
+
* its own or an owning ancestor's — seal the attempt as interrupted and
|
|
1008
|
+
* answer with the seal; otherwise null, and the caller's own ending
|
|
1009
|
+
* proceeds. Every fenced finalizer asks this first, inside its own
|
|
1010
|
+
* transaction, so a stop that commits before terminal settlement wins
|
|
1011
|
+
* whatever the attempt was about to say.
|
|
1012
|
+
*/
|
|
1013
|
+
export function interruptIfStopped(store, args) {
|
|
1014
|
+
const stop = store.applicableStopFor(args.runId);
|
|
1015
|
+
if (stop === null)
|
|
1016
|
+
return null;
|
|
1017
|
+
const run = store.getRun(args.runId);
|
|
1018
|
+
const where = run?.worktree ?? null;
|
|
1019
|
+
return finalizeInterruptedFenced(store, {
|
|
1020
|
+
...args,
|
|
1021
|
+
stopRun: stop.run,
|
|
1022
|
+
message: args.message ?? `stopped by ${stop.requestedBy} (run #${stop.run}) — ${where === null ? "the attempt's evidence is on record" : `the work is preserved in ${where}`}`,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Seal a failed attempt: one fenced transaction for the release, the run,
|
|
1027
|
+
* the strike, the hold, and the page (§6, gnhf adopted rather than
|
|
1028
|
+
* reinvented).
|
|
1029
|
+
*
|
|
1030
|
+
* Strikes count only top-level builder attempts; built, no-change, and
|
|
1031
|
+
* parked reset them elsewhere; refusals, fenced attempts, and repair
|
|
1032
|
+
* children never reach this function. Under three strikes the task
|
|
1033
|
+
* requeues behind a doubling backoff hold the failure owns; at three it
|
|
1034
|
+
* stalls — an 'attempts-exhausted' incident, the task marked failed, held,
|
|
1035
|
+
* and paged once — because a fourth identical attempt at 3am is a token
|
|
1036
|
+
* bonfire, not persistence. A commit-stage failure takes neither road: the
|
|
1037
|
+
* worktree holds unpreserved work, ordinary re-pooling would refuse it as
|
|
1038
|
+
* dirty forever, so it is an immediate incident naming the checkout.
|
|
1039
|
+
*/
|
|
1040
|
+
export function finalizeFailureFenced(store, args) {
|
|
1041
|
+
const { leaseId, runId, taskId, failureClass, message, now } = args;
|
|
1042
|
+
const db = store.handle;
|
|
1043
|
+
return inTransaction(store, () => {
|
|
1044
|
+
const run = store.getRun(runId);
|
|
1045
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
1046
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
|
|
1047
|
+
}
|
|
1048
|
+
if (run.role !== "builder") {
|
|
1049
|
+
throw new Error(`run ${runId} is a ${run.role} run — only top-level builder attempts take strikes`);
|
|
1050
|
+
}
|
|
1051
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1052
|
+
if (stopped !== null)
|
|
1053
|
+
return { ok: false, reason: "stopped" };
|
|
1054
|
+
const { changes } = db
|
|
1055
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
1056
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1057
|
+
.run(now.toISOString(), leaseId);
|
|
1058
|
+
if (Number(changes) === 0) {
|
|
1059
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1060
|
+
return refusal(db, leaseId);
|
|
1061
|
+
}
|
|
1062
|
+
store.finishRun(runId, { outcome: "failed", reason: failureClass, now });
|
|
1063
|
+
if (failureClass === "commit-failure") {
|
|
1064
|
+
// The work exists and is uncommitted; nothing times its way out of
|
|
1065
|
+
// that. A person (or an explicit repair) proves the checkout.
|
|
1066
|
+
const incidentId = store.createIncident({ run: runId, kind: "commit-failure" }, now);
|
|
1067
|
+
store.holdOwned({
|
|
1068
|
+
taskRef: run.taskRef,
|
|
1069
|
+
ownerKind: "incident",
|
|
1070
|
+
ownerId: String(incidentId),
|
|
1071
|
+
reason: `commit-failure — uncommitted work preserved in ${args.worktree}`,
|
|
1072
|
+
until: null,
|
|
1073
|
+
}, now);
|
|
1074
|
+
store.enqueueNotification({
|
|
1075
|
+
source: { run: runId },
|
|
1076
|
+
dedupeKey: `commit-failure:${runId}`,
|
|
1077
|
+
kind: "commit-failure",
|
|
1078
|
+
pushClass: "attention",
|
|
1079
|
+
link: `/r/${runId}`,
|
|
1080
|
+
subject: `${taskId}: the commit itself failed`,
|
|
1081
|
+
body: `${oneLine(message, 200)}\nThe work is preserved, uncommitted, in ${args.worktree}. Prove it and \`toolroll task requeue ${taskId}\`.`,
|
|
1082
|
+
}, now);
|
|
1083
|
+
return { ok: true, disposition: "commit-incident", incidentId };
|
|
1084
|
+
}
|
|
1085
|
+
const strikes = store.addStrike(run.taskRef);
|
|
1086
|
+
if (strikes >= MAX_STRIKES) {
|
|
1087
|
+
const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
|
|
1088
|
+
store.holdOwned({
|
|
1089
|
+
taskRef: run.taskRef,
|
|
1090
|
+
ownerKind: "incident",
|
|
1091
|
+
ownerId: String(incidentId),
|
|
1092
|
+
reason: `attempts-exhausted — ${strikes} consecutive failures, last: ${failureClass}`,
|
|
1093
|
+
until: null,
|
|
1094
|
+
}, now);
|
|
1095
|
+
db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
|
|
1096
|
+
store.enqueueNotification({
|
|
1097
|
+
source: { run: runId },
|
|
1098
|
+
dedupeKey: `stalled:${run.taskRef}`,
|
|
1099
|
+
kind: "attempts-exhausted",
|
|
1100
|
+
pushClass: "attention",
|
|
1101
|
+
link: `/r/${runId}`,
|
|
1102
|
+
subject: `${taskId} stalled after ${strikes} straight failures`,
|
|
1103
|
+
body: `Last failure (${failureClass}): ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
|
|
1104
|
+
}, now);
|
|
1105
|
+
return { ok: true, disposition: "stalled", strikes, incidentId };
|
|
1106
|
+
}
|
|
1107
|
+
// Under the limit: requeue behind a doubling pause the failure owns.
|
|
1108
|
+
// Replacing the previous backoff hold is correct — this attempt's strike
|
|
1109
|
+
// already reflects the whole streak.
|
|
1110
|
+
const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
|
|
1111
|
+
const until = new Date(now.getTime() + wait);
|
|
1112
|
+
store.holdOwned({
|
|
1113
|
+
taskRef: run.taskRef,
|
|
1114
|
+
ownerKind: "backoff",
|
|
1115
|
+
ownerId: String(run.taskRef),
|
|
1116
|
+
reason: `retry ${strikes}/${MAX_STRIKES} after ${failureClass} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1117
|
+
until,
|
|
1118
|
+
}, now);
|
|
1119
|
+
store.enqueueNotification({
|
|
1120
|
+
source: { run: runId },
|
|
1121
|
+
dedupeKey: `run:${runId}:failed`,
|
|
1122
|
+
kind: "build-failed",
|
|
1123
|
+
subject: `${taskId}: attempt failed (${failureClass}), retry ${strikes}/${MAX_STRIKES}`,
|
|
1124
|
+
body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
|
|
1125
|
+
}, now);
|
|
1126
|
+
return { ok: true, disposition: "backoff", strikes, until: until.toISOString() };
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
/** Untrusted text on its way into a subject line: one line, bounded. */
|
|
1130
|
+
function oneLine(text, cap) {
|
|
1131
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
1132
|
+
return flat.length <= cap ? flat : `${flat.slice(0, cap)}…`;
|
|
1133
|
+
}
|
|
1134
|
+
/** The live claim on a task, if there is one. */
|
|
1135
|
+
export const MAX_PLAN_STRIKES = 3;
|
|
1136
|
+
/** Backoff for planner retries: shorter than the builder's — a planner that
|
|
1137
|
+
* cannot start is usually a transient, and nothing downstream is waiting on
|
|
1138
|
+
* a workspace. */
|
|
1139
|
+
const PLAN_BACKOFF_MS = [60_000, 2 * 60_000, 4 * 60_000];
|
|
1140
|
+
/**
|
|
1141
|
+
* Seal a successful planning run: the proposed scope, the plan document
|
|
1142
|
+
* artifact, the drafted state, the run outcome, and the page to the
|
|
1143
|
+
* operator exist together or — if the lease was superseded — not at all.
|
|
1144
|
+
* The scope proposal is authority-bearing (it is what the approve card
|
|
1145
|
+
* will restate), which is why this is the park discipline, not the DONE
|
|
1146
|
+
* one (Codex planning review, question 2).
|
|
1147
|
+
*/
|
|
1148
|
+
export function finalizePlanFenced(store, args) {
|
|
1149
|
+
const { leaseId, runId, taskId, plan, now } = args;
|
|
1150
|
+
const db = store.handle;
|
|
1151
|
+
// The ingestion record (contract handoff, task 1) is composed here from
|
|
1152
|
+
// the filed terms and the drafted plan; its file and row land inside the
|
|
1153
|
+
// transaction below, only after the stale-source check admits the draft.
|
|
1154
|
+
const filed = args.source?.contract.scope ?? null;
|
|
1155
|
+
const contractChanges = filed === null ? [] : contractChangesOf(filed, plan);
|
|
1156
|
+
// A hand-built plan (the pre-source road) carries no amendment field.
|
|
1157
|
+
const amendment = plan.amendment ?? null;
|
|
1158
|
+
const record = args.source === undefined
|
|
1159
|
+
? null
|
|
1160
|
+
: {
|
|
1161
|
+
version: 1,
|
|
1162
|
+
sourceDigest: args.source.sourceDigest,
|
|
1163
|
+
sourceArtifact: args.sourceArtifact ?? null,
|
|
1164
|
+
filed: filed === null ? null : { goal: filed.goal, outOfScope: filed.outOfScope, touches: filed.touches, acceptance: filed.acceptance },
|
|
1165
|
+
proposed: { goal: plan.goal, outOfScope: plan.outOfScope, touches: plan.touches, acceptance: plan.acceptance },
|
|
1166
|
+
amendment,
|
|
1167
|
+
changes: contractChanges,
|
|
1168
|
+
};
|
|
1169
|
+
return inTransaction(store, () => {
|
|
1170
|
+
const run = store.getRun(runId);
|
|
1171
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
1172
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a plan seals exactly one`);
|
|
1173
|
+
}
|
|
1174
|
+
if (run.role !== "planner") {
|
|
1175
|
+
throw new Error(`run ${runId} is a ${run.role} run — only planner runs draft plans`);
|
|
1176
|
+
}
|
|
1177
|
+
const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
|
|
1178
|
+
if (store.lookupRef(taskId)?.id !== run.taskRef)
|
|
1179
|
+
throw new Error("a planner can finalize only its own task");
|
|
1180
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1181
|
+
if (stopped !== null)
|
|
1182
|
+
return { ok: false, reason: "stopped" };
|
|
1183
|
+
const invalidSource = (detail, kind = "malformed") => {
|
|
1184
|
+
const failed = finalizePlanFailureFenced(store, { leaseId, runId, taskId, kind, message: detail, now });
|
|
1185
|
+
if (repairRun !== null)
|
|
1186
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: failed.ok ? "source-invalid" : "fenced", now });
|
|
1187
|
+
return failed.ok ? { ok: false, reason: "source-invalid", detail } : failed;
|
|
1188
|
+
};
|
|
1189
|
+
const sources = store.artifactsFor(runId).filter(one => one.kind === "plan-contract" && one.key.endsWith("/planner-source.json"));
|
|
1190
|
+
// Legacy rows have no input stamp or artifact. Every current dispatch
|
|
1191
|
+
// stamps even an empty scope; dropping a caller argument cannot bypass custody.
|
|
1192
|
+
if (args.source !== undefined || sources.length > 0 || run.scopeDigest !== null) {
|
|
1193
|
+
if (args.source === undefined || args.evidenceRoot === undefined || sources.length !== 1 || sources[0].id !== args.sourceArtifact) {
|
|
1194
|
+
return invalidSource("the planner's admitted source inventory is missing, ambiguous, or was not presented");
|
|
1195
|
+
}
|
|
1196
|
+
const artifact = sources[0];
|
|
1197
|
+
let verified;
|
|
1198
|
+
try {
|
|
1199
|
+
verified = readVerifiedArtifact(args.evidenceRoot, artifact);
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
return invalidSource("the planner source file cannot be read");
|
|
1203
|
+
}
|
|
1204
|
+
if (!verified.ok || artifact.truncated)
|
|
1205
|
+
return invalidSource("the planner source bytes no longer verify");
|
|
1206
|
+
const recorded = decodePlannerSource(verified.content);
|
|
1207
|
+
if (recorded === null || recorded.taskId !== taskId || !verified.content.equals(encodePlannerSource(args.source)) || run.scopeDigest !== (recorded.contract.scope?.digest ?? "")) {
|
|
1208
|
+
return invalidSource("the supplied planning source is not the source recorded for this run");
|
|
1209
|
+
}
|
|
1210
|
+
if (contractChanges.length > 0 && (typeof amendment !== "string" || !amendment.trim() || amendment.length > PLANNER_SOURCE_LIMITS.amendment)) {
|
|
1211
|
+
return invalidSource("a planner changed the filed contract without a bounded, explicit amendment");
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// THE SOURCE RECHECK (contract handoff, task 1): the filed request is
|
|
1215
|
+
// re-derived from durable state inside this very transaction and must
|
|
1216
|
+
// still be the one the planner read. A scope rewritten, approved, or
|
|
1217
|
+
// re-termed while the planner ran is the NEWER source; an old draft
|
|
1218
|
+
// never overwrites it. The attempt ends refused in words — no strike,
|
|
1219
|
+
// the planner did nothing wrong — and the task stays requested. The
|
|
1220
|
+
// claim releases under the same fence the ingestion would have used.
|
|
1221
|
+
if (args.source !== undefined) {
|
|
1222
|
+
const current = plannerContractOf(store, taskId);
|
|
1223
|
+
const currentDigest = current.ok ? plannerSourceDigest(current.contract) : null;
|
|
1224
|
+
const currentAnswers = store.answeredDecisionsFor(taskId, PLANNER_SOURCE_LIMITS.answers + 1).map(one => ({ question: one.question, choice: one.choice ?? "", note: one.note }));
|
|
1225
|
+
if (currentDigest !== args.source.sourceDigest || canonicalContractJson(currentAnswers) !== canonicalContractJson(args.source.answers)) {
|
|
1226
|
+
const detail = current.ok
|
|
1227
|
+
? describeSourceDrift(args.source, current.contract)
|
|
1228
|
+
: `the filed request can no longer be read: ${current.message}`;
|
|
1229
|
+
const released = db
|
|
1230
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
1231
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1232
|
+
.run(now.toISOString(), leaseId);
|
|
1233
|
+
if (Number(released.changes) === 0) {
|
|
1234
|
+
if (repairRun !== null)
|
|
1235
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
|
|
1236
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1237
|
+
return refusal(db, leaseId);
|
|
1238
|
+
}
|
|
1239
|
+
if (repairRun !== null)
|
|
1240
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: "stale-source", now });
|
|
1241
|
+
store.finishRun(runId, { outcome: "refused", reason: "stale-source", now });
|
|
1242
|
+
store.enqueueNotification({
|
|
1243
|
+
source: { run: runId },
|
|
1244
|
+
dedupeKey: `plan-stale-source:${run.taskRef}:${runId}`,
|
|
1245
|
+
kind: "plan-stale-source",
|
|
1246
|
+
link: `/t/${encodeURIComponent(taskId)}`,
|
|
1247
|
+
subject: `${taskId}: the filed request changed while the planner ran`,
|
|
1248
|
+
body: `${oneLine(detail, 300)}\nNothing from that draft was ingested; the current terms stand and the next pass plans against them.`,
|
|
1249
|
+
}, now);
|
|
1250
|
+
return { ok: false, reason: "stale-source", detail };
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (record !== null && args.evidenceRoot !== undefined) {
|
|
1254
|
+
const recordBytes = encodePlanContractRecord(record);
|
|
1255
|
+
if (recordBytes.length > EVIDENCE_CAPS["plan-contract"])
|
|
1256
|
+
return invalidSource("the required plan amendment record exceeds its explicit evidence cap");
|
|
1257
|
+
try {
|
|
1258
|
+
const recordId = storeEvidence(store, args.evidenceRoot, runId, "plan-contract", "plan-contract.json", recordBytes, record.changes.length === 0
|
|
1259
|
+
? "plan ingestion: filed contract reproduced exactly (verified tree)"
|
|
1260
|
+
: `plan ingestion: ${record.changes.length} contract change(s), amendment ${record.amendment === null ? "absent" : "stated"} (verified tree)`, now, { captureStatus: "ok" });
|
|
1261
|
+
const captured = store.getArtifact(recordId);
|
|
1262
|
+
if (captured === null || captured.truncated || !readVerifiedArtifact(args.evidenceRoot, captured).ok) {
|
|
1263
|
+
return invalidSource("the required plan contract record did not seal completely", "failure");
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
return invalidSource("the required plan contract record could not be saved", "failure");
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const { changes } = db
|
|
1271
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
|
|
1272
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1273
|
+
.run(now.toISOString(), leaseId);
|
|
1274
|
+
if (Number(changes) === 0) {
|
|
1275
|
+
if (repairRun !== null)
|
|
1276
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
|
|
1277
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1278
|
+
return refusal(db, leaseId);
|
|
1279
|
+
}
|
|
1280
|
+
store.saveScope({
|
|
1281
|
+
taskId,
|
|
1282
|
+
goal: plan.goal,
|
|
1283
|
+
outOfScope: plan.outOfScope,
|
|
1284
|
+
touches: plan.touches,
|
|
1285
|
+
acceptance: plan.acceptance,
|
|
1286
|
+
proposedAt: now.toISOString(),
|
|
1287
|
+
digest: digestOf({ goal: plan.goal, outOfScope: plan.outOfScope, touches: plan.touches, acceptance: plan.acceptance }),
|
|
1288
|
+
budgetMicrousd: filed?.terms.budgetMicrousd ?? null,
|
|
1289
|
+
approvedAt: null,
|
|
1290
|
+
approvedBy: null,
|
|
1291
|
+
approvedDigest: null,
|
|
1292
|
+
}, {}, filed === null ? {} : {
|
|
1293
|
+
proposedVia: filed.proposedVia,
|
|
1294
|
+
riskLevel: filed.terms.riskLevel,
|
|
1295
|
+
qualityMode: filed.terms.qualityMode,
|
|
1296
|
+
...(filed.terms.profile == null ? {} : { profile: filed.terms.profile }),
|
|
1297
|
+
...(filed.terms.profile != null && routeFromJson(filed.terms.routeJson) !== null &&
|
|
1298
|
+
canonicalContractJson([...new Set(filed.acceptance.flatMap(one => one.evidence))].sort()) === canonicalContractJson([...new Set(plan.acceptance.flatMap(one => one.evidence))].sort())
|
|
1299
|
+
? { route: routeFromJson(filed.terms.routeJson) } : {}),
|
|
1300
|
+
});
|
|
1301
|
+
let verifiedPlan = false;
|
|
1302
|
+
if (args.artifact !== null) {
|
|
1303
|
+
const artifactId = store.saveArtifact({ run: runId, kind: "plan", ...args.artifact }, now);
|
|
1304
|
+
if (args.evidenceRoot !== undefined && !args.artifact.truncated) {
|
|
1305
|
+
try {
|
|
1306
|
+
const captured = store.getArtifact(artifactId);
|
|
1307
|
+
const verified = captured === null ? null : readVerifiedArtifact(args.evidenceRoot, captured);
|
|
1308
|
+
verifiedPlan = verified?.ok === true && verified.content.equals(Buffer.from(plan.plan, "utf8")) && parseExecutionPlanDocument(plan.plan).ok;
|
|
1309
|
+
}
|
|
1310
|
+
catch {
|
|
1311
|
+
verifiedPlan = false;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
store.setPlanState(run.taskRef, "drafted");
|
|
1316
|
+
const autoApproved = sealUnchangedPlanUnderMode(store, taskId, args.source?.sourceDigest ?? "", runId, args.source !== undefined && filed !== null && contractChanges.length === 0 && amendment === null && verifiedPlan, now);
|
|
1317
|
+
store.resetPlanStrikes(run.taskRef);
|
|
1318
|
+
store.finishRun(runId, { outcome: "built", reason: "plan-drafted", now });
|
|
1319
|
+
if (repairRun !== null) {
|
|
1320
|
+
store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
|
|
1321
|
+
}
|
|
1322
|
+
store.enqueueNotification({
|
|
1323
|
+
source: { run: runId },
|
|
1324
|
+
dedupeKey: `plan:${run.taskRef}:${runId}`,
|
|
1325
|
+
kind: "plan-ready",
|
|
1326
|
+
subject: autoApproved ? `${taskId}: unchanged plan auto-approved` : `${taskId}: plan ready for review${contractChanges.length === 0 ? "" : ` — ${contractChanges.length} contract change${contractChanges.length === 1 ? "" : "s"} to check`}`,
|
|
1327
|
+
body: `The planner proposes: ${oneLine(plan.goal, 200)}\n` +
|
|
1328
|
+
(contractChanges.length === 0
|
|
1329
|
+
? filed === null
|
|
1330
|
+
? ""
|
|
1331
|
+
: "The filed goal, exclusions, touches, and acceptance criteria are reproduced exactly.\n"
|
|
1332
|
+
: `It AMENDS the filed contract (${oneLine(describeContractChanges(contractChanges).join("; "), 300)})${amendment === null ? "" : ` — because: ${oneLine(amendment, 200)}`}\n`) +
|
|
1333
|
+
(autoApproved ? "Approved under the signed operating mode. The next build may proceed with the original terms and required quality checks." : "Review, edit, and approve the scope — nothing builds until you do."),
|
|
1334
|
+
}, now);
|
|
1335
|
+
return { ok: true, changes: contractChanges.length, amendment };
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
/** Which part of the filed request moved, in words — for the refusal,
|
|
1339
|
+
* the page, and the notification. */
|
|
1340
|
+
function describeSourceDrift(source, current) {
|
|
1341
|
+
const was = source.contract;
|
|
1342
|
+
const parts = [];
|
|
1343
|
+
if ((was.scope?.digest ?? null) !== (current.scope?.digest ?? null)) {
|
|
1344
|
+
parts.push(was.scope === null
|
|
1345
|
+
? "a scope was filed after planning started"
|
|
1346
|
+
: current.scope === null
|
|
1347
|
+
? "the filed scope was removed"
|
|
1348
|
+
: "the filed scope was rewritten");
|
|
1349
|
+
}
|
|
1350
|
+
else if (was.scope !== null && current.scope !== null && was.scope.approval.approvedDigest !== current.scope.approval.approvedDigest) {
|
|
1351
|
+
parts.push("the scope's approval changed");
|
|
1352
|
+
}
|
|
1353
|
+
else if (was.scope !== null && current.scope !== null && JSON.stringify(was.scope.terms) !== JSON.stringify(current.scope.terms)) {
|
|
1354
|
+
parts.push("the scope's execution terms changed");
|
|
1355
|
+
}
|
|
1356
|
+
if (JSON.stringify(was.task) !== JSON.stringify(current.task))
|
|
1357
|
+
parts.push("the task's terms changed");
|
|
1358
|
+
if (JSON.stringify(was.revision) !== JSON.stringify(current.revision))
|
|
1359
|
+
parts.push("the revision brief changed");
|
|
1360
|
+
return `the filed request changed while the planner ran: ${parts.length === 0 ? "its source identity moved" : parts.join("; ")} (planned against ${source.sourceDigest.slice(0, 12)})`;
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Seal a running build's plan-revision proposal (adaptive execution plans).
|
|
1364
|
+
*
|
|
1365
|
+
* A builder that finds repository evidence invalidating a named dependency,
|
|
1366
|
+
* risk, or assumption in the plan it was given files ONE proposal and stops
|
|
1367
|
+
* without committing — the park discipline, applied to the road rather than
|
|
1368
|
+
* to a question. The ledger row, the hold when one is owed, the run's
|
|
1369
|
+
* outcome, and the page exist together or, if the lease was superseded,
|
|
1370
|
+
* not at all. Structurally `finalizePlanFenced`'s twin: the same
|
|
1371
|
+
* open-attempt assertion, the same fenced release, the same all-or-nothing
|
|
1372
|
+
* transaction.
|
|
1373
|
+
*
|
|
1374
|
+
* Two dispositions, decided by the caller's classification and recorded
|
|
1375
|
+
* here:
|
|
1376
|
+
*
|
|
1377
|
+
* plan-only the signed scope and publication authority are
|
|
1378
|
+
* byte-identical to what this build started under, so
|
|
1379
|
+
* nothing authority-bearing moved and the revision is
|
|
1380
|
+
* APPLIED. The claim releases plainly, the task returns
|
|
1381
|
+
* to the ready set, and the next attempt reads the new
|
|
1382
|
+
* plan — that is the "resume" the subject line promises.
|
|
1383
|
+
*
|
|
1384
|
+
* authority-change something else moved the signed scope or the
|
|
1385
|
+
* publication authority WHILE this build ran. Whatever
|
|
1386
|
+
* the revision proposes, it is never auto-applied: the
|
|
1387
|
+
* row lands 'blocked', a `revision` hold keeps the task
|
|
1388
|
+
* out of every ready set until a person accepts or
|
|
1389
|
+
* rejects it, and the page names exactly which fields
|
|
1390
|
+
* changed.
|
|
1391
|
+
*
|
|
1392
|
+
* The lease is released either way — the attempt is over — and the run is
|
|
1393
|
+
* finished as `refused`, reusing the existing outcome vocabulary rather
|
|
1394
|
+
* than growing it: no work was committed, and nothing failed.
|
|
1395
|
+
*/
|
|
1396
|
+
export function finalizeRevisionFenced(store, args) {
|
|
1397
|
+
const { leaseId, runId, taskId, taskRef, revision, now } = args;
|
|
1398
|
+
const db = store.handle;
|
|
1399
|
+
return inTransaction(store, () => {
|
|
1400
|
+
const run = store.getRun(runId);
|
|
1401
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
1402
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a plan revision seals exactly one`);
|
|
1403
|
+
}
|
|
1404
|
+
if (run.role !== "builder") {
|
|
1405
|
+
throw new Error(`run ${runId} is a ${run.role} run — only builder runs file plan revisions`);
|
|
1406
|
+
}
|
|
1407
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1408
|
+
if (stopped !== null)
|
|
1409
|
+
return { ok: false, reason: "stopped" };
|
|
1410
|
+
const { changes } = db
|
|
1411
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
1412
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1413
|
+
.run(now.toISOString(), leaseId);
|
|
1414
|
+
if (Number(changes) === 0) {
|
|
1415
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1416
|
+
return refusal(db, leaseId);
|
|
1417
|
+
}
|
|
1418
|
+
const applied = revision.authorityKind === "plan-only";
|
|
1419
|
+
const revisionId = store.insertPlanRevision({ taskRef, ...revision, kind: "builder-proposal", status: applied ? "applied" : "blocked" }, now);
|
|
1420
|
+
const moved = revision.changedFields
|
|
1421
|
+
.map(field => (field === "signed-scope" ? "the signed scope" : "publication authority"))
|
|
1422
|
+
.join(" and ");
|
|
1423
|
+
if (!applied) {
|
|
1424
|
+
store.holdOwned({
|
|
1425
|
+
taskRef,
|
|
1426
|
+
ownerKind: "revision",
|
|
1427
|
+
ownerId: String(revisionId),
|
|
1428
|
+
reason: `plan-revision-blocked — ${moved} changed while this build ran, so revision ${revision.revision} waits for a person`,
|
|
1429
|
+
until: null,
|
|
1430
|
+
}, now);
|
|
1431
|
+
}
|
|
1432
|
+
store.finishRun(runId, { outcome: "refused", reason: applied ? "plan-revised" : "plan-revision-blocked", now });
|
|
1433
|
+
store.enqueueNotification({
|
|
1434
|
+
source: { run: runId },
|
|
1435
|
+
dedupeKey: `plan-revision:${taskRef}:${revisionId}`,
|
|
1436
|
+
kind: applied ? "plan-revised" : "plan-revision-blocked",
|
|
1437
|
+
...(applied ? {} : { pushClass: "attention" }),
|
|
1438
|
+
link: `/t/${encodeURIComponent(taskId)}`,
|
|
1439
|
+
subject: `${taskId}: plan revision ${revision.revision} ${applied ? "applied — resuming" : "awaiting your approval"}`,
|
|
1440
|
+
body: applied
|
|
1441
|
+
? `The build found the plan wrong and rewrote it: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing was committed. The next attempt builds against the new plan.`
|
|
1442
|
+
: `The build proposed a new plan, but ${moved} changed while it ran — so nothing was applied: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing runs on this task until you accept or reject the revision.`,
|
|
1443
|
+
}, now);
|
|
1444
|
+
return { ok: true, revisionId, authorityKind: revision.authorityKind };
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* The planner's own fenced failure finalizer (Codex planning review,
|
|
1449
|
+
* finding 6): releases exactly the planner claim, finishes the run,
|
|
1450
|
+
* counts a SEPARATE planning strike — a planner that cannot finish must
|
|
1451
|
+
* never spend the builder's three attempts — and eventually leaves a
|
|
1452
|
+
* durable incident + hold + page. It never marks the task done, never
|
|
1453
|
+
* touches builder strikes, never commits, never publishes.
|
|
1454
|
+
*
|
|
1455
|
+
* A malformed payload goes straight to its incident with no strike: the
|
|
1456
|
+
* protocol failed, not the weather, and retrying the same session buys
|
|
1457
|
+
* nothing without the repair machinery (deliberately not wired for
|
|
1458
|
+
* planners in v1).
|
|
1459
|
+
*/
|
|
1460
|
+
export function finalizePlanFailureFenced(store, args) {
|
|
1461
|
+
const { leaseId, runId, taskId, kind, message, now } = args;
|
|
1462
|
+
const db = store.handle;
|
|
1463
|
+
return inTransaction(store, () => {
|
|
1464
|
+
const run = store.getRun(runId);
|
|
1465
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
1466
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
|
|
1467
|
+
}
|
|
1468
|
+
if (run.role !== "planner") {
|
|
1469
|
+
throw new Error(`run ${runId} is a ${run.role} run — this finalizer seals planner attempts only`);
|
|
1470
|
+
}
|
|
1471
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1472
|
+
if (stopped !== null)
|
|
1473
|
+
return { ok: false, reason: "stopped" };
|
|
1474
|
+
const { changes } = db
|
|
1475
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
1476
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1477
|
+
.run(now.toISOString(), leaseId);
|
|
1478
|
+
if (Number(changes) === 0) {
|
|
1479
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1480
|
+
return refusal(db, leaseId);
|
|
1481
|
+
}
|
|
1482
|
+
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-plan";
|
|
1483
|
+
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1484
|
+
if (kind === "malformed") {
|
|
1485
|
+
const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
|
|
1486
|
+
store.holdOwned({
|
|
1487
|
+
taskRef: run.taskRef,
|
|
1488
|
+
ownerKind: "incident",
|
|
1489
|
+
ownerId: String(incidentId),
|
|
1490
|
+
reason: `${malformedKind} — the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
|
|
1491
|
+
until: null,
|
|
1492
|
+
}, now);
|
|
1493
|
+
store.enqueueNotification({
|
|
1494
|
+
source: { run: runId },
|
|
1495
|
+
dedupeKey: `${malformedKind}:${runId}`,
|
|
1496
|
+
kind: malformedKind,
|
|
1497
|
+
pushClass: "attention",
|
|
1498
|
+
link: `/r/${runId}`,
|
|
1499
|
+
subject: `${taskId}: the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
|
|
1500
|
+
body: `${oneLine(message, 300)}\nResolve the incident to let planning retry.`,
|
|
1501
|
+
}, now);
|
|
1502
|
+
return { ok: true, disposition: "malformed-incident", incidentId };
|
|
1503
|
+
}
|
|
1504
|
+
const strikes = store.addPlanStrike(run.taskRef);
|
|
1505
|
+
if (strikes >= MAX_PLAN_STRIKES) {
|
|
1506
|
+
const incidentId = store.createIncident({ run: runId, kind: "plan-attempts-exhausted" }, now);
|
|
1507
|
+
store.holdOwned({
|
|
1508
|
+
taskRef: run.taskRef,
|
|
1509
|
+
ownerKind: "incident",
|
|
1510
|
+
ownerId: String(incidentId),
|
|
1511
|
+
reason: `plan-attempts-exhausted — ${strikes} straight planning failures`,
|
|
1512
|
+
until: null,
|
|
1513
|
+
}, now);
|
|
1514
|
+
store.enqueueNotification({
|
|
1515
|
+
source: { run: runId },
|
|
1516
|
+
dedupeKey: `plan-stalled:${run.taskRef}`,
|
|
1517
|
+
kind: "plan-attempts-exhausted",
|
|
1518
|
+
pushClass: "attention",
|
|
1519
|
+
link: `/r/${runId}`,
|
|
1520
|
+
subject: `${taskId}: planning stalled after ${strikes} straight failures`,
|
|
1521
|
+
body: `Last failure: ${oneLine(message, 200)}\nResolve the incident to let planning retry, or write the scope yourself.`,
|
|
1522
|
+
}, now);
|
|
1523
|
+
return { ok: true, disposition: "exhausted", incidentId, strikes };
|
|
1524
|
+
}
|
|
1525
|
+
const wait = PLAN_BACKOFF_MS[Math.min(strikes, PLAN_BACKOFF_MS.length) - 1];
|
|
1526
|
+
const until = new Date(now.getTime() + wait);
|
|
1527
|
+
store.holdOwned({
|
|
1528
|
+
taskRef: run.taskRef,
|
|
1529
|
+
// Its own owner id: a planning pause must never displace the
|
|
1530
|
+
// builder's backoff for the same task, or vice versa.
|
|
1531
|
+
ownerKind: "backoff",
|
|
1532
|
+
ownerId: `plan:${run.taskRef}`,
|
|
1533
|
+
reason: `plan retry ${strikes}/${MAX_PLAN_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1534
|
+
until,
|
|
1535
|
+
}, now);
|
|
1536
|
+
store.enqueueNotification({
|
|
1537
|
+
source: { run: runId },
|
|
1538
|
+
dedupeKey: `plan-run:${runId}:failed`,
|
|
1539
|
+
kind: "plan-failed",
|
|
1540
|
+
subject: `${taskId}: planning attempt failed, retry ${strikes}/${MAX_PLAN_STRIKES}`,
|
|
1541
|
+
body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
|
|
1542
|
+
}, now);
|
|
1543
|
+
return { ok: true, disposition: "backoff", strikes };
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Seal a successful scouting run (mate arc §10): the report artifact, the
|
|
1548
|
+
* run outcome, the task's terminal state, and the page to the operator
|
|
1549
|
+
* exist together or — if the lease was superseded — not at all. Nothing
|
|
1550
|
+
* here touches a branch, a publication, or a scope: the deliverable is
|
|
1551
|
+
* the artifact, and the task is done the moment it verifies.
|
|
1552
|
+
*/
|
|
1553
|
+
export function finalizeScoutFenced(store, args) {
|
|
1554
|
+
const { leaseId, runId, taskId, report, now } = args;
|
|
1555
|
+
const db = store.handle;
|
|
1556
|
+
return inTransaction(store, () => {
|
|
1557
|
+
const run = store.getRun(runId);
|
|
1558
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
1559
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a report seals exactly one`);
|
|
1560
|
+
}
|
|
1561
|
+
if (run.role !== "scout") {
|
|
1562
|
+
throw new Error(`run ${runId} is a ${run.role} run — only scout runs deliver reports`);
|
|
1563
|
+
}
|
|
1564
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1565
|
+
if (stopped !== null)
|
|
1566
|
+
return { ok: false, reason: "stopped" };
|
|
1567
|
+
const { changes } = db
|
|
1568
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
|
|
1569
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1570
|
+
.run(now.toISOString(), leaseId);
|
|
1571
|
+
if (Number(changes) === 0) {
|
|
1572
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1573
|
+
return refusal(db, leaseId);
|
|
1574
|
+
}
|
|
1575
|
+
store.saveArtifact({ run: runId, kind: "report", ...args.artifact }, now);
|
|
1576
|
+
store.resetStrikes(run.taskRef);
|
|
1577
|
+
store.finishRun(runId, { outcome: "built", reason: "report-delivered", now });
|
|
1578
|
+
const done = store.setTaskState(taskId, "done", now);
|
|
1579
|
+
if (!done.ok) {
|
|
1580
|
+
// A mirror the tracker closed meanwhile: the report stands as evidence
|
|
1581
|
+
// on the run; the task keeps the tracker's word for its state.
|
|
1582
|
+
store.finishRun(runId, { outcome: "built", reason: `report-delivered (task ${done.reason})`, now });
|
|
1583
|
+
}
|
|
1584
|
+
store.enqueueNotification({
|
|
1585
|
+
source: { run: runId },
|
|
1586
|
+
dedupeKey: `report:${run.taskRef}:${runId}`,
|
|
1587
|
+
kind: "report-ready",
|
|
1588
|
+
subject: `${taskId}: report ready — ${oneLine(report.title, 120)}`,
|
|
1589
|
+
body: `${oneLine(report.summary, 300)}\nRead it on the task page${report.followUps.length > 0 ? `; ${report.followUps.length} follow-up(s) file with a tap` : ""}.`,
|
|
1590
|
+
}, now);
|
|
1591
|
+
return { ok: true };
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* The scout's fenced failure finalizer: the BUILDER's discipline, not the
|
|
1596
|
+
* planner's — a scout task has no builder attempts, so its strikes are the
|
|
1597
|
+
* task's own (three stall it, `task requeue` clears them), and a malformed
|
|
1598
|
+
* report is a straight incident with no strike. Never a branch, never a
|
|
1599
|
+
* repair session, never a commit.
|
|
1600
|
+
*/
|
|
1601
|
+
export function finalizeScoutFailureFenced(store, args) {
|
|
1602
|
+
const { leaseId, runId, taskId, kind, message, now } = args;
|
|
1603
|
+
const db = store.handle;
|
|
1604
|
+
return inTransaction(store, () => {
|
|
1605
|
+
const run = store.getRun(runId);
|
|
1606
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
|
|
1607
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
|
|
1608
|
+
}
|
|
1609
|
+
if (run.role !== "scout") {
|
|
1610
|
+
throw new Error(`run ${runId} is a ${run.role} run — this finalizer seals scout attempts only`);
|
|
1611
|
+
}
|
|
1612
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1613
|
+
if (stopped !== null)
|
|
1614
|
+
return { ok: false, reason: "stopped" };
|
|
1615
|
+
const { changes } = db
|
|
1616
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
1617
|
+
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1618
|
+
.run(now.toISOString(), leaseId);
|
|
1619
|
+
if (Number(changes) === 0) {
|
|
1620
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1621
|
+
return refusal(db, leaseId);
|
|
1622
|
+
}
|
|
1623
|
+
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-report";
|
|
1624
|
+
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1625
|
+
if (kind === "malformed") {
|
|
1626
|
+
const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
|
|
1627
|
+
store.holdOwned({
|
|
1628
|
+
taskRef: run.taskRef,
|
|
1629
|
+
ownerKind: "incident",
|
|
1630
|
+
ownerId: String(incidentId),
|
|
1631
|
+
reason: `${malformedKind} — the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
|
|
1632
|
+
until: null,
|
|
1633
|
+
}, now);
|
|
1634
|
+
store.enqueueNotification({
|
|
1635
|
+
source: { run: runId },
|
|
1636
|
+
dedupeKey: `${malformedKind}:${runId}`,
|
|
1637
|
+
kind: malformedKind,
|
|
1638
|
+
pushClass: "attention",
|
|
1639
|
+
link: `/r/${runId}`,
|
|
1640
|
+
subject: `${taskId}: the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
|
|
1641
|
+
body: `${oneLine(message, 300)}\nResolve the incident to let scouting retry.`,
|
|
1642
|
+
}, now);
|
|
1643
|
+
return { ok: true, disposition: "malformed-incident", incidentId };
|
|
1644
|
+
}
|
|
1645
|
+
const strikes = store.addStrike(run.taskRef);
|
|
1646
|
+
if (strikes >= MAX_STRIKES) {
|
|
1647
|
+
const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
|
|
1648
|
+
store.holdOwned({
|
|
1649
|
+
taskRef: run.taskRef,
|
|
1650
|
+
ownerKind: "incident",
|
|
1651
|
+
ownerId: String(incidentId),
|
|
1652
|
+
reason: `attempts-exhausted — ${strikes} consecutive scouting failures`,
|
|
1653
|
+
until: null,
|
|
1654
|
+
}, now);
|
|
1655
|
+
db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
|
|
1656
|
+
store.enqueueNotification({
|
|
1657
|
+
source: { run: runId },
|
|
1658
|
+
dedupeKey: `stalled:${run.taskRef}`,
|
|
1659
|
+
kind: "attempts-exhausted",
|
|
1660
|
+
pushClass: "attention",
|
|
1661
|
+
link: `/r/${runId}`,
|
|
1662
|
+
subject: `${taskId} stalled after ${strikes} straight scouting failures`,
|
|
1663
|
+
body: `Last failure: ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
|
|
1664
|
+
}, now);
|
|
1665
|
+
return { ok: true, disposition: "stalled", incidentId, strikes };
|
|
1666
|
+
}
|
|
1667
|
+
const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
|
|
1668
|
+
const until = new Date(now.getTime() + wait);
|
|
1669
|
+
store.holdOwned({
|
|
1670
|
+
taskRef: run.taskRef,
|
|
1671
|
+
ownerKind: "backoff",
|
|
1672
|
+
ownerId: String(run.taskRef),
|
|
1673
|
+
reason: `scout retry ${strikes}/${MAX_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1674
|
+
until,
|
|
1675
|
+
}, now);
|
|
1676
|
+
store.enqueueNotification({
|
|
1677
|
+
source: { run: runId },
|
|
1678
|
+
dedupeKey: `run:${runId}:failed`,
|
|
1679
|
+
kind: "scout-failed",
|
|
1680
|
+
subject: `${taskId}: scouting attempt failed, retry ${strikes}/${MAX_STRIKES}`,
|
|
1681
|
+
body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
|
|
1682
|
+
}, now);
|
|
1683
|
+
return { ok: true, disposition: "backoff", strikes };
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
export function currentClaim(store, taskRef, now) {
|
|
1687
|
+
const row = latest(store.handle, taskRef);
|
|
1688
|
+
if (row === undefined || !isLive(row, now.toISOString()))
|
|
1689
|
+
return null;
|
|
1690
|
+
return readClaim(row);
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Release every lease that has run out.
|
|
1694
|
+
*
|
|
1695
|
+
* Expiry alone already frees a task for acquisition, so this is not what makes
|
|
1696
|
+
* reclaim work — it is what makes it *visible*. A daemon reaping on a tick
|
|
1697
|
+
* turns "this lease is being ignored because its timestamp is in the past" into
|
|
1698
|
+
* a released row somebody can read, which is the difference between a system
|
|
1699
|
+
* that recovers and a system that appears to have lost the work.
|
|
1700
|
+
*/
|
|
1701
|
+
export function reap(store, now) {
|
|
1702
|
+
const stamp = now.toISOString();
|
|
1703
|
+
const db = store.handle;
|
|
1704
|
+
// One transaction, so the list returned is exactly the list released — a
|
|
1705
|
+
// lease expiring between the read and the write belongs to the next reap.
|
|
1706
|
+
return inTransaction(store, () => {
|
|
1707
|
+
const expired = db
|
|
1708
|
+
.prepare("SELECT * FROM claim WHERE released_at IS NULL AND expires_at <= ?")
|
|
1709
|
+
.all(stamp);
|
|
1710
|
+
if (expired.length === 0)
|
|
1711
|
+
return [];
|
|
1712
|
+
db.prepare("UPDATE claim SET released_at = ?, released_by = 'reaped' WHERE released_at IS NULL AND expires_at <= ?").run(stamp, stamp);
|
|
1713
|
+
return expired.map(readClaim);
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
function isLive(row, stamp) {
|
|
1717
|
+
return row["released_at"] === null && String(row["expires_at"]) > stamp;
|
|
1718
|
+
}
|
|
1719
|
+
function readClaim(row) {
|
|
1720
|
+
return {
|
|
1721
|
+
taskRef: Number(row["task_ref"]),
|
|
1722
|
+
leaseId: String(row["lease_id"]),
|
|
1723
|
+
generation: Number(row["lease_generation"]),
|
|
1724
|
+
runner: String(row["runner"]),
|
|
1725
|
+
acquiredAt: String(row["acquired_at"]),
|
|
1726
|
+
expiresAt: String(row["expires_at"]),
|
|
1727
|
+
heartbeatAt: String(row["heartbeat_at"]),
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* IMMEDIATE rather than DEFERRED: the write lock is taken up front, so two
|
|
1732
|
+
* processes racing for the same task queue behind one another instead of both
|
|
1733
|
+
* reading, both deciding they won, and one failing at commit time.
|
|
1734
|
+
*/
|
|
1735
|
+
function inTransaction(store, body) {
|
|
1736
|
+
// The store's transact: same BEGIN IMMEDIATE, plus reentrancy — a caller
|
|
1737
|
+
// composing a fenced finalizer with more writes (completion + publication
|
|
1738
|
+
// intent) joins one transaction instead of dying on a nested BEGIN.
|
|
1739
|
+
return store.transact(body);
|
|
1740
|
+
}
|