@deksden-com/dd-flow-cli 0.9.0-beta.14 → 0.9.0-beta.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +733 -0
- package/README.md +143 -1
- package/dist/build-info.json +8 -8
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +376 -0
- package/dist/cli/command-metadata.js +197 -0
- package/dist/cli/command-option-descriptions.js +191 -0
- package/dist/cli/completion-cli.js +70 -0
- package/dist/cli/completion.js +57 -0
- package/dist/cli/error-output.js +52 -0
- package/dist/cli/executor-onboarding.js +76 -0
- package/dist/cli/flows.js +29 -0
- package/dist/cli/harnesses.js +84 -0
- package/dist/cli/help.js +328 -18
- package/dist/cli/hook-ingress.js +78 -0
- package/dist/cli/input-preparation.js +165 -0
- package/dist/cli/memory-flow-admission.js +44 -0
- package/dist/cli/memory-init-invocation.js +78 -0
- package/dist/cli/memory-init.js +240 -0
- package/dist/cli/memory-storage-admission.js +53 -0
- package/dist/cli/models.js +39 -0
- package/dist/cli/native-hook-ingress.js +80 -0
- package/dist/cli/operator-profiles.js +61 -0
- package/dist/cli/output-options.js +35 -0
- package/dist/cli/response-file.js +71 -0
- package/dist/cli/run-cli.js +1689 -545
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +52 -0
- package/dist/cli/runtime-process.js +153 -0
- package/dist/cli/setup.js +149 -0
- package/dist/cli.js +71 -2
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/domain/stage-handler-identities.js +20 -0
- package/dist/harness-runtime/DD-ZCODE.md +112 -0
- package/dist/harness-runtime/OPENCODE-LICENSE.txt +24 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +371 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +724 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +70 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +196 -0
- package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +817 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +129 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +584 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +498 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +507 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +354 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +750 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +1145 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +14 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +157 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +37 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +273 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/managed-daemon.d.mts +13 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +654 -0
- package/dist/harness-runtime/lib/managed-flow-env.mjs +15 -0
- package/dist/harness-runtime/lib/model-observations.mjs +101 -0
- package/dist/harness-runtime/lib/native-children.d.mts +16 -0
- package/dist/harness-runtime/lib/native-children.mjs +113 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
- package/dist/harness-runtime/lib/native-request-proof.d.mts +2 -0
- package/dist/harness-runtime/lib/native-request-proof.mjs +65 -0
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
- package/dist/harness-runtime/lib/opencode-plugin.mjs +85 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +116 -0
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +114 -0
- package/dist/harness-runtime/lib/process-json.d.mts +5 -0
- package/dist/harness-runtime/lib/process-json.mjs +201 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/provider-overload-policy.d.mts +13 -0
- package/dist/harness-runtime/lib/provider-overload-policy.mjs +138 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/setup-progress.d.mts +9 -0
- package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-1y4a697q.js +81 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-2ny4vxct.js +92 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-6mptkzbc.js +0 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-7x67cf1q.js +52 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9rqn6x4v.js +16 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9vdz0zwn.js +1661 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ecs5ftka.js +1466 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-frbwqjmf.js +145 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gne7h7mq.js +50 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gx9wnkzr.js +105 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-m2eywc8w.js +0 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-nwcpakzx.js +12 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-q4g3afnw.js +623 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-qav6mfdz.js +0 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-sxt88q69.js +12 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-t1xa31vm.js +39 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ve4aws36.js +103 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vmytr4q8.js +4 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vt9jbch8.js +36 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vyas3vgt.js +0 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-wjyeq25y.js +30 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-xgnj2w4v.js +20 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-y2etg5bs.js +9 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ydhz1zrh.js +238 -0
- package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-z6qdwcsf.js +62 -0
- package/dist/harness-runtime/vendor/opencode-client/contract.d.ts +1 -0
- package/dist/harness-runtime/vendor/opencode-client/contract.js +12 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/api/api.d.ts +2806 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/api/api.js +0 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/api.d.ts +4 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/api.js +0 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/client.d.ts +2530 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/client.js +15 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.d.ts +7 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.js +6 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.d.ts +2529 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.js +8 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.d.ts +2 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.js +12 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/index.d.ts +35 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/index.js +75 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/rpc.d.ts +20 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/rpc.js +11 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/service.d.ts +90 -0
- package/dist/harness-runtime/vendor/opencode-client/effect/service.js +187 -0
- package/dist/harness-runtime/vendor/opencode-client/package.json +84 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/api.d.ts +21 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/api.js +0 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/client.d.ts +252 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/client.js +14 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.d.ts +8 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.js +6 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.d.ts +259 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.js +7 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.d.ts +3 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.js +79 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.d.ts +8932 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.js +70 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/index.d.ts +6 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/index.js +83 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/rpc.d.ts +32 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/rpc.js +7 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/service.d.ts +24 -0
- package/dist/harness-runtime/vendor/opencode-client/promise/service.js +187 -0
- package/dist/harness-runtime/vendor/opencode-client/pty-handoff.d.ts +12 -0
- package/dist/harness-runtime/vendor/opencode-client/pty-handoff.js +14 -0
- package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.d.ts +20 -0
- package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.js +14 -0
- package/dist/harness-runtime/vendor/opencode-client/service-contender.d.ts +37 -0
- package/dist/harness-runtime/vendor/opencode-client/service-contender.js +10 -0
- package/dist/harness-runtime/vendor/opencode-client/service-probe.d.ts +70 -0
- package/dist/harness-runtime/vendor/opencode-client/service-probe.js +16 -0
- package/dist/harness-runtime/vendor/opencode-client/service-timing.d.ts +12 -0
- package/dist/harness-runtime/vendor/opencode-client/service-timing.js +10 -0
- package/dist/harness-runtime/vendor/opencode-client/service-version.d.ts +2 -0
- package/dist/harness-runtime/vendor/opencode-client/service-version.js +6 -0
- package/dist/harness-runtime/vendor/opencode-client/service.d.ts +52 -0
- package/dist/harness-runtime/vendor/opencode-client/service.js +1 -0
- package/dist/harness-runtime/vendor/opencode-client/shared-events.d.ts +11 -0
- package/dist/harness-runtime/vendor/opencode-client/shared-events.js +8 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/connection.d.ts +37 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/connection.js +10 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/data.d.ts +211 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/data.js +18 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/index.d.ts +3 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/index.js +32 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/pty.d.ts +22 -0
- package/dist/harness-runtime/vendor/opencode-client/solid/pty.js +8 -0
- package/dist/harness-runtime/vendor/opencode-plugin.mjs +3 -0
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-verification.schema.json +7 -5
- package/dist/schemas/code-work-batch.schema.json +2 -2
- package/dist/schemas/discovery-result.schema.json +184 -0
- package/dist/schemas/execution-bundle.schema.json +205 -0
- package/dist/schemas/execution-settings.schema.json +162 -0
- package/dist/schemas/flow-descriptor.schema.json +54 -0
- package/dist/schemas/flow-run.schema.json +28 -0
- package/dist/schemas/harness-catalog.schema.json +80 -0
- package/dist/schemas/mb-init-compose-result.schema.json +457 -0
- package/dist/schemas/mb-init-discover-result.schema.json +500 -0
- package/dist/schemas/mb-init-finish-result.schema.json +422 -0
- package/dist/schemas/mb-init-preflight-result.schema.json +367 -0
- package/dist/schemas/mb-init-review-result.schema.json +396 -0
- package/dist/schemas/mb-init-verify-result.schema.json +391 -0
- package/dist/schemas/memory-bootstrap-receipt-v2.schema.json +67 -0
- package/dist/schemas/memory-bootstrap-receipt.schema.json +354 -0
- package/dist/schemas/memory-composition-work-result.schema.json +38 -0
- package/dist/schemas/memory-repair-work-result.schema.json +49 -0
- package/dist/schemas/memory-review-correction-plan.schema.json +30 -0
- package/dist/schemas/memory-review-work-result.schema.json +43 -0
- package/dist/schemas/model-catalog.schema.json +153 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/profile-settings.schema.json +122 -0
- package/dist/schemas/release-manifest.schema.json +291 -0
- package/dist/schemas/run-control-receipt.schema.json +118 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/run-engine-binding.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/branch-context.js +3 -3
- package/dist/services/canon.js +11 -8
- package/dist/services/check-runner-evidence.js +168 -0
- package/dist/services/cleanup.js +133 -40
- package/dist/services/cli-operation-classifier.js +25 -62
- package/dist/services/cli-upgrade-allowlist.js +27 -0
- package/dist/services/code-checks.js +1156 -107
- package/dist/services/codex-hook-delivery.js +32 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/compatibility-preflight.js +13 -3
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-child-capacity.js +13 -0
- package/dist/services/controller-child-observer.js +115 -0
- package/dist/services/controller-execution.js +88 -0
- package/dist/services/controller-fanout.js +202 -0
- package/dist/services/dashboard.js +143 -58
- package/dist/services/descriptor-coordinator-session.js +35 -0
- package/dist/services/descriptor-root-work.js +53 -0
- package/dist/services/descriptor-run-settlement.js +105 -0
- package/dist/services/descriptor-run-start.js +449 -0
- package/dist/services/descriptor-stage-coordinator.js +197 -0
- package/dist/services/descriptor-stage-settlement.js +222 -0
- package/dist/services/descriptor-stage-start.js +139 -0
- package/dist/services/discovery/agy-discovery.js +2 -0
- package/dist/services/discovery/codex-discovery.js +2 -0
- package/dist/services/discovery/droid-discovery.js +2 -0
- package/dist/services/discovery/grok-discovery.js +2 -0
- package/dist/services/discovery/opencode-discovery.js +2 -0
- package/dist/services/discovery/static.js +31 -0
- package/dist/services/discovery/zcode-discovery.js +2 -0
- package/dist/services/engines.js +294 -153
- package/dist/services/eval-snapshots.js +1215 -116
- package/dist/services/execution-bundles.js +263 -0
- package/dist/services/execution-policy.js +185 -0
- package/dist/services/executor-onboarding.js +56 -0
- package/dist/services/external-work-launch.js +308 -0
- package/dist/services/flow-catalog.js +21 -0
- package/dist/services/flow-descriptors.js +82 -0
- package/dist/services/frozen-engine.js +250 -0
- package/dist/services/generic-execution.js +164 -0
- package/dist/services/harness-adapter.js +355 -36
- package/dist/services/harness-config.js +2 -0
- package/dist/services/harness-discovery.js +29 -0
- package/dist/services/harness-doctor.js +75 -0
- package/dist/services/harness-probe-effects.js +51 -0
- package/dist/services/harness-version-probe.js +100 -0
- package/dist/services/hooks.js +771 -325
- package/dist/services/hybrid-contracts.js +483 -0
- package/dist/services/ids.js +29 -1
- package/dist/services/installation-lock.js +95 -0
- package/dist/services/installed-flow-source.js +183 -0
- package/dist/services/lanes.js +78 -65
- package/dist/services/lifecycle-command.js +1 -288
- package/dist/services/lifecycle-contract.js +1 -0
- package/dist/services/lifecycle-invocations.js +2007 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-lease-monitor.js +133 -0
- package/dist/services/managed-processes.js +475 -55
- package/dist/services/memory-accepted-composition.js +77 -0
- package/dist/services/memory-baseline-preservation.js +65 -0
- package/dist/services/memory-completed-correction-wave.js +320 -0
- package/dist/services/memory-compose-handler.js +258 -0
- package/dist/services/memory-composition-acceptance.js +199 -0
- package/dist/services/memory-composition-graph.js +134 -0
- package/dist/services/memory-composition-registration.js +146 -0
- package/dist/services/memory-composition-work-custody.js +151 -0
- package/dist/services/memory-composition-work-results.js +96 -0
- package/dist/services/memory-corrected-review-acceptance.js +129 -0
- package/dist/services/memory-corrected-review-batch.js +30 -0
- package/dist/services/memory-corrected-review-findings.js +106 -0
- package/dist/services/memory-corrected-review-inputs.js +210 -0
- package/dist/services/memory-corrected-review-projection.js +78 -0
- package/dist/services/memory-corrected-review-receipt.js +170 -0
- package/dist/services/memory-corrected-review-registration.js +191 -0
- package/dist/services/memory-correction-check-receipt.js +219 -0
- package/dist/services/memory-correction-input-owner.js +6 -0
- package/dist/services/memory-discover-handler.js +104 -0
- package/dist/services/memory-finish-evidence.js +435 -0
- package/dist/services/memory-finish-handler.js +215 -0
- package/dist/services/memory-finish-integration.js +280 -0
- package/dist/services/memory-finish-readiness.js +122 -0
- package/dist/services/memory-git-integration.js +151 -0
- package/dist/services/memory-historical-corrected-review.js +206 -0
- package/dist/services/memory-historical-original-review.js +130 -0
- package/dist/services/memory-historical-repair-registration.js +99 -0
- package/dist/services/memory-historical-second-review.js +128 -0
- package/dist/services/memory-init-bootstrap-readiness.js +210 -0
- package/dist/services/memory-init-contexts.js +89 -0
- package/dist/services/memory-init-controller-start.js +84 -0
- package/dist/services/memory-init-engine-release.js +223 -0
- package/dist/services/memory-init-git-policy.js +136 -0
- package/dist/services/memory-init-lint-capability.js +185 -0
- package/dist/services/memory-init-native-capacity.js +296 -0
- package/dist/services/memory-init-preflight.js +330 -0
- package/dist/services/memory-init-project-bootstrap.js +599 -0
- package/dist/services/memory-init-request-candidate.js +157 -0
- package/dist/services/memory-init-request-input.js +66 -0
- package/dist/services/memory-init-resource-storage.js +125 -0
- package/dist/services/memory-init-run-allocation.js +81 -0
- package/dist/services/memory-init-run-bundle.js +98 -0
- package/dist/services/memory-init-start.js +181 -0
- package/dist/services/memory-init-startup-custody.js +80 -0
- package/dist/services/memory-init-startup-input.js +240 -0
- package/dist/services/memory-init-storage.js +81 -0
- package/dist/services/memory-init-stores.js +359 -0
- package/dist/services/memory-init-worktrunk-capability.js +178 -0
- package/dist/services/memory-layer-publication.js +611 -0
- package/dist/services/memory-layer.js +156 -0
- package/dist/services/memory-lint-config.js +85 -0
- package/dist/services/memory-native-review-copy.js +165 -0
- package/dist/services/memory-normal-flow-entry.js +75 -0
- package/dist/services/memory-ordinary-defaults.js +112 -0
- package/dist/services/memory-preflight-bootstrap-custody.js +115 -0
- package/dist/services/memory-preflight-handler.js +259 -0
- package/dist/services/memory-repair-current-view.js +299 -0
- package/dist/services/memory-repair-document-delta.js +451 -0
- package/dist/services/memory-repair-history.js +491 -0
- package/dist/services/memory-repair-physical-binding.js +312 -0
- package/dist/services/memory-repair-publication-files.js +152 -0
- package/dist/services/memory-repair-publication-intent.js +27 -0
- package/dist/services/memory-repair-publication-plan.js +160 -0
- package/dist/services/memory-repair-publication-source.js +225 -0
- package/dist/services/memory-repair-publication.js +794 -0
- package/dist/services/memory-repair-registration.js +239 -0
- package/dist/services/memory-repair-work-baseline.js +413 -0
- package/dist/services/memory-repair-work-completion.js +97 -0
- package/dist/services/memory-repair-work-custody.js +73 -0
- package/dist/services/memory-repair-work-results.js +89 -0
- package/dist/services/memory-repair-workspace.js +252 -0
- package/dist/services/memory-repair-writer-copy.js +735 -0
- package/dist/services/memory-result-binding.js +173 -0
- package/dist/services/memory-retained-review-authority.js +129 -0
- package/dist/services/memory-retained-review-results.js +70 -0
- package/dist/services/memory-retained-second-baseline.js +102 -0
- package/dist/services/memory-retained-second-repairs.js +156 -0
- package/dist/services/memory-review-acceptance.js +214 -0
- package/dist/services/memory-review-correction-plan.js +171 -0
- package/dist/services/memory-review-handler.js +585 -0
- package/dist/services/memory-review-inputs.js +200 -0
- package/dist/services/memory-review-receipt.js +180 -0
- package/dist/services/memory-review-registration.js +108 -0
- package/dist/services/memory-review-work-custody.js +124 -0
- package/dist/services/memory-review-work-input.js +72 -0
- package/dist/services/memory-review-work-results.js +73 -0
- package/dist/services/memory-second-corrected-review-inputs.js +165 -0
- package/dist/services/memory-second-correction-plan.js +76 -0
- package/dist/services/memory-second-repair-baseline.js +91 -0
- package/dist/services/memory-second-repair-launch.js +614 -0
- package/dist/services/memory-second-repair-registration.js +88 -0
- package/dist/services/memory-second-repair-source.js +187 -0
- package/dist/services/memory-second-review-acceptance.js +115 -0
- package/dist/services/memory-source-observations.js +117 -0
- package/dist/services/memory-stage-dispatch.js +28 -0
- package/dist/services/memory-stage-results.js +199 -0
- package/dist/services/memory-verification-acceptance.js +135 -0
- package/dist/services/memory-verification-checks.js +252 -0
- package/dist/services/memory-verification-receipt.js +159 -0
- package/dist/services/memory-verify-handler.js +135 -0
- package/dist/services/memory-work-result-preparation.js +63 -0
- package/dist/services/merge-queue.js +228 -149
- package/dist/services/merge-server.js +161 -35
- package/dist/services/migrations.js +37 -16
- package/dist/services/model-catalog.js +109 -0
- package/dist/services/model-discovery.js +46 -0
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/operator-profiles.js +236 -0
- package/dist/services/plan-runtime.js +26 -11
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +37 -17
- package/dist/services/prompts.js +28 -17
- package/dist/services/protocols.js +163 -87
- package/dist/services/provider-capacity.js +385 -0
- package/dist/services/recovery-observation-budget.js +121 -0
- package/dist/services/recovery-snapshot-database.js +560 -0
- package/dist/services/release-archive-producer.js +52 -0
- package/dist/services/release-archive.js +143 -0
- package/dist/services/release-cache.js +142 -0
- package/dist/services/release-compatibility.js +18 -0
- package/dist/services/release-download.js +67 -0
- package/dist/services/release-envelope.js +104 -0
- package/dist/services/release-manifest.js +45 -0
- package/dist/services/release-payload.js +114 -0
- package/dist/services/release-producer.js +63 -0
- package/dist/services/release-provenance.js +160 -0
- package/dist/services/release-registry.js +66 -0
- package/dist/services/release-source.js +194 -0
- package/dist/services/repair-intents.js +42 -0
- package/dist/services/review-copy.js +165 -0
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +411 -0
- package/dist/services/run-control.js +1056 -0
- package/dist/services/run-controller-adapter.js +399 -0
- package/dist/services/run-controller-capture.js +186 -0
- package/dist/services/run-controller-process.js +208 -0
- package/dist/services/run-controller-recovery.js +289 -0
- package/dist/services/run-controller-state.js +97 -0
- package/dist/services/run-controller.js +1205 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +147 -0
- package/dist/services/run-health.js +149 -0
- package/dist/services/run-observations.js +152 -0
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-guard.js +77 -0
- package/dist/services/run-recovery-runtime.js +112 -0
- package/dist/services/run-recovery.js +489 -0
- package/dist/services/run-start-custody.js +177 -0
- package/dist/services/runs.js +1019 -247
- package/dist/services/runtime-budget.js +430 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +52 -0
- package/dist/services/runtime-scope-control.js +468 -0
- package/dist/services/runtime-scope-resume.js +599 -0
- package/dist/services/runtime-scope-stop.js +210 -0
- package/dist/services/runtime-scope-worker.js +287 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/scope-writer-cleanup.js +27 -0
- package/dist/services/sessions.js +35 -69
- package/dist/services/setup.js +377 -0
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +193 -19
- package/dist/services/stage-handlers.js +74 -0
- package/dist/services/stage-lifecycle.js +100 -108
- package/dist/services/stage-pause.js +89 -75
- package/dist/services/stage-report-renderer.js +234 -6
- package/dist/services/stage-work-graph.js +52 -0
- package/dist/services/tooling.js +32 -23
- package/dist/services/usage.js +49 -101
- package/dist/services/verification-matrix.js +374 -0
- package/dist/services/vnext-code-review.js +223 -88
- package/dist/services/vnext-code.js +327 -226
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +80 -5
- package/dist/services/vnext-fanout.js +311 -19
- package/dist/services/vnext-merge.js +332 -111
- package/dist/services/vnext-plan-review.js +279 -127
- package/dist/services/vnext-plan.js +229 -104
- package/dist/services/vnext-protocolize.js +411 -86
- package/dist/services/vnext-specify.js +119 -81
- package/dist/services/vnext-workspace-policy.js +7 -2
- package/dist/services/work-registry.js +1627 -199
- package/dist/services/workspace-bootstrap.js +52 -15
- package/dist/services/workspace-files.js +85 -0
- package/dist/services/worktrees.js +98 -44
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +17 -0
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +891 -39
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/paths.js +17 -4
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +89 -0
- package/dist/storage/writer-migration.js +85 -0
- package/package.json +43 -13
- package/tools/create-canon-release.mjs +33 -0
- package/tools/produce-hybrid-release.mjs +41 -0
- package/tools/repair-paused-run-status.mjs +84 -0
|
@@ -0,0 +1,2007 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { checkRepairDisposition } from "./check-runner-evidence.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { quote } from "shell-quote";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
8
|
+
import { shortEntityReference } from "../shared/entity-references.js";
|
|
9
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
10
|
+
import { commandOption, commandPosition, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
11
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
12
|
+
import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPacketReceipt } from "./run-recovery.js";
|
|
13
|
+
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
14
|
+
import { managedProcessStatus, processIsAlive, processStartedAt } from "./managed-processes.js";
|
|
15
|
+
import { appendEvent } from "./run-controller-state.js";
|
|
16
|
+
import { recoverCommittedWorkStart } from "./work-registry.js";
|
|
17
|
+
import { observeCommandHook } from "./hooks.js";
|
|
18
|
+
import { runtimeHarness } from "./execution-policy.js";
|
|
19
|
+
import { normalizedPathAlias, resolveContextualPaths } from "./command-context.js";
|
|
20
|
+
import { isRegisteredRepair } from "./repair-intents.js";
|
|
21
|
+
import { stageAttempt } from "./stage-work-graph.js";
|
|
22
|
+
import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
|
|
23
|
+
import { canonicalPath } from "../storage/paths.js";
|
|
24
|
+
import { runtimeCommand, shellArgument } from "./runtime-command.js";
|
|
25
|
+
// Process-local custody: an executing SQL row or ambient variable alone cannot
|
|
26
|
+
// recreate admission after a CLI restart, or in another resource database.
|
|
27
|
+
const currentAdmissions = new WeakMap();
|
|
28
|
+
export function currentLifecycleAdmission(context, id) {
|
|
29
|
+
const owned = currentAdmissions.get(context.db)?.get(id);
|
|
30
|
+
if (!owned || context.env.DD_FLOW_CURRENT_INVOCATION !== id || owned.pid !== process.pid
|
|
31
|
+
|| process.cwd() !== owned.cwd)
|
|
32
|
+
return null;
|
|
33
|
+
// This private module cannot survive owner death/restart/PID reuse. Its
|
|
34
|
+
// captured physical birth + exact PID/CWD/store remain a lifetime proof;
|
|
35
|
+
// revalidation reads SQL only, including inside the atomic claim writer.
|
|
36
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id=?", [id]);
|
|
37
|
+
if (!row || row.status !== "executing" || row.event_key !== owned.eventKey || row.identity_json !== owned.identity
|
|
38
|
+
|| row.scope_json !== owned.scope || row.command !== owned.command || row.updated_at !== owned.admittedAt)
|
|
39
|
+
return null;
|
|
40
|
+
return { admittedAt: owned.admittedAt };
|
|
41
|
+
}
|
|
42
|
+
/** Auxiliary commands have no invocation capability, but may not escape their
|
|
43
|
+
* native daemon's project/RUN. Context aliases never grant mutation authority. */
|
|
44
|
+
export function assertAuxiliaryCommandScope(context, command, projectRoot) {
|
|
45
|
+
const auxiliary = continuationCommand(command);
|
|
46
|
+
const daemonId = context.env.DD_FLOW_DAEMON_ID;
|
|
47
|
+
if (!auxiliary || !daemonId)
|
|
48
|
+
return;
|
|
49
|
+
const event = observedLifecycleNativeEvent(context, command, daemonId);
|
|
50
|
+
if (!event)
|
|
51
|
+
throw new AppError("invocation_receipt_missing", "Auxiliary command has no exact native receipt", 1, { phase: "prepare", effect: "no_effect" });
|
|
52
|
+
const project = context.db.get("SELECT root FROM projects WHERE id = ?", [event.project_id]);
|
|
53
|
+
if (!project || !projectRoot || canonicalPath(projectRoot, false) !== project.root)
|
|
54
|
+
throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native project scope", 1, { phase: "prepare", effect: "no_effect" });
|
|
55
|
+
for (const record of managedProcessStatus(context)) {
|
|
56
|
+
if (record.project_id !== event.project_id || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
57
|
+
continue;
|
|
58
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
59
|
+
if (typeof metadata.state_dir !== "string")
|
|
60
|
+
continue;
|
|
61
|
+
let state;
|
|
62
|
+
try {
|
|
63
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (state.daemon_id !== daemonId)
|
|
69
|
+
continue;
|
|
70
|
+
requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: event.project_id, runId: record.run_id });
|
|
71
|
+
const reference = auxiliary.args.options.get("run")?.[0] ?? auxiliary.args.positional[2];
|
|
72
|
+
const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [event.project_id, reference, reference]);
|
|
73
|
+
if (run?.id !== record.run_id)
|
|
74
|
+
throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native RUN scope", 1, { phase: "prepare", effect: "no_effect" });
|
|
75
|
+
assertRunMutationAllowed(context, event.project_id, record.run_id, metadata.run_generation);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new AppError("invocation_scope_unavailable", "Auxiliary command has no live managed daemon binding", 1, { phase: "prepare", effect: "no_effect" });
|
|
79
|
+
}
|
|
80
|
+
function sameNativeCall(left, right) {
|
|
81
|
+
const a = JSON.parse(left), b = JSON.parse(right);
|
|
82
|
+
// Parent and execution route are immutable facts, not a different call ID.
|
|
83
|
+
return [0, 1, 2, 4].every(index => a[index] === b[index]);
|
|
84
|
+
}
|
|
85
|
+
// Conclusive result/check rejections, never transport, ownership, registry or
|
|
86
|
+
// unknown-effect failures. These validators reject before accepting the result.
|
|
87
|
+
export function isRetryableLifecycleRejection(code, details = {}) {
|
|
88
|
+
if (code.startsWith("native_hook_") || code === "invocation_receipt_timeout")
|
|
89
|
+
return false;
|
|
90
|
+
if (code === "fresh_session_required" || details.recoverable === false || details.handoff_required === true || details.effect === "unknown" || details.effect === "committed")
|
|
91
|
+
return false;
|
|
92
|
+
if (code === "validation" || code === "schema_validation")
|
|
93
|
+
return details.phase === "prepare" && details.effect === "no_effect" && details.recoverable === true;
|
|
94
|
+
if (["invocation_command_mismatch", "invocation_argument_mismatch"].includes(code))
|
|
95
|
+
return details.recoverable !== false;
|
|
96
|
+
if (["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed"].includes(code))
|
|
97
|
+
return Array.isArray(details.failures) && details.failures.length > 0 && details.failures.every(receipt => checkRepairDisposition(receipt) === "source_repair");
|
|
98
|
+
return details.recoverable === true || ["review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
99
|
+
"review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
|
|
100
|
+
"evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
|
|
101
|
+
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
102
|
+
|| (code === "invalid_work_state" && details.retryable_no_effect === true);
|
|
103
|
+
}
|
|
104
|
+
/** A correctable rejection either publishes an exact replacement command or
|
|
105
|
+
* proves that the controller must hand the existing Work to another Session.
|
|
106
|
+
* The latter is deliberately not retryable in the caller that just failed. */
|
|
107
|
+
export function isCorrectableLifecycleRejection(code, details = {}) {
|
|
108
|
+
if (details.recoverable === false || details.effect === "unknown" || details.effect === "committed")
|
|
109
|
+
return false;
|
|
110
|
+
return isRetryableLifecycleRejection(code, details)
|
|
111
|
+
|| (code === "fresh_session_required" && details.handoff_required === true
|
|
112
|
+
&& details.recoverable === true);
|
|
113
|
+
}
|
|
114
|
+
function registeredLifecycleContinuation(context, error, projectId, runId) {
|
|
115
|
+
if (runId && isRegisteredRepair(context, error.details.continuation, { projectId, runId }))
|
|
116
|
+
return true;
|
|
117
|
+
return error.code === "fresh_session_required" && error.details.handoff_required === true && runId !== null
|
|
118
|
+
&& typeof error.details.work_id === "string" && Boolean(context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND work_id = ? AND launch_policy = 'fresh_agent_required' AND status IN ('created','running','paused')", [projectId, runId, error.details.work_id]));
|
|
119
|
+
}
|
|
120
|
+
function storage(context) {
|
|
121
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
|
|
122
|
+
throw new AppError("invocation_storage_unprepared", "Lifecycle invocation storage is not prepared; start dd-flow normally before issuing native work", 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function invocation(command) {
|
|
126
|
+
const parsed = parseLifecycleCommand(command);
|
|
127
|
+
if (parsed.kind !== "standalone" || parsed.invocation.wrapped) {
|
|
128
|
+
throw new AppError("invocation_command_invalid", "A lifecycle attempt requires one direct standalone command", 1);
|
|
129
|
+
}
|
|
130
|
+
return parsed.invocation;
|
|
131
|
+
}
|
|
132
|
+
function launchExpression(parsed) {
|
|
133
|
+
const executable = parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
|
|
134
|
+
return parsed.launcher ? `${quote([parsed.launcher])} ${executable}` : executable;
|
|
135
|
+
}
|
|
136
|
+
// Input data may be supplied later (stdin/file contents); authority is bound to
|
|
137
|
+
// the exact operation and literal argv. Existing lifecycle validation owns data.
|
|
138
|
+
function fingerprint(parsed) {
|
|
139
|
+
const projected = publicInvocationArgs(parsed);
|
|
140
|
+
const publicParsed = invocation(`${launchExpression(parsed)} ${quoteModelFacingArgs(projected)}`);
|
|
141
|
+
const options = [...publicParsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl"].includes(key))
|
|
142
|
+
.map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
|
|
143
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
144
|
+
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, publicParsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
|
|
145
|
+
}
|
|
146
|
+
const runtimeOwnedOptions = {
|
|
147
|
+
stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
|
|
148
|
+
stage_finish: new Set(["project-root", "result-file", "decision-file", "verification-file"]),
|
|
149
|
+
stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root", "answer-file"]),
|
|
150
|
+
work_start: new Set(["project-root", "recovery-id"]),
|
|
151
|
+
work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
|
|
152
|
+
merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
|
|
153
|
+
recovery_accept: new Set(["project-root"])
|
|
154
|
+
};
|
|
155
|
+
function publicInvocationArgs(parsed) {
|
|
156
|
+
const hidden = runtimeOwnedOptions[parsed.operation] ?? new Set();
|
|
157
|
+
const args = [];
|
|
158
|
+
for (let index = 0; index < parsed.argv.length; index += 1) {
|
|
159
|
+
const value = parsed.argv[index];
|
|
160
|
+
if (!value.startsWith("--")) {
|
|
161
|
+
args.push(value);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const key = value.slice(2).split("=", 1)[0];
|
|
165
|
+
const inline = value.includes("=");
|
|
166
|
+
const next = parsed.argv[index + 1];
|
|
167
|
+
if (hidden.has(key) || key === "invocation-id" || key === "hook-event-id") {
|
|
168
|
+
if (!inline && next && !next.startsWith("--"))
|
|
169
|
+
index += 1;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
args.push(value);
|
|
173
|
+
if (!inline && next && !next.startsWith("--"))
|
|
174
|
+
args.push(parsed.argv[++index]);
|
|
175
|
+
}
|
|
176
|
+
// A managed coordinator Session is already bound to its RUN. Work IDs stay
|
|
177
|
+
// public because sibling Work assignments can coexist under one native root.
|
|
178
|
+
const operationOffset = parsed.operation === "recovery_accept" ? 3 : 2;
|
|
179
|
+
if (["stage_start", "stage_finish", "recovery_accept"].includes(parsed.operation)
|
|
180
|
+
&& !parsed.args.options.has("bootstrap") && args[operationOffset] && !args[operationOffset].startsWith("--"))
|
|
181
|
+
args.splice(operationOffset, 1);
|
|
182
|
+
return args;
|
|
183
|
+
}
|
|
184
|
+
function normalizedInvocationValue(key, value) {
|
|
185
|
+
value = normalizedPathAlias(value);
|
|
186
|
+
if (key === "project-root" && (path.isAbsolute(value) || value === "@project"))
|
|
187
|
+
return "@project";
|
|
188
|
+
if (["result-file", "decision-file", "verification-file"].includes(key))
|
|
189
|
+
return "<validated-input-path>";
|
|
190
|
+
if (value.startsWith("@project") || value.startsWith("@workspace") || value.startsWith("@run"))
|
|
191
|
+
return value.replace(/\\/g, "/");
|
|
192
|
+
if (["result-file", "decision-file", "verification-file", "context-file"].includes(key) && path.isAbsolute(value)) {
|
|
193
|
+
const runRelative = value.replace(/\\/g, "/").match(/\/(?:runs\/)?RUN-[^/]+\/(.+)$/)?.[1];
|
|
194
|
+
if (runRelative)
|
|
195
|
+
return `@run/${runRelative}`;
|
|
196
|
+
}
|
|
197
|
+
if (["positional", "work", "run"].includes(key))
|
|
198
|
+
return shortEntityReference(value);
|
|
199
|
+
return key === "retry-check" ? /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value : value;
|
|
200
|
+
}
|
|
201
|
+
/** Shell/JSON builders sometimes preserve the shell escape before an alias.
|
|
202
|
+
* Accept it only for the small declared alias vocabulary. */
|
|
203
|
+
function quoteModelFacingArgs(argv) {
|
|
204
|
+
return argv.map(value => /^@(project|workspace|run)(?:\/.*)?$/.test(value)
|
|
205
|
+
? `'${value.replaceAll("'", `'\\''`)}'`
|
|
206
|
+
: quote([value])).join(" ");
|
|
207
|
+
}
|
|
208
|
+
function withoutInvocationId(command, aliases) {
|
|
209
|
+
const parsed = invocation(command);
|
|
210
|
+
const argv = publicInvocationArgs(parsed);
|
|
211
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
212
|
+
const option = argv[index - 1]?.replace(/^--/, "");
|
|
213
|
+
if (option === "project-root")
|
|
214
|
+
argv[index] = "@project";
|
|
215
|
+
else if (option === "work" || option === "run")
|
|
216
|
+
argv[index] = shortEntityReference(argv[index]);
|
|
217
|
+
else if (option && aliases && ["result-file", "decision-file", "verification-file", "context-file"].includes(option) && path.isAbsolute(argv[index])) {
|
|
218
|
+
for (const [name, root] of [["run", aliases.run], ["workspace", aliases.workspace], ["project", aliases.project]]) {
|
|
219
|
+
if (!root)
|
|
220
|
+
continue;
|
|
221
|
+
const relative = path.relative(root, argv[index]);
|
|
222
|
+
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
|
223
|
+
argv[index] = `@${name}${relative ? `/${relative.replaceAll(path.sep, "/")}` : ""}`;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else if (!argv[index - 1]?.startsWith("--") && !argv[index].startsWith("--"))
|
|
229
|
+
argv[index] = normalizedInvocationValue("positional", argv[index]);
|
|
230
|
+
}
|
|
231
|
+
const env = Object.fromEntries(Object.entries(parsed.env).filter(([key]) => !["DD_FLOW_INVOCATION_SCOPE", "DD_FLOW_HOME", "DD_FLOW_BIN"].includes(key)));
|
|
232
|
+
const executable = launchExpression(parsed);
|
|
233
|
+
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
234
|
+
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
235
|
+
return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
236
|
+
}
|
|
237
|
+
function publicInvocationCommand(context, row, command) {
|
|
238
|
+
assertPrivateStageStartContract(row, command);
|
|
239
|
+
if (!commandOption(invocation(command), "project-root"))
|
|
240
|
+
command = retainedCommandWithoutInvocationId(row);
|
|
241
|
+
const rendered = renderInvocationCommand(row, command);
|
|
242
|
+
const scope = JSON.parse(row.scope_json);
|
|
243
|
+
const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
244
|
+
context.captureLifecycleAssignment?.(row.id);
|
|
245
|
+
return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
|
|
246
|
+
}
|
|
247
|
+
/** Public fingerprints recognize a command; they do not replace its private
|
|
248
|
+
* controller contract when an issuer renders the same public operation. */
|
|
249
|
+
function assertPrivateStageStartContract(row, command) {
|
|
250
|
+
const actual = invocation(command), retained = invocation(row.command);
|
|
251
|
+
if (actual.operation !== "stage_start")
|
|
252
|
+
return;
|
|
253
|
+
const lifecycleAssignment = { issuer: "dd-flow", phase: "issuance", scope: JSON.parse(row.scope_json) };
|
|
254
|
+
if (commandPosition(actual, 0) !== commandPosition(retained, 0))
|
|
255
|
+
throw new AppError("invocation_argument_mismatch", "Retained Stage entry has a different RUN target", 1, { invocation_id: row.id, phase: "prepare", effect: "no_effect", recoverable: false, lifecycle_assignment: lifecycleAssignment });
|
|
256
|
+
for (const key of ["context-file", "context-sha256", "response-file", "require-session-binding"]) {
|
|
257
|
+
const supplied = commandOption(actual, key), expected = commandOption(retained, key);
|
|
258
|
+
const normalized = (value) => ["context-file", "response-file"].includes(key) && path.isAbsolute(value) ? path.normalize(value) : normalizedInvocationValue(key, value);
|
|
259
|
+
if ((supplied === undefined) !== (expected === undefined)
|
|
260
|
+
|| supplied !== undefined && normalized(supplied) !== normalized(expected)) {
|
|
261
|
+
throw new AppError("invocation_argument_mismatch", `Retained Stage entry has a different --${key} contract`, 1, { invocation_id: row.id, parameter: key, phase: "prepare", effect: "no_effect", recoverable: false, lifecycle_assignment: lifecycleAssignment });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Bind issued commands to the exact bytes sent to the model, not to an
|
|
266
|
+
* internal marker which is intentionally absent from that prompt. */
|
|
267
|
+
export function bindLifecyclePacketAssignments(context, prompt, ids) {
|
|
268
|
+
const unique = [...new Set(ids)];
|
|
269
|
+
if (!unique.length)
|
|
270
|
+
return;
|
|
271
|
+
const digest = crypto.createHash("sha256").update(prompt).digest("hex");
|
|
272
|
+
context.db.writeTransaction(() => {
|
|
273
|
+
for (const id of unique) {
|
|
274
|
+
const row = load(context, id);
|
|
275
|
+
if (row.status !== "issued")
|
|
276
|
+
throw new AppError("invocation_packet_conflict", "Packet contains a lifecycle assignment already in flight", 1, { invocation_id: id });
|
|
277
|
+
context.db.run("INSERT OR IGNORE INTO lifecycle_packet_assignments (packet_sha256, invocation_id) VALUES (?, ?)", [digest, id]);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/** Stage renderers issue their commands before the final packet is assembled.
|
|
282
|
+
* Bind only issued commands visibly present in that exact published packet. */
|
|
283
|
+
export function bindLifecyclePublishedPacket(context, prompt) {
|
|
284
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
285
|
+
if (!scope)
|
|
286
|
+
return;
|
|
287
|
+
const issued = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'issued' ORDER BY rowid", [scope]);
|
|
288
|
+
const ids = issued.filter(row => prompt.includes(publicInvocationCommand(context, row, retainedCommandWithoutInvocationId(row)))).map(row => row.id);
|
|
289
|
+
bindLifecyclePacketAssignments(context, prompt, ids);
|
|
290
|
+
}
|
|
291
|
+
function retainedCommandWithoutInvocationId(row) {
|
|
292
|
+
const marker = ` --invocation-id ${row.id}`;
|
|
293
|
+
const at = row.command.indexOf(marker);
|
|
294
|
+
if (at < 0 || row.command.indexOf(marker, at + marker.length) >= 0)
|
|
295
|
+
throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
|
|
296
|
+
return `${row.command.slice(0, at)}${row.command.slice(at + marker.length)}`;
|
|
297
|
+
}
|
|
298
|
+
/** Resolve the small, declared alias vocabulary used by model-facing lifecycle commands.
|
|
299
|
+
* Both native-hook admission and CLI execution call this function so they cannot
|
|
300
|
+
* disagree about the command that an alias denotes. */
|
|
301
|
+
export function expandLifecycleInvocationArgs(context, args, scope, operation) {
|
|
302
|
+
const pathOptions = contextualPathOptionsForLifecycle(operation);
|
|
303
|
+
const run = scope.runId ? context.db.get("SELECT id, short_id, workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
304
|
+
const roots = { project: scope.projectRoot, workspace: run?.workspace_root, run: run?.run_root };
|
|
305
|
+
return resolveContextualPaths(args.map((value, index) => {
|
|
306
|
+
if (run && !args[index - 1]?.startsWith("--") && [run.short_id, normalizedInvocationValue("positional", run.id)].includes(value) && !operation.startsWith("work_"))
|
|
307
|
+
return run.id;
|
|
308
|
+
return value;
|
|
309
|
+
}), pathOptions, roots);
|
|
310
|
+
}
|
|
311
|
+
/** Validate alias spelling before native-receipt lookup, so a correctable
|
|
312
|
+
* command typo is never reported as missing infrastructure evidence. */
|
|
313
|
+
export function validateLifecyclePathAliases(args, operation) {
|
|
314
|
+
const pathOptions = contextualPathOptionsForLifecycle(operation);
|
|
315
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
316
|
+
const option = args[index - 1]?.replace(/^--/, "");
|
|
317
|
+
if (!option || !pathOptions.has(option))
|
|
318
|
+
continue;
|
|
319
|
+
const original = args[index];
|
|
320
|
+
const value = normalizedPathAlias(original);
|
|
321
|
+
if ((original.startsWith("\\") && original.replace(/^\\+/, "").startsWith("@") && value === original)
|
|
322
|
+
|| (value.startsWith("@") && !/^@(project|workspace|run)(?:\/.*)?$/.test(value))) {
|
|
323
|
+
throw new AppError("usage", `Unknown path alias: ${original}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function canonicalManagedCommand(context, command, scope) {
|
|
328
|
+
const parsed = invocation(command);
|
|
329
|
+
const argv = expandLifecycleInvocationArgs(context, parsed.argv, scope, parsed.operation);
|
|
330
|
+
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
331
|
+
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
332
|
+
const executable = launchExpression(parsed);
|
|
333
|
+
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
334
|
+
return `${prefix} ${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
335
|
+
}
|
|
336
|
+
function load(context, id) {
|
|
337
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
|
|
338
|
+
throw new AppError("invocation_unknown", "This runtime has no issued lifecycle attempt for the requested operation; use a current runtime-issued command without adding an invocation ID", 1, { invocation_id: id, effect: "no_effect" });
|
|
339
|
+
}
|
|
340
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
341
|
+
if (!row)
|
|
342
|
+
throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime; use a current runtime-issued command without adding an invocation ID", 1, { invocation_id: id, effect: "no_effect" });
|
|
343
|
+
return row;
|
|
344
|
+
}
|
|
345
|
+
export function lifecycleInvocationScope(context, id) {
|
|
346
|
+
return JSON.parse(load(context, id).scope_json);
|
|
347
|
+
}
|
|
348
|
+
/** Read-only controller observation of a retained lifecycle outcome. */
|
|
349
|
+
export function lifecycleInvocationOutcome(context, id) {
|
|
350
|
+
const row = load(context, id);
|
|
351
|
+
return { status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null };
|
|
352
|
+
}
|
|
353
|
+
/** Current failures are read from runtime authority, never from model text. */
|
|
354
|
+
export function assertLifecycleOutcomes(context, scope) {
|
|
355
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
356
|
+
const diagnostics = lifecycleDiagnostics(context, project.id, scope.runId, scope.generation);
|
|
357
|
+
if (fs.existsSync(diagnostics)) {
|
|
358
|
+
for (const name of fs.readdirSync(diagnostics).filter(name => name.endsWith(".json"))) {
|
|
359
|
+
const record = JSON.parse(fs.readFileSync(path.join(diagnostics, name), "utf8"));
|
|
360
|
+
if (record.project_id !== project.id || record.run_id !== scope.runId || record.generation !== scope.generation)
|
|
361
|
+
throw new AppError("lifecycle_outcome_conflict", "Lifecycle diagnostic scope differs", 1);
|
|
362
|
+
throw new AppError(record.error?.code ?? "lifecycle_outcome_unknown", record.error?.message ?? "Lifecycle settlement could not be persisted", 1, { ...record.error?.details, lifecycle_outcome: record, diagnostic_artifact: path.join(diagnostics, name) });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const columns = context.db.all("PRAGMA table_info(hook_events)");
|
|
366
|
+
if (columns.some(column => column.name === "outcome_json")) {
|
|
367
|
+
for (const row of context.db.all("SELECT outcome_json FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ? ORDER BY id", [project.id, scope.runId, scope.generation])) {
|
|
368
|
+
const outcome = JSON.parse(row.outcome_json);
|
|
369
|
+
if (outcome.disposition === "retrying" && outcome.deadline <= Date.now())
|
|
370
|
+
throw new AppError("lifecycle_outcome_unknown", "Storage retry has no terminal receipt; reconcile its committed evidence", 1, { lifecycle_outcome: outcome, effect: "unknown" });
|
|
371
|
+
if (outcome.disposition === "fatal" && !isRegisteredRepair(context, outcome.error?.details?.continuation, { projectId: project.id, runId: scope.runId }))
|
|
372
|
+
throw new AppError(outcome.error?.code ?? "lifecycle_outcome_unknown", outcome.error?.message || "Mandatory lifecycle failed (historical receipt has no message)", 1, { ...outcome.error?.details, lifecycle_outcome: outcome });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
376
|
+
return;
|
|
377
|
+
const rows = context.db.all(`SELECT a.* FROM lifecycle_invocations a
|
|
378
|
+
WHERE json_extract(a.scope_json, '$.projectRoot') = ? AND json_extract(a.scope_json, '$.runId') = ?
|
|
379
|
+
AND json_extract(a.scope_json, '$.generation') = ? AND a.status = 'settled'
|
|
380
|
+
AND NOT EXISTS (SELECT 1 FROM lifecycle_invocations newer WHERE newer.scope_json = a.scope_json AND newer.fingerprint = a.fingerprint AND newer.rowid > a.rowid)
|
|
381
|
+
ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
|
|
382
|
+
for (const row of rows) {
|
|
383
|
+
const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
|
|
384
|
+
if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details) && !isRegisteredRepair(context, error.details?.continuation, { projectId: project.id, runId: scope.runId }))
|
|
385
|
+
throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Hooks already own native identity. Retain command outcomes there, with an
|
|
389
|
+
* atomic controller notification; missing notifications reconcile from the row. */
|
|
390
|
+
export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
391
|
+
const parsed = parseLifecycleCommand(command);
|
|
392
|
+
const auxiliary = continuationCommand(command);
|
|
393
|
+
if (parsed.kind !== "standalone" && !auxiliary)
|
|
394
|
+
return () => { };
|
|
395
|
+
let anchor;
|
|
396
|
+
context.observeLifecycleHook = (projectId, eventId) => {
|
|
397
|
+
if (anchor)
|
|
398
|
+
return;
|
|
399
|
+
const event = context.db.get("SELECT session_id, provider_session_id, parent_session_id, daemon_id, harness FROM hook_events WHERE id = ? AND project_id = ?", [eventId, projectId]);
|
|
400
|
+
if (!event)
|
|
401
|
+
return;
|
|
402
|
+
let runId = context.db.get("SELECT run_id FROM sessions WHERE project_id = ? AND harness = ? AND (session_id = ? OR session_id = ? OR provider_session_id = ?) AND run_id IS NOT NULL ORDER BY session_id = ? DESC LIMIT 1", [projectId, event.harness, event.session_id, event.parent_session_id, event.parent_session_id, event.session_id])?.run_id;
|
|
403
|
+
let generation;
|
|
404
|
+
let correlation = {};
|
|
405
|
+
// A first Stage command precedes logical Session binding. Its native root
|
|
406
|
+
// is already owned by the controller's retained create receipt.
|
|
407
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")) {
|
|
408
|
+
const owners = context.db.all("SELECT run_id, generation, state_json FROM run_controllers WHERE project_id = ? AND status NOT IN ('completed','cancelled','superseded')", [projectId]).filter(owner => {
|
|
409
|
+
const state = JSON.parse(owner.state_json);
|
|
410
|
+
return state.sessions?.some(session => [event.provider_session_id ?? event.session_id, event.parent_session_id].includes(session.id) && runtimeHarness(session.profile) === event.harness);
|
|
411
|
+
});
|
|
412
|
+
if (owners.length > 1)
|
|
413
|
+
return;
|
|
414
|
+
if (owners.length === 1) {
|
|
415
|
+
if (runId && runId !== owners[0].run_id)
|
|
416
|
+
return;
|
|
417
|
+
runId = owners[0].run_id;
|
|
418
|
+
generation = owners[0].generation;
|
|
419
|
+
const current = JSON.parse(owners[0].state_json);
|
|
420
|
+
correlation = { stage: current.stage, ...(current.stage ? { attempt: stageAttempt(context, projectId, runId, current.stage) } : {}), controller_turn: current.turns };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (event.daemon_id) {
|
|
424
|
+
for (const record of managedProcessStatus(context)) {
|
|
425
|
+
if (record.project_id !== projectId || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
426
|
+
continue;
|
|
427
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
428
|
+
if (typeof metadata.state_dir !== "string")
|
|
429
|
+
continue;
|
|
430
|
+
let daemonId;
|
|
431
|
+
try {
|
|
432
|
+
daemonId = readNativeDaemonState(metadata.state_dir).daemon_id;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
continue;
|
|
436
|
+
} // Discovery of unrelated historical daemons is not authority.
|
|
437
|
+
if (daemonId !== event.daemon_id)
|
|
438
|
+
continue;
|
|
439
|
+
const binding = requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId, runId: record.run_id });
|
|
440
|
+
if (binding.state.daemon_id !== event.daemon_id)
|
|
441
|
+
continue;
|
|
442
|
+
runId = record.run_id;
|
|
443
|
+
generation = metadata.run_generation;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!runId)
|
|
448
|
+
return;
|
|
449
|
+
anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.kind === "standalone" ? parsed.invocation.operation : auxiliary.operation, ...correlation, ...(auxiliary ? { auxiliary: true } : {}) };
|
|
450
|
+
};
|
|
451
|
+
if (nativeEvent)
|
|
452
|
+
context.observeLifecycleHook(nativeEvent.project_id, nativeEvent.id);
|
|
453
|
+
else
|
|
454
|
+
observeCommandHook(context, command);
|
|
455
|
+
const invocationId = parsed.kind === "standalone" ? commandOption(parsed.invocation, "invocation-id") : undefined;
|
|
456
|
+
if (!anchor && invocationId && !nativeEvent) {
|
|
457
|
+
const attempt = load(context, invocationId);
|
|
458
|
+
checkCommand(attempt, command);
|
|
459
|
+
if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
|
|
460
|
+
const scope = JSON.parse(attempt.scope_json);
|
|
461
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
462
|
+
const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, attempt.event_key]);
|
|
463
|
+
if (event)
|
|
464
|
+
context.observeLifecycleHook(project.id, event.id);
|
|
465
|
+
}
|
|
466
|
+
if (!anchor)
|
|
467
|
+
throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { invocation_id: invocationId, effect: "no_effect" });
|
|
468
|
+
}
|
|
469
|
+
return outcome => {
|
|
470
|
+
delete context.observeLifecycleHook;
|
|
471
|
+
if (!anchor)
|
|
472
|
+
return;
|
|
473
|
+
let error = outcome.error ? errorRecord(outcome.error) : undefined;
|
|
474
|
+
// The exact committed event can diagnose a broken transport, but cannot
|
|
475
|
+
// grant execution authority or select an assignment by command similarity.
|
|
476
|
+
if (error && parsed.kind === "standalone" && (nativeEvent || commandOption(parsed.invocation, "hook-event-id"))) {
|
|
477
|
+
const event = context.db.get("SELECT event_key, daemon_id, provider_session_id FROM hook_events WHERE id = ? AND project_id = ?", [anchor.hook_event_id, anchor.project_id]);
|
|
478
|
+
const assigned = event && context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'lifecycle_invocations'")
|
|
479
|
+
? context.db.all("SELECT * FROM lifecycle_invocations WHERE event_key = ? AND status = 'observed'", [event.event_key]) : [];
|
|
480
|
+
if (assigned.length === 1 && event?.daemon_id && context.env.DD_FLOW_DAEMON_ID !== event.daemon_id) {
|
|
481
|
+
const scope = JSON.parse(assigned[0].scope_json);
|
|
482
|
+
const identity = assigned[0].identity_json ? JSON.parse(assigned[0].identity_json) : null;
|
|
483
|
+
const project = context.db.get("SELECT root FROM projects WHERE id = ?", [anchor.project_id]);
|
|
484
|
+
if (assigned[0].fingerprint === fingerprint(parsed.invocation) && scope.projectRoot === project?.root && scope.daemonId === event.daemon_id && scope.runId === anchor.run_id && scope.generation === anchor.generation
|
|
485
|
+
&& Array.isArray(identity) && identity[0] === event.daemon_id && identity[1] === scope.rootSessionId && identity[2] === (event.provider_session_id ?? anchor.session_id))
|
|
486
|
+
error = errorRecord(new AppError("invocation_scope_unproven", "Managed shell did not receive its native daemon binding", 1, {
|
|
487
|
+
reason: "managed_shell_context_missing", phase: "prepare", effect: "no_effect", recoverable: false,
|
|
488
|
+
cause: { code: error.code, parameter: error.details.parameter, reason: error.details.reason }
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const registeredContinuation = error && registeredLifecycleContinuation(context, error, anchor.project_id, anchor.run_id);
|
|
493
|
+
const retainedSuccessor = error && typeof error.details.retry_command === "string"
|
|
494
|
+
&& typeof error.details.rejection_signature === "string"
|
|
495
|
+
&& context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'lifecycle_invocations'")
|
|
496
|
+
&& context.db.get(`SELECT 1 FROM lifecycle_invocations a JOIN hook_events h ON h.event_key = a.event_key
|
|
497
|
+
JOIN projects p ON p.id = h.project_id WHERE h.id = ? AND h.project_id = ? AND a.status = 'settled'
|
|
498
|
+
AND json_extract(a.scope_json,'$.projectRoot') = p.root AND json_extract(a.scope_json,'$.runId') = ?
|
|
499
|
+
AND json_extract(a.scope_json,'$.generation') = ? AND json_extract(a.outcome_json,'$.error.details.retry_command') = ?
|
|
500
|
+
AND json_extract(a.outcome_json,'$.error.details.rejection_signature') = ?`, [anchor.hook_event_id, anchor.project_id, anchor.run_id, anchor.generation, error.details.retry_command, error.details.rejection_signature]);
|
|
501
|
+
const hookOnlyArgumentDefect = error && !invocationId && !context.env.DD_FLOW_INVOCATION_SCOPE
|
|
502
|
+
&& !retainedSuccessor
|
|
503
|
+
&& ["missing_required_option", "missing_option_value", "empty_option_value", "invalid_number", "invalid_choice", "duplicate_option"].includes(String(error.details.reason));
|
|
504
|
+
const hookOnlyCorrection = error && parsed.kind === "standalone" && !auxiliary && !invocationId && !context.env.DD_FLOW_INVOCATION_SCOPE
|
|
505
|
+
&& !retainedSuccessor && !hookOnlyArgumentDefect
|
|
506
|
+
&& error.details.effect === "no_effect" && isRetryableLifecycleRejection(error.code, error.details)
|
|
507
|
+
&& error.code !== "usage" && !["invocation_command_mismatch", "invocation_argument_mismatch"].includes(error.code)
|
|
508
|
+
&& parsed.invocation.argv.some(value => /^(?:--(?:(?:result|semantic|decision|verification|context|answer|intake|question|reason)-file|data))(?:=|$)/.test(value))
|
|
509
|
+
&& !isRegisteredRepair(context, error.details.continuation, { projectId: anchor.project_id, runId: anchor.run_id });
|
|
510
|
+
let correctionCwd;
|
|
511
|
+
if (error && hookOnlyCorrection) {
|
|
512
|
+
const receipt = context.db.get("SELECT sanitized_summary FROM hook_events WHERE id = ? AND project_id = ?", [anchor.hook_event_id, anchor.project_id]);
|
|
513
|
+
let payload = {};
|
|
514
|
+
try {
|
|
515
|
+
const decoded = JSON.parse(receipt?.sanitized_summary ?? "{}");
|
|
516
|
+
if (decoded && typeof decoded === "object" && !Array.isArray(decoded))
|
|
517
|
+
payload = decoded;
|
|
518
|
+
}
|
|
519
|
+
catch { /* Truncated summaries cannot supply a route. */ }
|
|
520
|
+
if (payload.lifecycle_execution_cwd !== undefined) {
|
|
521
|
+
let confirmed = false;
|
|
522
|
+
const cwd = payload.lifecycle_execution_cwd;
|
|
523
|
+
if (typeof cwd === "string" && path.isAbsolute(cwd) && payload.lifecycle_execution_cwd_provenance === "literal_cd_intent_requires_cli_confirmation") {
|
|
524
|
+
try {
|
|
525
|
+
confirmed = fs.realpathSync(cwd) === fs.realpathSync(process.cwd());
|
|
526
|
+
}
|
|
527
|
+
catch { /* No physical confirmation, no executable correction. */ }
|
|
528
|
+
}
|
|
529
|
+
if (confirmed)
|
|
530
|
+
correctionCwd = cwd;
|
|
531
|
+
else
|
|
532
|
+
error = errorRecord(new AppError("invocation_directory_mismatch", "Hook-only correction has no confirmed native execution directory", 1, {
|
|
533
|
+
reason: "literal_execution_cwd_unconfirmed", phase: "prepare", effect: "no_effect", recoverable: false,
|
|
534
|
+
cause: { code: error.code, parameter: error.details.parameter, reason: error.details.reason }
|
|
535
|
+
}));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const correctable = error && !hookOnlyArgumentDefect && error.details.recoverable !== false && error.details.effect !== "unknown"
|
|
539
|
+
&& (registeredContinuation || error.details.effect === "no_effect" && isCorrectableLifecycleRejection(error.code, error.details)
|
|
540
|
+
&& typeof error.details.retry_command === "string");
|
|
541
|
+
const correlationKey = parsed.kind === "standalone" ? fingerprint(parsed.invocation)
|
|
542
|
+
: JSON.stringify([auxiliary.operation, auxiliary.operation === "stage_block" ? auxiliary.args.positional[2] ?? null : auxiliary.args.options.get("run")?.[0] ?? null, auxiliary.args.options.get("stage")?.[0] ?? null]);
|
|
543
|
+
const record = { ...anchor, stage: anchor.stage ?? null, attempt: anchor.attempt ?? null, correlation_key: correlationKey, ...(parsed.kind === "standalone" ? { fingerprint: correlationKey } : {}), ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? auxiliary ? "pending_continuation" : correctable ? "correctable" : "fatal" : "completed" };
|
|
544
|
+
try {
|
|
545
|
+
context.db.writeTransaction(() => {
|
|
546
|
+
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
|
|
547
|
+
const previous = retained?.outcome_json ? JSON.parse(retained.outcome_json) : null;
|
|
548
|
+
if (previous?.disposition !== "retrying" && previous?.error && error && previous.correlation_key === correlationKey
|
|
549
|
+
&& preparationRejectionSignature(new AppError(previous.error.code, previous.error.message, 1, previous.error.details))
|
|
550
|
+
=== preparationRejectionSignature(new AppError(error.code, error.message, 1, error.details))) {
|
|
551
|
+
error = previous.error;
|
|
552
|
+
Object.assign(record, previous);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (error && parsed.kind === "standalone" && hookOnlyCorrection && error.details.recoverable !== false) {
|
|
556
|
+
const signature = preparationRejectionSignature(new AppError(error.code, error.message, 1, error.details));
|
|
557
|
+
error.details.hook_retry_signature = signature;
|
|
558
|
+
const repeated = context.db.get(`SELECT 1 FROM hook_events WHERE project_id = ? AND id <> ?
|
|
559
|
+
AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
|
|
560
|
+
AND json_extract(outcome_json, '$.operation') = ? AND json_extract(outcome_json, '$.fingerprint') = ?
|
|
561
|
+
AND json_extract(outcome_json, '$.harness') = ? AND json_extract(outcome_json, '$.session_id') = ?
|
|
562
|
+
AND json_type(outcome_json, '$.stage') IS NOT NULL AND json_type(outcome_json, '$.attempt') IS NOT NULL
|
|
563
|
+
AND json_extract(outcome_json, '$.stage') IS ? AND json_extract(outcome_json, '$.attempt') IS ?
|
|
564
|
+
AND json_extract(outcome_json, '$.error.details.hook_retry_signature') = ? LIMIT 1`, [anchor.project_id, anchor.hook_event_id, anchor.run_id, anchor.generation, anchor.operation, correlationKey,
|
|
565
|
+
anchor.harness, anchor.session_id, anchor.stage ?? null, anchor.attempt ?? null, signature]);
|
|
566
|
+
if (!repeated) {
|
|
567
|
+
const argv = parsed.invocation.argv.filter((value, index, all) => !/^--(?:hook-event-id|invocation-id)(?:=|$)/.test(value)
|
|
568
|
+
&& !/^--(?:hook-event-id|invocation-id)$/.test(all[index - 1] ?? ""));
|
|
569
|
+
error.details.retry_command = `${correctionCwd ? `cd ${shellArgument(correctionCwd)} && ` : ""}${runtimeCommand(context, argv)}`;
|
|
570
|
+
error.details.retry_instruction = "Correct the evidenced input, then execute the exact supplied retry_command once in this Session without changing its launch route. The rejected attempt had no lifecycle effect.";
|
|
571
|
+
error.details.recoverable = true;
|
|
572
|
+
record.disposition = "correctable";
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
error.details.recoverable = false;
|
|
576
|
+
error.details.repeated_without_progress = true;
|
|
577
|
+
record.disposition = "fatal";
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (error && !auxiliary && record.disposition === "fatal") {
|
|
581
|
+
error.details.recoverable = false;
|
|
582
|
+
delete error.details.retry_command;
|
|
583
|
+
delete error.details.retry_instruction;
|
|
584
|
+
}
|
|
585
|
+
if (previous?.storage_retry === 1)
|
|
586
|
+
Object.assign(record, { storage_retry: 1, work_id: previous.work_id });
|
|
587
|
+
const encoded = JSON.stringify(record);
|
|
588
|
+
if (retained?.outcome_json && previous?.disposition !== "retrying") {
|
|
589
|
+
if (retained.outcome_json !== encoded)
|
|
590
|
+
throw new AppError("lifecycle_outcome_conflict", "Hook outcome cannot change", 1, { hook_event_id: anchor.hook_event_id });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
context.db.run("UPDATE hook_events SET outcome_json = ? WHERE id = ?", [encoded, anchor.hook_event_id]);
|
|
594
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'"))
|
|
595
|
+
for (const controller of context.db.all("SELECT controller_id FROM run_controllers WHERE project_id = ? AND run_id = ? AND generation = ?", [anchor.project_id, anchor.run_id, anchor.generation]))
|
|
596
|
+
appendEvent(context, controller.controller_id, "lifecycle_outcome", record);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
catch (persistence) {
|
|
600
|
+
if (persistence instanceof AppError && persistence.code === "lifecycle_outcome_conflict")
|
|
601
|
+
throw persistence;
|
|
602
|
+
if (error) {
|
|
603
|
+
error.details.recoverable = false;
|
|
604
|
+
delete error.details.retry_command;
|
|
605
|
+
delete error.details.retry_instruction;
|
|
606
|
+
}
|
|
607
|
+
// Diagnostic evidence is not replay authority. If SQL is unavailable,
|
|
608
|
+
// the controller still sees this failure and fences instead of waiting
|
|
609
|
+
// for a model to report it or invent a new invocation.
|
|
610
|
+
const directory = lifecycleDiagnostics(context, anchor.project_id, anchor.run_id, anchor.generation);
|
|
611
|
+
try {
|
|
612
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
613
|
+
fs.writeFileSync(path.join(directory, `${anchor.hook_event_id}.json`), JSON.stringify({ ...record, disposition: "fatal", persistence_error: String(persistence) }), { flag: "wx", mode: 0o600, flush: true });
|
|
614
|
+
}
|
|
615
|
+
catch (diagnostic) {
|
|
616
|
+
if (diagnostic.code !== "EEXIST")
|
|
617
|
+
process.stderr.write(`${JSON.stringify({ primary: record.error ?? { effect: "committed" }, persistence: String(persistence), diagnostic: String(diagnostic) })}\n`);
|
|
618
|
+
}
|
|
619
|
+
throw persistence;
|
|
620
|
+
}
|
|
621
|
+
// Serializers intentionally clone JSON-safe diagnostics. These response
|
|
622
|
+
// fields are the deliberate API overlay on the original rejection, only
|
|
623
|
+
// after its exact durable hook outcome has committed.
|
|
624
|
+
if (outcome.error && error) {
|
|
625
|
+
outcome.error.code = error.code;
|
|
626
|
+
outcome.error.message = error.message;
|
|
627
|
+
Object.assign(outcome.error.details, error.details);
|
|
628
|
+
if (record.disposition === "fatal") {
|
|
629
|
+
delete outcome.error.details.retry_command;
|
|
630
|
+
delete outcome.error.details.retry_instruction;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function lifecycleDiagnostics(context, projectId, runId, generation) {
|
|
636
|
+
return path.join(context.ddFlowHome, "diagnostics", "lifecycle", encodeURIComponent(projectId), encodeURIComponent(runId), String(generation));
|
|
637
|
+
}
|
|
638
|
+
/** Current, explicitly-issued replacements for commands from the resumed stage.
|
|
639
|
+
* Original prompts remain immutable evidence; callers may present this small
|
|
640
|
+
* overlay after a state transition made an earlier no-effect rejection retryable. */
|
|
641
|
+
export function lifecycleRetryCommands(context, stage) {
|
|
642
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
643
|
+
if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
644
|
+
return [];
|
|
645
|
+
return [...new Set(context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
|
|
646
|
+
.flatMap((row) => {
|
|
647
|
+
try {
|
|
648
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
649
|
+
const retry = outcome?.error?.details?.retry_command;
|
|
650
|
+
const parsed = invocation(row.command);
|
|
651
|
+
if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
|
|
652
|
+
return [];
|
|
653
|
+
const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
|
|
654
|
+
return latest?.status === "issued" && (latest.deadline === null || latest.deadline > Date.now()) ? [retry] : [];
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return [];
|
|
658
|
+
}
|
|
659
|
+
}))];
|
|
660
|
+
}
|
|
661
|
+
export function managedInvocationContext(context, input) {
|
|
662
|
+
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
663
|
+
if (!["zcode-acp", "codex-desktop", "grok-acp", "antigravity-cli", "droid-cli", "opencode-server"].includes(input.harness))
|
|
664
|
+
return { ...context, env };
|
|
665
|
+
const project = requireProjectByRoot(context, input.projectRoot);
|
|
666
|
+
const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
|
|
667
|
+
if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
|
|
668
|
+
throw new AppError("invocation_generation_stale", "Managed daemon belongs to another RUN generation", 1);
|
|
669
|
+
const session = state.sessions?.find(session => typeof session === "string" ? session === input.sessionId : session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
|
|
670
|
+
// Codex owns a single root Session per controller and persists that root as
|
|
671
|
+
// a string. Child agent_id values arrive later through the native hook and
|
|
672
|
+
// are checked against this root scope; they are not controller Sessions.
|
|
673
|
+
const rootSessionId = input.harness !== "zcode-acp"
|
|
674
|
+
? (typeof session === "string" ? session : session?.root_provider_session_id ?? session?.provider_session_id ?? null)
|
|
675
|
+
: (typeof session === "string" ? null : session?.root_provider_session_id ?? null);
|
|
676
|
+
if (!state.daemon_id || !rootSessionId)
|
|
677
|
+
throw new AppError("invocation_scope_unproven", "Managed native Session has no persisted native root binding", 1);
|
|
678
|
+
const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId, runId: input.runId, generation: input.generation };
|
|
679
|
+
env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
|
|
680
|
+
return { ...context, env };
|
|
681
|
+
}
|
|
682
|
+
/** Generated commands retain their attempt across repeated rendering. Execution
|
|
683
|
+
* can issue a retry; accepted HITL intent can replace an unobserved placeholder
|
|
684
|
+
* or issue the next pause's assignment after successful settlement. */
|
|
685
|
+
export function managedLifecycleCommand(context, command, options = {}) {
|
|
686
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
687
|
+
if (!configured)
|
|
688
|
+
return command;
|
|
689
|
+
const scope = JSON.parse(configured);
|
|
690
|
+
command = canonicalManagedCommand(context, command, scope);
|
|
691
|
+
const parsed = invocation(command);
|
|
692
|
+
if (options.freshWorkAttempt && !["work_finish", "work_fail"].includes(parsed.operation))
|
|
693
|
+
throw new AppError("invocation_command_mismatch", "Fresh Work packet may only issue Work settlement commands", 1);
|
|
694
|
+
const suppliedId = commandOption(parsed, "invocation-id");
|
|
695
|
+
// An invocation id is authority, never decorative text. In particular,
|
|
696
|
+
// callers that add arguments after rendering must not be able to reuse an
|
|
697
|
+
// issued id for a different command.
|
|
698
|
+
if (suppliedId) {
|
|
699
|
+
const retained = load(context, suppliedId);
|
|
700
|
+
if (retained.scope_json !== JSON.stringify(scope)) {
|
|
701
|
+
throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
|
|
702
|
+
}
|
|
703
|
+
assertPrivateStageStartContract(retained, command);
|
|
704
|
+
checkCommand(retained, command);
|
|
705
|
+
assertRenderableInvocation(retained);
|
|
706
|
+
return publicInvocationCommand(context, retained, command);
|
|
707
|
+
}
|
|
708
|
+
if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
|
|
709
|
+
const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
|
|
710
|
+
? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
|
|
711
|
+
if (!retained)
|
|
712
|
+
throw new AppError("invocation_command_unprepared", "The controller has not issued this lifecycle operation yet. End this Turn and await its next assignment; do not add or reuse an internal invocation ID.", 1);
|
|
713
|
+
return publicInvocationCommand(context, retained, command);
|
|
714
|
+
}
|
|
715
|
+
storage(context);
|
|
716
|
+
return context.db.writeTransaction(() => {
|
|
717
|
+
const prior = options.freshWorkAttempt ? undefined : context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
|
|
718
|
+
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
719
|
+
const replacesAnswer = options.replaceUnobservedAnswer && parsed.operation === "stage_resume"
|
|
720
|
+
&& (prior.status === "settled" || commandOption(invocation(prior.command), "answer-file") !== commandOption(parsed, "answer-file"));
|
|
721
|
+
if (!replacesAnswer)
|
|
722
|
+
return publicInvocationCommand(context, prior, command);
|
|
723
|
+
// The pause packet can prepare a placeholder before the controller has
|
|
724
|
+
// accepted an answer. Replace only an unobserved assignment; never change
|
|
725
|
+
// the authority of a native call already in flight.
|
|
726
|
+
const completed = prior.status === "settled" && prior.outcome_json && !JSON.parse(prior.outcome_json).error;
|
|
727
|
+
if (prior.status !== "issued" && !completed)
|
|
728
|
+
throw new AppError("invocation_answer_conflict", "Cannot replace an answer while its lifecycle assignment is in flight or failed", 1, { invocation_id: prior.id });
|
|
729
|
+
if (prior.status === "issued")
|
|
730
|
+
context.db.run("UPDATE lifecycle_invocations SET status = 'expired' WHERE id = ?", [prior.id]);
|
|
731
|
+
}
|
|
732
|
+
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
733
|
+
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
734
|
+
const issued = issueLifecycleInvocation(context, command, scope);
|
|
735
|
+
return publicInvocationCommand(context, load(context, issued.id), command);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
export function retryLifecycleInvocationCommand(context, id, checkReceiptId) {
|
|
739
|
+
const prior = load(context, id);
|
|
740
|
+
if (prior.status !== "executing")
|
|
741
|
+
throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
|
|
742
|
+
return successorLifecycleInvocationCommand(context, prior, checkReceiptId);
|
|
743
|
+
}
|
|
744
|
+
/** A predecessor may publish exactly one successor only while the caller has
|
|
745
|
+
* proved that no lifecycle effect was admitted. Shared by CLI argument
|
|
746
|
+
* validation and conclusive lifecycle/result rejection, never by hooks. */
|
|
747
|
+
function successorLifecycleInvocationCommand(context, prior, checkReceiptId) {
|
|
748
|
+
const parsed = invocation(prior.command);
|
|
749
|
+
const argv = [...parsed.argv];
|
|
750
|
+
const at = argv.indexOf("--invocation-id");
|
|
751
|
+
if (at < 0 || argv[at + 1] !== prior.id)
|
|
752
|
+
throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
|
|
753
|
+
// The parser proves the marker belongs to argv. Remove that one rendered
|
|
754
|
+
// token from the retained text so response-file/stdin/heredoc presentation
|
|
755
|
+
// remains byte-for-byte stable across a retry.
|
|
756
|
+
let command = retainedCommandWithoutInvocationId(prior);
|
|
757
|
+
if (checkReceiptId) {
|
|
758
|
+
// Keep the trusted route and all stdin/redirection bytes. Replace only
|
|
759
|
+
// already-parsed literal retry options; ambiguous rendering fails closed.
|
|
760
|
+
let retained = prior.command;
|
|
761
|
+
for (const key of ["retry-check", "reason", "reason-file"]) {
|
|
762
|
+
const value = commandOption(parsed, key);
|
|
763
|
+
if (value === undefined)
|
|
764
|
+
continue;
|
|
765
|
+
const spellings = [...new Set([shellArgument(value), quote([value]), JSON.stringify(value)])];
|
|
766
|
+
const fragments = spellings.flatMap(rendered => [` --${key} ${rendered}`, ` --${key}=${rendered}`]).filter(fragment => retained.includes(fragment));
|
|
767
|
+
if (fragments.length !== 1 || retained.indexOf(fragments[0]) !== retained.lastIndexOf(fragments[0]))
|
|
768
|
+
throw new AppError("invocation_command_invalid", "Retained retry option cannot be rewritten unambiguously", 1);
|
|
769
|
+
retained = retained.replace(fragments[0], "");
|
|
770
|
+
}
|
|
771
|
+
command = retained.replace(` --invocation-id ${prior.id}`, ` --retry-check ${shellArgument(checkReceiptId)} --reason ${shellArgument("Recheck after candidate-source correction")}`);
|
|
772
|
+
}
|
|
773
|
+
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
|
|
774
|
+
}
|
|
775
|
+
// Caller holds the write transaction and has checked authority. Issued (or
|
|
776
|
+
// legacy expired) attempts have never admitted an effect; observed/executing
|
|
777
|
+
// attempts must never receive an automatic replacement.
|
|
778
|
+
function settleUnadmittedInvocation(context, prior, error) {
|
|
779
|
+
if (!["issued", "expired"].includes(prior.status))
|
|
780
|
+
throw new AppError("invocation_rejection_conflict", "Attempt already admitted native execution", 1);
|
|
781
|
+
const details = { ...error.details, effect: "no_effect", recoverable: false };
|
|
782
|
+
if (context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = ?", [JSON.stringify({ error: { ...error, details } }), context.now(), prior.id, prior.status]).changes !== 1) {
|
|
783
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle rejection could not be committed", 1, { invocation_id: prior.id });
|
|
784
|
+
}
|
|
785
|
+
return details;
|
|
786
|
+
}
|
|
787
|
+
const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "No native confirmation before the receipt deadline; lifecycle state was not changed", exitCode: 1, details: { invocation_id: id } });
|
|
788
|
+
/** Settle a conclusive rejection and publish its only legal successor atomically.
|
|
789
|
+
* A crash must leave either the old attempt executing, or both the settled
|
|
790
|
+
* outcome and its successor; never a usable successor beside an unknown effect. */
|
|
791
|
+
export function settleLifecycleRejection(context, id, error) {
|
|
792
|
+
storage(context);
|
|
793
|
+
// Only this settlement may publish a successor; inherited help is not authority.
|
|
794
|
+
delete error.details.retry_command;
|
|
795
|
+
delete error.details.retry_instruction;
|
|
796
|
+
const details = { ...error.details };
|
|
797
|
+
try {
|
|
798
|
+
context.db.writeTransaction(() => {
|
|
799
|
+
const prior = load(context, id);
|
|
800
|
+
if (prior.status !== "executing")
|
|
801
|
+
throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
|
|
802
|
+
const storageRetry = error.code === "storage_write_failed" && error.details.effect === "no_effect"
|
|
803
|
+
&& !context.db.get("SELECT 1 FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND json_extract(outcome_json, '$.error.details.storage_retry') = 1 LIMIT 1", [prior.scope_json, prior.fingerprint]);
|
|
804
|
+
if (isRetryableLifecycleRejection(error.code, error.details) || storageRetry) {
|
|
805
|
+
const failures = ["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed"].includes(error.code) ? error.details.failures : undefined;
|
|
806
|
+
const retry = retryLifecycleInvocationCommand(context, id, failures?.[0]?.id);
|
|
807
|
+
details.retry_command = retry;
|
|
808
|
+
details.effect = "no_effect";
|
|
809
|
+
details.recoverable = true;
|
|
810
|
+
if (storageRetry)
|
|
811
|
+
details.storage_retry = 1;
|
|
812
|
+
details.retry_instruction = isRegisteredRepair(context, details.continuation)
|
|
813
|
+
? "The repair is already registered. End this coordinator turn so the controller can launch its fresh child. Use retry_command only after that repair completes, with fresh semantic verification."
|
|
814
|
+
: "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
815
|
+
if (["memory_review_corrections_required", "memory_correction_checks_invalid"].includes(error.code) && typeof details.correction_instruction === "string")
|
|
816
|
+
details.retry_instruction = details.correction_instruction;
|
|
817
|
+
}
|
|
818
|
+
else if (isCorrectableLifecycleRejection(error.code, error.details)
|
|
819
|
+
&& registeredLifecycleContinuation(context, error, requireProjectByRoot(context, JSON.parse(prior.scope_json).projectRoot).id, JSON.parse(prior.scope_json).runId)) {
|
|
820
|
+
details.effect = "no_effect";
|
|
821
|
+
details.recoverable = true;
|
|
822
|
+
details.handoff_required = true;
|
|
823
|
+
details.recovery_instruction = "Do not retry this command in the current Session. End the coordinator Turn; the controller will launch the already-registered Work in a fresh child Session.";
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
details.recoverable = false;
|
|
827
|
+
}
|
|
828
|
+
settleLifecycleInvocation(context, id, { error: { ...error, details } });
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
catch (persistence) {
|
|
832
|
+
error.details.recoverable = false;
|
|
833
|
+
throw persistence;
|
|
834
|
+
}
|
|
835
|
+
Object.assign(error.details, details);
|
|
836
|
+
}
|
|
837
|
+
/** Recovery preserves the original packet as evidence. Only its retained,
|
|
838
|
+
* never-executed commands receive new authority; unknown effects never replay. */
|
|
839
|
+
export function hasLifecyclePacketAssignments(context, originalPrompt) {
|
|
840
|
+
const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
|
|
841
|
+
return Boolean(context.db.get("SELECT 1 FROM lifecycle_packet_assignments WHERE packet_sha256 = ? LIMIT 1", [digest]));
|
|
842
|
+
}
|
|
843
|
+
export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
844
|
+
const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
|
|
845
|
+
const ids = context.db.all("SELECT invocation_id FROM lifecycle_packet_assignments WHERE packet_sha256 = ? ORDER BY rowid", [digest]).map(row => row.invocation_id);
|
|
846
|
+
if (!ids.length)
|
|
847
|
+
throw new AppError("invocation_recovery_unbound", "Original packet has no verified lifecycle assignment binding; reconcile it before continuing", 1);
|
|
848
|
+
const recovered = [];
|
|
849
|
+
for (const id of ids) {
|
|
850
|
+
let prior = load(context, id);
|
|
851
|
+
const oldScope = JSON.parse(prior.scope_json);
|
|
852
|
+
if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
|
|
853
|
+
|| oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
|
|
854
|
+
throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
|
|
855
|
+
}
|
|
856
|
+
// The immutable prompt can predate several recoveries. Follow the newest
|
|
857
|
+
// retained attempt, including an unknown successor, rather than reviving
|
|
858
|
+
// an older unexecuted command after the successor already produced effects.
|
|
859
|
+
prior = context.db.get(`SELECT * FROM lifecycle_invocations WHERE fingerprint = ?
|
|
860
|
+
AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') IS ?
|
|
861
|
+
AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ?
|
|
862
|
+
ORDER BY json_extract(scope_json, '$.generation') DESC, rowid DESC LIMIT 1`, [prior.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation]);
|
|
863
|
+
if (prior.status === "executing" || prior.status === "settled") {
|
|
864
|
+
const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
|
|
865
|
+
recovered.push({ invocation_id: id, status: prior.status, outcome,
|
|
866
|
+
instruction: prior.status === "executing" ? "Outcome unknown: reconcile retained operation evidence; do not reissue this command." : "Read the retained outcome; do not repeat completed effects." });
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
const parsed = invocation(prior.command);
|
|
870
|
+
const argv = [...parsed.argv];
|
|
871
|
+
const at = argv.indexOf("--invocation-id");
|
|
872
|
+
if (at < 0 || argv[at + 1] !== prior.id)
|
|
873
|
+
throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
|
|
874
|
+
argv.splice(at, 2);
|
|
875
|
+
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
876
|
+
const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${launchExpression(parsed)} ${quote(argv)} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
|
|
877
|
+
const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
|
|
878
|
+
recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
|
|
879
|
+
}
|
|
880
|
+
return recovered;
|
|
881
|
+
}
|
|
882
|
+
export function assertLifecycleInvocationCurrent(context, scope, command) {
|
|
883
|
+
const parsed = invocation(command);
|
|
884
|
+
if (!scope.runId) {
|
|
885
|
+
if ((parsed.operation === "stage_start" && parsed.args.options.has("bootstrap")) || parsed.operation === "session_register")
|
|
886
|
+
return;
|
|
887
|
+
throw new AppError("invocation_scope_invalid", "This lifecycle operation requires an issued RUN scope", 1);
|
|
888
|
+
}
|
|
889
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
890
|
+
let workId;
|
|
891
|
+
if (parsed.operation.startsWith("work_")) {
|
|
892
|
+
const target = parsed.args.positional[0] ?? "";
|
|
893
|
+
try {
|
|
894
|
+
workId = resolveWorkReference(context, target, { projectId: project.id, runId: scope.runId }).work_id;
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
|
|
898
|
+
throw error;
|
|
899
|
+
throw new AppError("invocation_scope_mismatch", "Work does not belong unambiguously to the issued RUN scope", 1);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
else if (parsed.operation.startsWith("merge_")) {
|
|
903
|
+
const request = context.db.get("SELECT project_id, run_id FROM merge_requests WHERE merge_request_id = ?", [parsed.args.positional[0] ?? ""]);
|
|
904
|
+
if (!request || request.project_id !== project.id || request.run_id !== scope.runId)
|
|
905
|
+
throw new AppError("invocation_scope_mismatch", "MERGE request does not belong to the issued RUN scope", 1);
|
|
906
|
+
}
|
|
907
|
+
else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
|
|
908
|
+
const target = parsed.args.positional[0];
|
|
909
|
+
const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
|
|
910
|
+
if (run?.id !== scope.runId)
|
|
911
|
+
throw new AppError("invocation_scope_mismatch", "Command does not belong to the issued RUN scope", 1);
|
|
912
|
+
}
|
|
913
|
+
const guard = recoveryGuard(context, project.id, scope.runId);
|
|
914
|
+
// A cooperative pause advances the fence but explicitly admits settlement
|
|
915
|
+
// of the captured WorkSession/Stage attempt. Reuse that exact authority;
|
|
916
|
+
// never turn the invocation layer into a stricter, divergent pause policy.
|
|
917
|
+
if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
|
|
918
|
+
if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
|
|
919
|
+
return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
|
|
920
|
+
if (parsed.operation === "stage_finish") {
|
|
921
|
+
assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if ((guard?.generation ?? 0) !== scope.generation)
|
|
926
|
+
throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
|
|
927
|
+
if (parsed.operation !== "recovery_accept")
|
|
928
|
+
assertRunMutationAllowed(context, project.id, scope.runId);
|
|
929
|
+
}
|
|
930
|
+
function checkCommand(row, command) {
|
|
931
|
+
const parsed = invocation(command);
|
|
932
|
+
assertSuppliedLifecycleTarget(row, parsed);
|
|
933
|
+
const scope = JSON.parse(row.scope_json);
|
|
934
|
+
if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
|
|
935
|
+
|| path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
|
|
936
|
+
throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/** Public commands may omit the coordinator RUN. A supplied target remains
|
|
940
|
+
* input to validate, not text to silently overwrite with retained authority. */
|
|
941
|
+
function assertSuppliedLifecycleTarget(row, actual) {
|
|
942
|
+
if (!["stage_start", "stage_finish", "recovery_accept"].includes(actual.operation) || actual.args.options.has("bootstrap"))
|
|
943
|
+
return;
|
|
944
|
+
const supplied = commandPosition(actual, 0), expected = commandPosition(invocation(row.command), 0);
|
|
945
|
+
if (supplied === undefined)
|
|
946
|
+
return;
|
|
947
|
+
if (!expected || ![expected, shortEntityReference(expected)].includes(supplied))
|
|
948
|
+
throw new AppError("invocation_argument_mismatch", "Supplied RUN target differs from its retained lifecycle assignment", 2, { invocation_id: row.id, parameter: "run", phase: "prepare", effect: "no_effect", recoverable: true });
|
|
949
|
+
}
|
|
950
|
+
/** Explicit issuance; callers retain this command. Read-only show/status must
|
|
951
|
+
* return that retained value, not issue a replacement on every observation. */
|
|
952
|
+
export function prepareLifecycleIssuance(command, value) {
|
|
953
|
+
if (typeof command !== "string" || !command.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
954
|
+
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires command and managed scope", 1);
|
|
955
|
+
}
|
|
956
|
+
const scope = value;
|
|
957
|
+
const parsed = invocation(command);
|
|
958
|
+
if (typeof scope.daemonId !== "string" || !scope.daemonId.trim() || typeof scope.rootSessionId !== "string" || !scope.rootSessionId.trim() || !Number.isSafeInteger(scope.generation) || scope.generation < 0
|
|
959
|
+
|| (scope.runId !== null && (typeof scope.runId !== "string" || !scope.runId.trim()))
|
|
960
|
+
|| typeof scope.projectRoot !== "string" || !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
|
|
961
|
+
|| commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
|
|
962
|
+
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
|
|
963
|
+
}
|
|
964
|
+
return { command, scope, parsed };
|
|
965
|
+
}
|
|
966
|
+
export function issueLifecycleInvocation(context, command, scope) {
|
|
967
|
+
const { parsed } = prepareLifecycleIssuance(command, scope);
|
|
968
|
+
storage(context);
|
|
969
|
+
const id = crypto.randomUUID();
|
|
970
|
+
const prepared = commandWithInvocationId(command, id);
|
|
971
|
+
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
972
|
+
const row = { id, command: prepared, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
973
|
+
checkCommand(row, prepared);
|
|
974
|
+
context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
|
|
975
|
+
return { id, command: prepared };
|
|
976
|
+
}
|
|
977
|
+
/** Prepare outside a write transaction; the Work retry CAS owns issuance. */
|
|
978
|
+
export function prepareWorkStartAuthority(context, command, previousId) {
|
|
979
|
+
const prior = previousId ? load(context, previousId) : null;
|
|
980
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
981
|
+
if (!configured && !prior)
|
|
982
|
+
return null;
|
|
983
|
+
const scope = JSON.parse(configured ?? prior.scope_json);
|
|
984
|
+
if (prior && prior.scope_json !== JSON.stringify(scope))
|
|
985
|
+
throw new AppError("invocation_scope_mismatch", "Work start belongs to another controller scope", 1);
|
|
986
|
+
command = canonicalManagedCommand(context, command, scope);
|
|
987
|
+
const { parsed } = prepareLifecycleIssuance(command, scope);
|
|
988
|
+
if (parsed.operation !== "work_start")
|
|
989
|
+
throw new AppError("invocation_command_mismatch", "Work authority must start its Work", 1);
|
|
990
|
+
if (prior && prior.fingerprint !== fingerprint(parsed))
|
|
991
|
+
throw new AppError("invocation_command_mismatch", "Retained Work start authority belongs to a different Work", 1, { invocation_id: prior.id });
|
|
992
|
+
assertLifecycleInvocationCurrent(context, scope, command);
|
|
993
|
+
return { command, scope };
|
|
994
|
+
}
|
|
995
|
+
/** Caller owns the retry CAS. Preserve settled outcomes; never steal an
|
|
996
|
+
* observed/executing terminal operation from the previous Work attempt. */
|
|
997
|
+
export function retireWorkPacketAuthorities(context, workId) {
|
|
998
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'lifecycle_packet_assignments'"))
|
|
999
|
+
return;
|
|
1000
|
+
const attempts = context.db.all("SELECT DISTINCT li.* FROM lifecycle_invocations li JOIN lifecycle_packet_assignments pa ON pa.invocation_id=li.id JOIN work_sessions ws ON pa.packet_sha256=json_extract(ws.start_receipt_json,'$.prompt_sha256') WHERE ws.work_id=?", [workId]);
|
|
1001
|
+
for (const attempt of attempts) {
|
|
1002
|
+
if (!["work_finish", "work_fail"].includes(invocation(attempt.command).operation))
|
|
1003
|
+
continue;
|
|
1004
|
+
if (["observed", "executing"].includes(attempt.status))
|
|
1005
|
+
throw new AppError("work_retry_incomplete", "Previous Work settlement still has native authority in flight", 1, { work_id: workId, invocation_id: attempt.id });
|
|
1006
|
+
if (attempt.status === "issued")
|
|
1007
|
+
context.db.run("UPDATE lifecycle_invocations SET status='expired',updated_at=? WHERE id=? AND status='issued'", [context.now(), attempt.id]);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
function commandWithInvocationId(command, id) {
|
|
1011
|
+
const at = shellSuffixOffset(command);
|
|
1012
|
+
const suffix = command.slice(at);
|
|
1013
|
+
return `${command.slice(0, at)} --invocation-id ${id}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
|
|
1014
|
+
}
|
|
1015
|
+
/** The attempt owns authority; the caller owns presentation-only shell input.
|
|
1016
|
+
* Preserve current stdin/heredoc/response-file text while validating that it
|
|
1017
|
+
* still names the exact persisted operation and project. */
|
|
1018
|
+
function assertRenderableInvocation(row) {
|
|
1019
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
1020
|
+
if (row.status === "settled" && outcome?.error)
|
|
1021
|
+
throw new AppError("invocation_command_blocked", "Failed lifecycle authority cannot be advertised as an executable command", 1, { invocation_id: row.id, outcome });
|
|
1022
|
+
if (row.status === "expired" || row.status === "issued" && row.deadline !== null && row.deadline <= Date.now()) {
|
|
1023
|
+
throw new AppError("invocation_expired", "Retained lifecycle attempt has expired; inspection cannot grant a new attempt", 1, { invocation_id: row.id, status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null });
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
function renderInvocationCommand(row, command) {
|
|
1027
|
+
assertRenderableInvocation(row);
|
|
1028
|
+
const parsed = invocation(command);
|
|
1029
|
+
if (commandOption(parsed, "invocation-id")) {
|
|
1030
|
+
checkCommand(row, command);
|
|
1031
|
+
return command;
|
|
1032
|
+
}
|
|
1033
|
+
const rendered = commandWithInvocationId(command, row.id);
|
|
1034
|
+
checkCommand(row, rendered);
|
|
1035
|
+
return rendered;
|
|
1036
|
+
}
|
|
1037
|
+
/** Native observer and receipt writer commit together. Never await I/O inside
|
|
1038
|
+
* this transaction; the callback must write to this same SQLite connection. */
|
|
1039
|
+
function checkNativeIdentity(scope, identity, id) {
|
|
1040
|
+
if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
|
|
1041
|
+
|| !identity.sessionId || !identity.toolCallId
|
|
1042
|
+
|| (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
|
|
1043
|
+
throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: id });
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function managedNativeOwner(context, identity, harness, projectId) {
|
|
1047
|
+
const nativeRoot = (stateDir, sessionId) => {
|
|
1048
|
+
try {
|
|
1049
|
+
const state = readNativeDaemonState(stateDir);
|
|
1050
|
+
if (state.daemon_id !== identity.daemonId)
|
|
1051
|
+
return null;
|
|
1052
|
+
const native = state.sessions?.find(native => typeof native === "string" ? native === sessionId : native.adapter_session_id === sessionId || native.provider_session_id === sessionId);
|
|
1053
|
+
return typeof native === "string" ? native : native?.root_provider_session_id ?? native?.provider_session_id ?? null;
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
const owners = [];
|
|
1060
|
+
const hasTable = (name) => Boolean(context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]));
|
|
1061
|
+
if (hasTable("run_controllers")) {
|
|
1062
|
+
const controllers = context.db.all("SELECT c.controller_id, c.run_id, r.status AS run_status, c.status, c.generation, c.state_json FROM run_controllers c JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id WHERE c.project_id = ?", [projectId]);
|
|
1063
|
+
for (const controller of controllers) {
|
|
1064
|
+
const state = JSON.parse(controller.state_json);
|
|
1065
|
+
for (const [index, session] of (state.sessions ?? []).entries()) {
|
|
1066
|
+
if (typeof session.state_dir !== "string")
|
|
1067
|
+
continue;
|
|
1068
|
+
const root = nativeRoot(session.state_dir, session.id);
|
|
1069
|
+
if (root !== identity.rootSessionId)
|
|
1070
|
+
continue;
|
|
1071
|
+
owners.push({ runId: controller.run_id, rootSessionId: session.id,
|
|
1072
|
+
stateDir: session.state_dir, harness: runtimeHarness(session.profile), generation: controller.generation, ownerId: controller.controller_id,
|
|
1073
|
+
active: ["running", "paused", "waiting_for_user", "blocked"].includes(controller.run_status)
|
|
1074
|
+
&& !["completed", "cancelled", "superseded"].includes(controller.status)
|
|
1075
|
+
&& state.current_session === index && session.stopped !== true });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (hasTable("work_launches")) {
|
|
1080
|
+
const launches = context.db.all("SELECT w.run_id, r.status AS run_status, l.work_id, l.operation_id, l.status, l.state_dir, l.provider_session_id, l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id JOIN runs r ON r.project_id = w.project_id AND r.id = w.run_id WHERE w.project_id = ?", [projectId]);
|
|
1081
|
+
for (const launch of launches) {
|
|
1082
|
+
if (nativeRoot(launch.state_dir, launch.provider_session_id) !== identity.rootSessionId)
|
|
1083
|
+
continue;
|
|
1084
|
+
// Cooperative pause/drain may retain an admitted Work settlement while
|
|
1085
|
+
// the RUN is paused; only terminal RUN states retire its native root.
|
|
1086
|
+
const active = ["running", "paused", "waiting_for_user", "blocked"].includes(launch.run_status)
|
|
1087
|
+
&& ["launching", "start_requested"].includes(launch.status);
|
|
1088
|
+
if (!active)
|
|
1089
|
+
throw new AppError("invocation_scope_unproven", "External Work native root is no longer active", 1, { effect: "no_effect" });
|
|
1090
|
+
const profile = JSON.parse(launch.profile_json);
|
|
1091
|
+
const owner = profile.runtime_adapter?.owner;
|
|
1092
|
+
if (!owner || owner.owner_id !== `run:${launch.run_id}:work:${launch.work_id}` || owner.project_id !== projectId
|
|
1093
|
+
|| owner.run_id !== launch.run_id || owner.work_id !== launch.work_id || owner.operation_id !== launch.operation_id || owner.state_dir !== launch.state_dir) {
|
|
1094
|
+
throw new AppError("invocation_scope_unproven", "External Work has no matching persisted runtime owner", 1, { effect: "no_effect" });
|
|
1095
|
+
}
|
|
1096
|
+
owners.push({ runId: launch.run_id, rootSessionId: launch.provider_session_id, stateDir: launch.state_dir,
|
|
1097
|
+
harness: runtimeHarness(profile.worker), ownerId: owner.owner_id, active });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (hasTable("merge_requests")) {
|
|
1101
|
+
const requests = context.db.all("SELECT m.merge_request_id, m.run_id, r.status AS run_status, m.status, m.executor_work_id, m.dispatch_owner, m.dispatch_lease_token, m.adapter_receipt_json FROM merge_requests m JOIN runs r ON r.project_id = m.project_id AND r.id = m.run_id WHERE m.project_id = ? AND m.execution_route = 'server' AND json_valid(m.adapter_receipt_json)", [projectId]);
|
|
1102
|
+
for (const request of requests) {
|
|
1103
|
+
const retained = JSON.parse(request.adapter_receipt_json);
|
|
1104
|
+
if (!retained.session_id || !retained.state_dir || nativeRoot(retained.state_dir, retained.session_id) !== identity.rootSessionId)
|
|
1105
|
+
continue;
|
|
1106
|
+
const active = ["running", "paused", "waiting_for_user", "blocked"].includes(request.run_status)
|
|
1107
|
+
&& ["dispatching", "active", "waiting_user", "action_required", "recovery_required"].includes(request.status);
|
|
1108
|
+
if (!active)
|
|
1109
|
+
throw new AppError("invocation_scope_unproven", "Server MERGE native root is no longer active", 1, { effect: "no_effect" });
|
|
1110
|
+
const receipt = JSON.parse(request.adapter_receipt_json);
|
|
1111
|
+
if (receipt.controller_id) {
|
|
1112
|
+
const controller = context.db.get("SELECT generation, status, state_json FROM run_controllers WHERE controller_id = ? AND project_id = ? AND run_id = ?", [receipt.controller_id, projectId, request.run_id]);
|
|
1113
|
+
const state = controller && JSON.parse(controller.state_json);
|
|
1114
|
+
const session = state?.current_session === undefined || state.current_session === null ? undefined : state.sessions?.[state.current_session];
|
|
1115
|
+
if (!request.dispatch_lease_token || request.dispatch_owner !== receipt.controller_id
|
|
1116
|
+
|| !controller || ["completed", "cancelled", "superseded"].includes(controller.status)
|
|
1117
|
+
|| state?.merge_claim?.request_id !== request.merge_request_id || state.merge_claim.token !== request.dispatch_lease_token
|
|
1118
|
+
|| state.merge_claim.work_id !== request.executor_work_id || !session || session.stopped === true
|
|
1119
|
+
|| session.id !== receipt.session_id || session.state_dir !== receipt.state_dir || !receipt.profile
|
|
1120
|
+
|| JSON.stringify(session.profile) !== JSON.stringify(receipt.profile)) {
|
|
1121
|
+
throw new AppError("invocation_scope_unproven", "Controller MERGE root does not match its dispatch lease and retained Session", 1, { effect: "no_effect" });
|
|
1122
|
+
}
|
|
1123
|
+
// The controller Session above is already an owner candidate; its
|
|
1124
|
+
// daemon binding and generation are checked once in the common loop.
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const owner = receipt.owner;
|
|
1128
|
+
if (!receipt.session_id || !receipt.state_dir || !receipt.profile || !owner
|
|
1129
|
+
|| owner.owner_id !== `run:${request.run_id}:merge:${request.merge_request_id}` || owner.project_id !== projectId
|
|
1130
|
+
|| owner.run_id !== request.run_id || owner.work_id !== request.executor_work_id || owner.state_dir !== receipt.state_dir
|
|
1131
|
+
|| owner.operation_id !== `${request.merge_request_id}:${request.dispatch_lease_token}`) {
|
|
1132
|
+
throw new AppError("invocation_scope_unproven", "Server MERGE has no matching persisted runtime owner", 1, { effect: "no_effect" });
|
|
1133
|
+
}
|
|
1134
|
+
owners.push({ runId: request.run_id, rootSessionId: receipt.session_id, stateDir: receipt.state_dir,
|
|
1135
|
+
harness: runtimeHarness(receipt.profile), ownerId: owner.owner_id, active });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (owners.length > 1)
|
|
1139
|
+
throw new AppError("invocation_scope_unproven", "Native root has more than one persisted owner", 1, { effect: "no_effect" });
|
|
1140
|
+
for (const owner of owners) {
|
|
1141
|
+
if (!owner.active)
|
|
1142
|
+
throw new AppError("invocation_scope_unproven", "Managed native root is no longer active", 1, { effect: "no_effect" });
|
|
1143
|
+
if (owner.harness !== harness)
|
|
1144
|
+
throw new AppError("invocation_scope_unproven", "Native root belongs to another managed harness", 1, { effect: "no_effect" });
|
|
1145
|
+
const binding = requireManagedDaemonBinding(context, { stateDir: owner.stateDir, projectId, runId: owner.runId, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) });
|
|
1146
|
+
const kind = { "grok-acp": "grok-daemon", "antigravity-cli": "agy-daemon", "zcode-acp": "zcode-daemon", "codex-desktop": "codex-daemon", "droid-cli": "droid-daemon", "opencode-server": "opencode-daemon" }[harness];
|
|
1147
|
+
if (binding.process.kind !== kind) {
|
|
1148
|
+
throw new AppError("invocation_scope_unproven", "Native daemon kind differs from its persisted harness", 1, { effect: "no_effect" });
|
|
1149
|
+
}
|
|
1150
|
+
if (binding.state.daemon_id !== identity.daemonId)
|
|
1151
|
+
throw new AppError("invocation_scope_unproven", "Native daemon differs from its persisted launch", 1, { effect: "no_effect" });
|
|
1152
|
+
const root = binding.state.sessions?.some(native => typeof native === "string" ? native === identity.rootSessionId
|
|
1153
|
+
: native.provider_session_id === identity.rootSessionId && (native.root_provider_session_id ?? identity.rootSessionId) === identity.rootSessionId);
|
|
1154
|
+
const generation = JSON.parse(binding.process.metadata_json).run_generation;
|
|
1155
|
+
if (!root || !Number.isSafeInteger(generation) || generation < 0 || owner.generation !== undefined && generation !== owner.generation) {
|
|
1156
|
+
throw new AppError("invocation_scope_unproven", "Managed native root or RUN generation is not persisted", 1, { effect: "no_effect" });
|
|
1157
|
+
}
|
|
1158
|
+
return { projectId, runId: owner.runId, generation, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) };
|
|
1159
|
+
}
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
/** Manual recovery retains native receipt authority, not controller issuance.
|
|
1163
|
+
* Recognize only the existing pinned recovery dispatch and its live root. */
|
|
1164
|
+
export function manualRecoveryInvocationScope(context, projectRoot, identity, harness) {
|
|
1165
|
+
if (identity.sessionId !== identity.rootSessionId || identity.parentSessionId !== null
|
|
1166
|
+
|| !context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_recovery_bindings'"))
|
|
1167
|
+
return null;
|
|
1168
|
+
const project = context.db.get("SELECT id,root FROM projects WHERE root = ?", [canonicalPath(projectRoot)]);
|
|
1169
|
+
if (!project)
|
|
1170
|
+
return null;
|
|
1171
|
+
let resolved = null;
|
|
1172
|
+
for (const record of managedProcessStatus(context)) {
|
|
1173
|
+
if (record.project_id !== project.id || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
1174
|
+
continue;
|
|
1175
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
1176
|
+
if (typeof metadata.state_dir !== "string")
|
|
1177
|
+
continue;
|
|
1178
|
+
let state;
|
|
1179
|
+
try {
|
|
1180
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (state.daemon_id !== identity.daemonId)
|
|
1186
|
+
continue;
|
|
1187
|
+
const guard = recoveryGuard(context, project.id, record.run_id);
|
|
1188
|
+
if (!guard || !["resuming", "recovered"].includes(guard.status) || metadata.run_generation !== guard.generation)
|
|
1189
|
+
return null;
|
|
1190
|
+
const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [project.id, record.run_id]);
|
|
1191
|
+
if (!run || !["running", "paused", "waiting_for_user", "blocked"].includes(run.status))
|
|
1192
|
+
return null;
|
|
1193
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")
|
|
1194
|
+
&& context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? AND generation = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, record.run_id, guard.generation]))
|
|
1195
|
+
return null;
|
|
1196
|
+
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [guard.recovery_id]);
|
|
1197
|
+
if (!binding || binding.daemon_id !== identity.daemonId || binding.provider_session_id !== identity.rootSessionId || binding.harness !== harness
|
|
1198
|
+
|| binding.status !== (guard.status === "resuming" ? "prepared" : "accepted") || !binding.runtime_dispatch_json)
|
|
1199
|
+
return null;
|
|
1200
|
+
const dispatch = JSON.parse(binding.runtime_dispatch_json);
|
|
1201
|
+
if (dispatch.process_id !== record.id || dispatch.owner_id !== record.owner_id || dispatch.state_dir !== metadata.state_dir
|
|
1202
|
+
|| dispatch.daemon_id !== identity.daemonId || record.state !== "running" || record.lease_expires_at <= context.now() || !processIsAlive(record))
|
|
1203
|
+
return null;
|
|
1204
|
+
const physical = requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: project.id, runId: record.run_id, ownerId: dispatch.owner_id });
|
|
1205
|
+
const kind = { "codex-desktop": "codex-daemon", "zcode-acp": "zcode-daemon", "grok-acp": "grok-daemon", "antigravity-cli": "agy-daemon", "droid-cli": "droid-daemon", "opencode-server": "opencode-daemon" }[harness];
|
|
1206
|
+
if (physical.process.id !== dispatch.process_id || physical.process.kind !== kind || !physical.state.sessions?.some(native => typeof native === "string" ? native === identity.rootSessionId
|
|
1207
|
+
: native.provider_session_id === identity.rootSessionId && (native.root_provider_session_id ?? native.provider_session_id) === identity.rootSessionId))
|
|
1208
|
+
return null;
|
|
1209
|
+
const packet = recoveryPacketReceipt(binding).packet;
|
|
1210
|
+
if (packet.run_id !== record.run_id || packet.generation !== guard.generation || packet.recovery_id !== guard.recovery_id)
|
|
1211
|
+
return null;
|
|
1212
|
+
if (resolved)
|
|
1213
|
+
throw new AppError("invocation_scope_unproven", "Manual recovery native root has more than one physical owner", 1, { effect: "no_effect" });
|
|
1214
|
+
resolved = { projectRoot: project.root, runId: record.run_id, generation: guard.generation, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId };
|
|
1215
|
+
}
|
|
1216
|
+
return resolved;
|
|
1217
|
+
}
|
|
1218
|
+
/** Hook-only recovery commands must carry their own target, not acquire one
|
|
1219
|
+
* through assignment expansion. Existing recovery/Work guards own mutation. */
|
|
1220
|
+
export function assertManualRecoveryInvocationTarget(context, scope, command) {
|
|
1221
|
+
const parsed = invocation(command);
|
|
1222
|
+
const projectRoot = commandOption(parsed, "project-root");
|
|
1223
|
+
if (!["recovery_accept", "stage_start", "stage_finish", "stage_pause", "stage_resume", "work_start", "work_finish", "work_fail", "merge_apply", "merge_repair"].includes(parsed.operation)
|
|
1224
|
+
|| parsed.args.options.has("bootstrap") || !commandPosition(parsed, 0) || !projectRoot
|
|
1225
|
+
|| projectRoot !== "@project" && (!path.isAbsolute(projectRoot) || canonicalPath(projectRoot) !== scope.projectRoot)) {
|
|
1226
|
+
throw new AppError("invocation_scope_mismatch", "Manual recovery requires an explicit owned target and project", 1, { effect: "no_effect" });
|
|
1227
|
+
}
|
|
1228
|
+
if (parsed.operation === "recovery_accept" && commandOption(parsed, "recovery-id") !== recoveryGuard(context, requireProjectByRoot(context, scope.projectRoot).id, scope.runId)?.recovery_id)
|
|
1229
|
+
throw new AppError("invocation_scope_mismatch", "Recovery acknowledgement targets another recovery generation", 1, { effect: "no_effect" });
|
|
1230
|
+
assertLifecycleInvocationCurrent(context, scope, command);
|
|
1231
|
+
}
|
|
1232
|
+
/** Resolve scope from the physical runtime owner, never from a command match. */
|
|
1233
|
+
export function managedNativeInvocationScope(context, projectRoot, identity, harness) {
|
|
1234
|
+
const project = context.db.get("SELECT id,root FROM projects WHERE root = ?", [canonicalPath(projectRoot)]);
|
|
1235
|
+
// Bootstrap native hooks precede project registration. They may retain
|
|
1236
|
+
// evidence, but have no managed RUN authority to resolve yet.
|
|
1237
|
+
if (!project)
|
|
1238
|
+
return null;
|
|
1239
|
+
const owner = managedNativeOwner(context, identity, harness, project.id);
|
|
1240
|
+
if (owner)
|
|
1241
|
+
return { projectRoot: project.root, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId, runId: owner.runId, generation: owner.generation };
|
|
1242
|
+
const manualRecovery = manualRecoveryInvocationScope(context, project.root, identity, harness);
|
|
1243
|
+
if (manualRecovery)
|
|
1244
|
+
return manualRecovery;
|
|
1245
|
+
for (const record of managedProcessStatus(context)) {
|
|
1246
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
1247
|
+
if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
|
|
1248
|
+
continue;
|
|
1249
|
+
let state;
|
|
1250
|
+
try {
|
|
1251
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
1252
|
+
}
|
|
1253
|
+
catch {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
if (state.daemon_id === identity.daemonId && state.config?.runtime_owner)
|
|
1257
|
+
throw new AppError("invocation_scope_unproven", "Managed daemon has no retained native owner", 1, { effect: "no_effect" });
|
|
1258
|
+
}
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
export function cliManagedInvocationScope(context) {
|
|
1262
|
+
const daemonId = context.env.DD_FLOW_DAEMON_ID;
|
|
1263
|
+
if (!daemonId)
|
|
1264
|
+
return null;
|
|
1265
|
+
const scopes = [];
|
|
1266
|
+
for (const record of managedProcessStatus(context)) {
|
|
1267
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
1268
|
+
if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
|
|
1269
|
+
continue;
|
|
1270
|
+
let state;
|
|
1271
|
+
try {
|
|
1272
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
1273
|
+
}
|
|
1274
|
+
catch {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (state.daemon_id !== daemonId || !record.project_id || !record.run_id)
|
|
1278
|
+
continue;
|
|
1279
|
+
const harness = { "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "droid-daemon": "droid-cli", "opencode-daemon": "opencode-server" }[record.kind];
|
|
1280
|
+
const project = context.db.get("SELECT root FROM projects WHERE id = ?", [record.project_id]);
|
|
1281
|
+
if (!harness || !project)
|
|
1282
|
+
continue;
|
|
1283
|
+
requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: record.project_id, runId: record.run_id });
|
|
1284
|
+
const roots = new Set((state.sessions ?? []).flatMap(native => typeof native === "string" ? [native] : native.root_provider_session_id ? [native.root_provider_session_id] : native.provider_session_id ? [native.provider_session_id] : []));
|
|
1285
|
+
for (const rootSessionId of roots) {
|
|
1286
|
+
let scope;
|
|
1287
|
+
try {
|
|
1288
|
+
scope = managedNativeInvocationScope(context, project.root, { daemonId, rootSessionId, sessionId: rootSessionId, parentSessionId: null, toolCallId: "cli-scope-inspection" }, harness);
|
|
1289
|
+
}
|
|
1290
|
+
catch (error) {
|
|
1291
|
+
if (error instanceof AppError && error.code === "invocation_scope_unproven")
|
|
1292
|
+
continue;
|
|
1293
|
+
throw error;
|
|
1294
|
+
}
|
|
1295
|
+
if (scope && !scopes.some(prior => JSON.stringify(prior) === JSON.stringify(scope)))
|
|
1296
|
+
scopes.push(scope);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (scopes.length !== 1)
|
|
1300
|
+
throw new AppError("invocation_scope_unproven", "CLI has no unique persisted current managed native scope", 1, { effect: "no_effect" });
|
|
1301
|
+
return scopes[0];
|
|
1302
|
+
}
|
|
1303
|
+
export function isControllerManagedInvocation(context, projectRoot, runId) {
|
|
1304
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
1305
|
+
if (!configured)
|
|
1306
|
+
return false;
|
|
1307
|
+
const scope = JSON.parse(configured);
|
|
1308
|
+
if (context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
1309
|
+
const current = load(context, context.env.DD_FLOW_CURRENT_INVOCATION);
|
|
1310
|
+
const identity = current.identity_json && JSON.parse(current.identity_json);
|
|
1311
|
+
if (identity && identity[2] !== scope.rootSessionId)
|
|
1312
|
+
throw new AppError("invocation_scope_unproven", "A native child cannot issue coordinator Stage successors", 1);
|
|
1313
|
+
}
|
|
1314
|
+
if (scope.projectRoot !== canonicalPath(projectRoot) || scope.runId !== runId)
|
|
1315
|
+
throw new AppError("invocation_scope_mismatch", "Successor differs from retained lifecycle scope", 1);
|
|
1316
|
+
const projectId = requireProjectByRoot(context, projectRoot).id;
|
|
1317
|
+
const rows = context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")
|
|
1318
|
+
? context.db.all("SELECT controller_id,generation,status,state_json FROM run_controllers WHERE project_id = ? AND run_id = ?", [projectId, runId]) : [];
|
|
1319
|
+
let matches = 0;
|
|
1320
|
+
for (const row of rows) {
|
|
1321
|
+
const state = JSON.parse(row.state_json);
|
|
1322
|
+
const session = state.sessions?.[state.current_session];
|
|
1323
|
+
if (!session)
|
|
1324
|
+
continue;
|
|
1325
|
+
let nativeState;
|
|
1326
|
+
try {
|
|
1327
|
+
nativeState = readNativeDaemonState(session.state_dir);
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
if (nativeState.daemon_id !== scope.daemonId)
|
|
1333
|
+
continue;
|
|
1334
|
+
const binding = requireManagedDaemonBinding(context, { stateDir: session.state_dir, projectId, runId, ownerId: row.controller_id });
|
|
1335
|
+
if (binding.state.daemon_id !== scope.daemonId)
|
|
1336
|
+
continue;
|
|
1337
|
+
const native = binding.state.sessions?.find(native => typeof native === "string" ? native === session.id && native === scope.rootSessionId
|
|
1338
|
+
: (native.adapter_session_id === session.id || native.provider_session_id === session.id) && (native.root_provider_session_id ?? native.provider_session_id) === scope.rootSessionId);
|
|
1339
|
+
const generation = JSON.parse(binding.process.metadata_json).run_generation;
|
|
1340
|
+
if (!native || session.stopped || ["completed", "cancelled", "superseded"].includes(row.status) || row.generation !== scope.generation || generation !== scope.generation)
|
|
1341
|
+
throw new AppError("invocation_scope_unproven", "Controller successor owner is stale or unproven", 1);
|
|
1342
|
+
matches += 1;
|
|
1343
|
+
}
|
|
1344
|
+
if (matches > 1)
|
|
1345
|
+
throw new AppError("invocation_scope_unproven", "Stage successor has multiple coordinator owners", 1);
|
|
1346
|
+
if (matches === 1)
|
|
1347
|
+
return true;
|
|
1348
|
+
for (const record of managedProcessStatus(context)) {
|
|
1349
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
1350
|
+
if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
|
|
1351
|
+
continue;
|
|
1352
|
+
let state;
|
|
1353
|
+
try {
|
|
1354
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
if (state.daemon_id !== scope.daemonId)
|
|
1360
|
+
continue;
|
|
1361
|
+
if (/^run:.+:(?:work|merge):/.test(record.owner_id))
|
|
1362
|
+
return false;
|
|
1363
|
+
throw new AppError("invocation_scope_unproven", "Managed coordinator has no retained current controller Session", 1);
|
|
1364
|
+
}
|
|
1365
|
+
throw new AppError("invocation_scope_unproven", "Managed successor has no proven physical owner", 1);
|
|
1366
|
+
}
|
|
1367
|
+
/** recordReceipt is a synchronous SQL-only hook ingress callback. Native
|
|
1368
|
+
* adapters must prepare any filesystem or process evidence before calling. */
|
|
1369
|
+
export function observeLifecycleInvocation(context, input) {
|
|
1370
|
+
const priorReceipt = input.currentEventKey && input.projectId
|
|
1371
|
+
&& context.db.get("SELECT 1 FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.currentEventKey]);
|
|
1372
|
+
const result = observeLifecycleInvocationReceipt(context, input);
|
|
1373
|
+
// Hook-only calls have no invocation row, but their immutable receipt still
|
|
1374
|
+
// distinguishes a repeated native delivery from a new observation.
|
|
1375
|
+
if (priorReceipt && result.disposition === "admitted" && result.eventKey === input.currentEventKey) {
|
|
1376
|
+
result.disposition = "replay";
|
|
1377
|
+
result.duplicate = true;
|
|
1378
|
+
}
|
|
1379
|
+
return { ...result, currentEventKey: input.currentEventKey ?? null, authoritativeEventKey: result.eventKey ?? null };
|
|
1380
|
+
}
|
|
1381
|
+
function observeLifecycleInvocationReceipt(context, input) {
|
|
1382
|
+
storage(context);
|
|
1383
|
+
const projectRoot = input.projectId ? context.db.get("SELECT root FROM projects WHERE id = ?", [input.projectId])?.root : undefined;
|
|
1384
|
+
const provenScope = projectRoot && input.harness ? managedNativeInvocationScope(context, projectRoot, input.identity, input.harness) : null;
|
|
1385
|
+
const owner = provenScope && input.harness && input.projectId ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
|
|
1386
|
+
const ownerSnapshot = () => {
|
|
1387
|
+
if (!provenScope?.runId || !input.projectId)
|
|
1388
|
+
return null;
|
|
1389
|
+
const rows = [context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [input.projectId, provenScope.runId])];
|
|
1390
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'"))
|
|
1391
|
+
rows.push(context.db.all("SELECT controller_id,generation,status,owner_token,state_json FROM run_controllers WHERE project_id = ? AND run_id = ? AND controller_id = ? ORDER BY controller_id", [input.projectId, provenScope.runId, owner?.ownerId ?? ""]).map(row => {
|
|
1392
|
+
const state = JSON.parse(row.state_json), session = state.sessions?.[state.current_session];
|
|
1393
|
+
return [row.controller_id, row.generation, row.status, row.owner_token, state.current_session, session && [session.id, session.state_dir, session.stopped, session.owner]];
|
|
1394
|
+
}));
|
|
1395
|
+
for (const [table, query] of [
|
|
1396
|
+
["work_launches", "SELECT l.status,l.provider_session_id,l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id WHERE w.project_id = ? AND w.run_id = ? AND CASE WHEN json_valid(l.profile_json) THEN json_extract(l.profile_json, '$.runtime_adapter.owner.owner_id') END = ? ORDER BY l.operation_id"],
|
|
1397
|
+
["merge_requests", "SELECT status,dispatch_owner,dispatch_lease_token,adapter_receipt_json FROM merge_requests WHERE project_id = ? AND run_id = ? AND (dispatch_owner = ? OR CASE WHEN json_valid(adapter_receipt_json) THEN json_extract(adapter_receipt_json, '$.owner.owner_id') END = ?) ORDER BY merge_request_id"]
|
|
1398
|
+
])
|
|
1399
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = ?", [table]))
|
|
1400
|
+
rows.push(context.db.all(query, table === "merge_requests" ? [input.projectId, provenScope.runId, owner?.ownerId ?? "", owner?.ownerId ?? ""] : [input.projectId, provenScope.runId, owner?.ownerId ?? ""]));
|
|
1401
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_recovery_bindings'"))
|
|
1402
|
+
rows.push(context.db.all("SELECT g.recovery_id,g.generation,g.status,b.* FROM run_recovery_guards g JOIN run_recovery_bindings b ON b.recovery_id = g.recovery_id WHERE g.project_id = ? AND g.run_id = ? ORDER BY g.generation", [input.projectId, provenScope.runId]));
|
|
1403
|
+
return JSON.stringify(rows);
|
|
1404
|
+
};
|
|
1405
|
+
const retainedOwner = ownerSnapshot();
|
|
1406
|
+
const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
|
|
1407
|
+
const analysis = parseLifecycleCommand(input.command);
|
|
1408
|
+
if (analysis.kind === "none" || analysis.supported === false) {
|
|
1409
|
+
let issuedBarrier;
|
|
1410
|
+
const singleLeaf = analysis.kind !== "none" && (analysis.invocations?.length ?? 1) === 1 && !(analysis.kind === "compound" && analysis.reason === "multiple_lifecycle_invocations");
|
|
1411
|
+
const refused = singleLeaf ? context.db.all("SELECT * FROM lifecycle_invocations WHERE status = 'issued'").filter(row => {
|
|
1412
|
+
const scope = JSON.parse(row.scope_json);
|
|
1413
|
+
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId || provenScope && row.scope_json !== JSON.stringify(provenScope))
|
|
1414
|
+
return false;
|
|
1415
|
+
try {
|
|
1416
|
+
return fingerprint(invocation(canonicalManagedCommand(context, analysis.invocation.command, scope))) === row.fingerprint;
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
}) : [];
|
|
1422
|
+
const correction = supplied ?? (refused.length === 1 ? refused[0] : undefined);
|
|
1423
|
+
if (singleLeaf && correction && correction.status === "issued" && (correction.deadline === null || correction.deadline > Date.now())) {
|
|
1424
|
+
const scope = JSON.parse(correction.scope_json);
|
|
1425
|
+
checkNativeIdentity(scope, input.identity, correction.id);
|
|
1426
|
+
assertSuppliedLifecycleTarget(correction, invocation(canonicalManagedCommand(context, analysis.invocation.command, scope)));
|
|
1427
|
+
if ((!provenScope || correction.scope_json === JSON.stringify(provenScope)) && fingerprint(invocation(canonicalManagedCommand(context, analysis.invocation.command, scope))) === correction.fingerprint)
|
|
1428
|
+
issuedBarrier = { issued_invocation_id: correction.id, cli_admission_barrier: true };
|
|
1429
|
+
}
|
|
1430
|
+
const eventKey = input.recordReceipt();
|
|
1431
|
+
if (analysis.kind !== "none") {
|
|
1432
|
+
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
|
|
1433
|
+
let outcome = {};
|
|
1434
|
+
try {
|
|
1435
|
+
outcome = retained?.outcome_json ? JSON.parse(retained.outcome_json) : {};
|
|
1436
|
+
}
|
|
1437
|
+
catch {
|
|
1438
|
+
outcome = {};
|
|
1439
|
+
}
|
|
1440
|
+
context.db.run("UPDATE hook_events SET outcome_json = ? WHERE event_key = ?", [JSON.stringify({
|
|
1441
|
+
...outcome,
|
|
1442
|
+
lifecycle_observation: {
|
|
1443
|
+
code: "lifecycle_shell_syntax_invalid",
|
|
1444
|
+
reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command",
|
|
1445
|
+
command_fingerprint: fingerprint(analysis.invocation),
|
|
1446
|
+
effect: "no_effect",
|
|
1447
|
+
recoverable: true
|
|
1448
|
+
}
|
|
1449
|
+
}), eventKey]);
|
|
1450
|
+
}
|
|
1451
|
+
if (analysis.kind !== "none")
|
|
1452
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey, ...issuedBarrier });
|
|
1453
|
+
return { eventKey, duplicate: false, disposition: "admitted", invocationId: null };
|
|
1454
|
+
}
|
|
1455
|
+
// Admit the one parsed leaf, never execute or fingerprint the prefix/suffix.
|
|
1456
|
+
const leafCommand = analysis.invocation.command;
|
|
1457
|
+
if (input.executionCwd !== undefined && !path.isAbsolute(input.executionCwd))
|
|
1458
|
+
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd must be absolute", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1459
|
+
const deliveredIdentity = JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId,
|
|
1460
|
+
...(input.executionCwd === undefined ? [] : [{ executionCwd: canonicalPath(input.executionCwd) }])]);
|
|
1461
|
+
const matching = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
|
|
1462
|
+
.filter(row => {
|
|
1463
|
+
const scope = JSON.parse(row.scope_json);
|
|
1464
|
+
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
1465
|
+
return false;
|
|
1466
|
+
if (provenScope && row.scope_json !== JSON.stringify(provenScope))
|
|
1467
|
+
return false;
|
|
1468
|
+
try {
|
|
1469
|
+
if (fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) !== row.fingerprint)
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
catch {
|
|
1473
|
+
return false;
|
|
1474
|
+
}
|
|
1475
|
+
if (row.identity_json && row.identity_json !== deliveredIdentity
|
|
1476
|
+
&& sameNativeCall(row.identity_json, deliveredIdentity)) {
|
|
1477
|
+
throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1478
|
+
}
|
|
1479
|
+
if (row.status === "executing" && row.identity_json !== deliveredIdentity)
|
|
1480
|
+
return false;
|
|
1481
|
+
if (row.status === "settled" && row.outcome_json && JSON.parse(row.outcome_json).error && row.identity_json !== deliveredIdentity)
|
|
1482
|
+
return false;
|
|
1483
|
+
return true;
|
|
1484
|
+
});
|
|
1485
|
+
const candidates = lifecycleCandidates(matching, deliveredIdentity);
|
|
1486
|
+
// A known explicit ID remains a compatibility path. A missing or mistyped
|
|
1487
|
+
// public ID is corrected only by an exact operation/target match inside the
|
|
1488
|
+
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
1489
|
+
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
1490
|
+
if (!supplied && candidates.length > 1) {
|
|
1491
|
+
input.recordReceipt();
|
|
1492
|
+
throw new AppError("invocation_ambiguous", "More than one issued lifecycle attempt matches this native command", 1, { effect: "no_effect", matches: candidates.map(row => row.id), ...(provenScope ? { lifecycle_assignment: { issuer: "dd-flow", scope: provenScope } } : {}) });
|
|
1493
|
+
}
|
|
1494
|
+
if (owner) {
|
|
1495
|
+
if (!initial)
|
|
1496
|
+
throw new AppError("invocation_assignment_missing", "Managed native lifecycle call has no issued assignment", 1, { effect: "no_effect", recoverable: false });
|
|
1497
|
+
const scope = JSON.parse(initial.scope_json);
|
|
1498
|
+
if (requireProjectByRoot(context, scope.projectRoot).id !== owner.projectId || scope.runId !== owner.runId || scope.generation !== owner.generation) {
|
|
1499
|
+
throw new AppError("invocation_scope_mismatch", "Native daemon owner does not match issued RUN authority", 1, { effect: "no_effect", recoverable: false });
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (!initial) {
|
|
1503
|
+
if (provenScope)
|
|
1504
|
+
assertManualRecoveryInvocationTarget(context, provenScope, leafCommand);
|
|
1505
|
+
return context.db.writeTransaction(() => {
|
|
1506
|
+
if (retainedOwner !== ownerSnapshot())
|
|
1507
|
+
throw new AppError("invocation_scope_unproven", "Native owner changed before hook-only recovery receipt", 1, { effect: "no_effect" });
|
|
1508
|
+
const eventKey = input.recordReceipt();
|
|
1509
|
+
return { eventKey, duplicate: false, disposition: "admitted", invocationId: null };
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
const resolved = input.id === initial.id ? {} : { invocationId: initial.id };
|
|
1513
|
+
assertSuppliedLifecycleTarget(initial, invocation(canonicalManagedCommand(context, leafCommand, JSON.parse(initial.scope_json))));
|
|
1514
|
+
const scope = JSON.parse(initial.scope_json);
|
|
1515
|
+
checkNativeIdentity(scope, input.identity, initial.id);
|
|
1516
|
+
// A completed invocation is a read, even from a new native tool call after
|
|
1517
|
+
// reattach. Do not manufacture a new receipt or reject that read as a second
|
|
1518
|
+
// executor; the CLI returns the immutable stored outcome without dispatch.
|
|
1519
|
+
if (initial.status === "settled")
|
|
1520
|
+
return { eventKey: initial.event_key, duplicate: true, disposition: initial.identity_json === deliveredIdentity ? "replay" : "settled_read", ...resolved };
|
|
1521
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
1522
|
+
const identity = input.identity;
|
|
1523
|
+
const identityJson = deliveredIdentity;
|
|
1524
|
+
return context.db.writeTransaction(() => {
|
|
1525
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
1526
|
+
if (retainedOwner !== ownerSnapshot())
|
|
1527
|
+
throw new AppError("invocation_scope_unproven", "Native owner changed before admission receipt", 1, { effect: "no_effect" });
|
|
1528
|
+
if (row.scope_json !== initial.scope_json)
|
|
1529
|
+
throw new AppError("invocation_scope_mismatch", "Lifecycle scope changed before native receipt", 1, { effect: "no_effect" });
|
|
1530
|
+
if (row.status === "settled")
|
|
1531
|
+
return { eventKey: row.event_key, duplicate: true, disposition: row.identity_json === identityJson ? "replay" : "settled_read", ...resolved };
|
|
1532
|
+
// Observation records native facts only. CLI owns deadline, argument and
|
|
1533
|
+
// RUN-state validation, including decisions about retrying an invocation.
|
|
1534
|
+
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
|
|
1535
|
+
return { eventKey: row.event_key, duplicate: true, disposition: "expired", ...resolved };
|
|
1536
|
+
}
|
|
1537
|
+
if (row.identity_json === identityJson) {
|
|
1538
|
+
return { eventKey: row.event_key, duplicate: true, disposition: "replay", ...resolved };
|
|
1539
|
+
}
|
|
1540
|
+
if (row.identity_json && sameNativeCall(row.identity_json, identityJson)) {
|
|
1541
|
+
throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1542
|
+
}
|
|
1543
|
+
const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
|
|
1544
|
+
// A native hook may execute from a provider worktree while the issued
|
|
1545
|
+
// lifecycle scope is anchored to the stable project root. Project
|
|
1546
|
+
// identity is the canonical boundary; comparing raw cwd would reject a
|
|
1547
|
+
// valid receipt before the CLI can run (Codex does this in particular).
|
|
1548
|
+
const receipt = eventKey && context.db.get("SELECT provider_session_id, daemon_id, status, harness FROM hook_events WHERE event_key = ? AND harness = ? AND project_id = ?", [eventKey, input.harness ?? "zcode-acp", project.id]);
|
|
1549
|
+
if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
|
|
1550
|
+
throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
|
|
1551
|
+
}
|
|
1552
|
+
// The hook observes intent; it does not own execution. A native shell may
|
|
1553
|
+
// reject a tool call before the CLI process exists (for example because
|
|
1554
|
+
// its workdir is invalid), after which repeating the same issued command
|
|
1555
|
+
// is safe. Keep the newest receipt until the CLI claims the attempt. The
|
|
1556
|
+
// observed -> executing compare-and-swap remains the exactly-once boundary.
|
|
1557
|
+
const updated = context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status IN ('issued', 'observed')", [identityJson, eventKey, context.now(), initial.id]);
|
|
1558
|
+
// Once execution has been claimed, later PreToolUse observations are
|
|
1559
|
+
// retained as hook evidence but cannot replace the executor's receipt.
|
|
1560
|
+
return { eventKey: updated.changes === 1 ? eventKey : row.event_key, duplicate: updated.changes !== 1, disposition: updated.changes === 1 ? "admitted" : "in_flight", ...resolved };
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
1564
|
+
* matching. A hook-added event key must name the same persisted native call. */
|
|
1565
|
+
export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
1566
|
+
const signature = (value) => {
|
|
1567
|
+
const parsed = parseLifecycleCommand(value);
|
|
1568
|
+
const candidate = parsed.kind !== "none" && parsed.supported ? parsed.invocation : continuationCommand(value);
|
|
1569
|
+
if (!candidate)
|
|
1570
|
+
return null;
|
|
1571
|
+
return JSON.stringify([candidate.operation, candidate.args.positional,
|
|
1572
|
+
[...candidate.args.options].filter(([key]) => !["json", "progress-jsonl", "hook-event-id"].includes(key)).sort(([a], [b]) => a.localeCompare(b))]);
|
|
1573
|
+
};
|
|
1574
|
+
const expected = signature(command);
|
|
1575
|
+
if (!expected)
|
|
1576
|
+
return null;
|
|
1577
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
|
|
1578
|
+
return null;
|
|
1579
|
+
const parsed = parseLifecycleCommand(command);
|
|
1580
|
+
const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
|
|
1581
|
+
const matches = context.db.all(daemonId
|
|
1582
|
+
? "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL"
|
|
1583
|
+
: eventKey
|
|
1584
|
+
? "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE event_key = ? AND status = 'observed' AND outcome_json IS NULL"
|
|
1585
|
+
: "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE harness IN ('antigravity-cli','codex-desktop') AND status = 'observed' AND outcome_json IS NULL", daemonId || eventKey ? [daemonId ?? eventKey] : []).filter(row => {
|
|
1586
|
+
if (eventKey && row.event_key !== eventKey)
|
|
1587
|
+
return false;
|
|
1588
|
+
try {
|
|
1589
|
+
const observed = lifecycleCommandFromPayload(JSON.parse(row.sanitized_summary));
|
|
1590
|
+
return typeof observed === "string" && signature(observed) === expected;
|
|
1591
|
+
}
|
|
1592
|
+
catch {
|
|
1593
|
+
return false;
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
// Diagnostic correlation only: never use this as execution authority, and
|
|
1597
|
+
// never choose the latest of several identical native calls.
|
|
1598
|
+
return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
|
|
1599
|
+
}
|
|
1600
|
+
/** Keep shell diagnostics distinct from genuinely absent native delivery. */
|
|
1601
|
+
export function observedLifecycleDiagnostic(context, command, daemonId, invocationId) {
|
|
1602
|
+
const analysis = parseLifecycleCommand(command);
|
|
1603
|
+
if (analysis.kind !== "standalone")
|
|
1604
|
+
return null;
|
|
1605
|
+
const commandFingerprint = fingerprint(analysis.invocation);
|
|
1606
|
+
const issuedAt = invocationId ? context.db.get("SELECT created_at FROM lifecycle_invocations WHERE id = ?", [invocationId])?.created_at : undefined;
|
|
1607
|
+
const rows = context.db.all(`SELECT id, event_key, outcome_json FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NOT NULL
|
|
1608
|
+
${issuedAt ? "AND created_at >= ?" : ""} ORDER BY id DESC LIMIT 20`, issuedAt ? [daemonId, issuedAt] : [daemonId]).flatMap(row => {
|
|
1609
|
+
try {
|
|
1610
|
+
const outcome = JSON.parse(row.outcome_json);
|
|
1611
|
+
return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key, hook_event_id: row.id }] : [];
|
|
1612
|
+
}
|
|
1613
|
+
catch {
|
|
1614
|
+
return [];
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1617
|
+
if (!rows.length)
|
|
1618
|
+
return null;
|
|
1619
|
+
const reasons = new Set(rows.map(row => `${String(row.code)}\0${String(row.reason)}`));
|
|
1620
|
+
if (reasons.size !== 1)
|
|
1621
|
+
return null;
|
|
1622
|
+
return { ...rows[0], matching_events: rows.map(row => row.event_key) };
|
|
1623
|
+
}
|
|
1624
|
+
/** Resolve only a command that a native hook has already bound to an issued
|
|
1625
|
+
* attempt. This is the CLI-side half of admission for model-facing commands
|
|
1626
|
+
* that intentionally omit the internal UUID. */
|
|
1627
|
+
export function observedLifecycleInvocation(context, command, scopeConstraint, correlation) {
|
|
1628
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
1629
|
+
return null;
|
|
1630
|
+
const analysis = parseLifecycleCommand(command);
|
|
1631
|
+
if (analysis.kind === "none" || analysis.supported === false)
|
|
1632
|
+
return null;
|
|
1633
|
+
const leafCommand = analysis.invocation.command;
|
|
1634
|
+
const suppliedId = commandOption(analysis.invocation, "invocation-id");
|
|
1635
|
+
const retainedId = suppliedId && context.db.get("SELECT id FROM lifecycle_invocations WHERE id = ?", [suppliedId]) ? suppliedId : undefined;
|
|
1636
|
+
// `issued` is a rendezvous, not authority: the CLI still blocks in
|
|
1637
|
+
// awaitLifecycleInvocation until the native observer commits the receipt.
|
|
1638
|
+
const matching = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
|
|
1639
|
+
.filter(row => {
|
|
1640
|
+
try {
|
|
1641
|
+
const explicitId = retainedId;
|
|
1642
|
+
const eventKey = commandOption(analysis.invocation, "hook-event-id") ?? correlation?.eventKey;
|
|
1643
|
+
if (explicitId && row.id !== explicitId || eventKey && row.event_key !== eventKey)
|
|
1644
|
+
return false;
|
|
1645
|
+
if (row.status === "settled" && (!row.outcome_json || JSON.parse(row.outcome_json).error) && !explicitId && !eventKey && !correlation?.identity)
|
|
1646
|
+
return false;
|
|
1647
|
+
const scope = JSON.parse(row.scope_json);
|
|
1648
|
+
const constraint = typeof scopeConstraint === "string" ? { daemonId: scopeConstraint } : scopeConstraint;
|
|
1649
|
+
if (constraint && Object.entries(constraint).some(([key, value]) => scope[key] !== value))
|
|
1650
|
+
return false;
|
|
1651
|
+
return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
|
|
1652
|
+
}
|
|
1653
|
+
catch {
|
|
1654
|
+
return false;
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
const identity = correlation?.identity;
|
|
1658
|
+
const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true);
|
|
1659
|
+
if (!matches.length)
|
|
1660
|
+
return null;
|
|
1661
|
+
if (matches.length !== 1)
|
|
1662
|
+
throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
|
|
1663
|
+
return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
|
|
1664
|
+
}
|
|
1665
|
+
/** A new native call may consume a successor, but never a historical result.
|
|
1666
|
+
* Exact native delivery replays its immutable assignment. CLI lookup without
|
|
1667
|
+
* native correlation remains ambiguous when history and a successor coexist. */
|
|
1668
|
+
function lifecycleCandidates(rows, identity, lookup = false) {
|
|
1669
|
+
if (identity) {
|
|
1670
|
+
const replay = rows.filter(row => {
|
|
1671
|
+
if (!row.identity_json)
|
|
1672
|
+
return false;
|
|
1673
|
+
if (row.identity_json === identity)
|
|
1674
|
+
return true;
|
|
1675
|
+
if (!lookup)
|
|
1676
|
+
return false;
|
|
1677
|
+
try {
|
|
1678
|
+
const retained = JSON.parse(row.identity_json);
|
|
1679
|
+
return Array.isArray(retained) && JSON.stringify(retained.slice(0, 5)) === identity;
|
|
1680
|
+
}
|
|
1681
|
+
catch {
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
});
|
|
1685
|
+
if (replay.length)
|
|
1686
|
+
return replay;
|
|
1687
|
+
}
|
|
1688
|
+
const active = rows.filter(row => row.status !== "settled");
|
|
1689
|
+
return (!lookup || identity) && active.length ? active : rows;
|
|
1690
|
+
}
|
|
1691
|
+
/** Restore private assignment arguments without discarding the provider's
|
|
1692
|
+
* verified public input. The fingerprint deliberately projects semantic
|
|
1693
|
+
* values such as a Work failure reason and a Work result-file path. */
|
|
1694
|
+
export function resolveObservedLifecycleArgs(context, id, actualArgs) {
|
|
1695
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
1696
|
+
if (!row)
|
|
1697
|
+
throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
|
|
1698
|
+
const retained = invocation(row.command), actual = invocation(`${launchExpression(retained)} ${quoteModelFacingArgs(actualArgs)}`);
|
|
1699
|
+
assertSuppliedLifecycleTarget(row, actual);
|
|
1700
|
+
if (fingerprint(actual) !== row.fingerprint)
|
|
1701
|
+
throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
1702
|
+
for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
|
|
1703
|
+
const supplied = commandOption(actual, key), expected = commandOption(retained, key);
|
|
1704
|
+
const exactInput = ["result-file", "decision-file", "verification-file"].includes(key);
|
|
1705
|
+
const actualValue = exactInput && supplied !== undefined
|
|
1706
|
+
? commandOption(invocation(`${launchExpression(retained)} ${quote(expandLifecycleInvocationArgs(context, actual.argv, JSON.parse(row.scope_json), retained.operation))}`), key)
|
|
1707
|
+
: supplied;
|
|
1708
|
+
if (supplied !== undefined && (exactInput ? path.resolve(actualValue ?? "") !== path.resolve(expected ?? "")
|
|
1709
|
+
: normalizedInvocationValue(key, supplied) !== normalizedInvocationValue(key, expected ?? ""))) {
|
|
1710
|
+
throw new AppError("invocation_argument_mismatch", `--${key} is owned by the managed assignment and does not match it`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: key });
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
const argv = [...retained.argv];
|
|
1714
|
+
const publicKey = retained.operation === "work_fail" ? "reason" : retained.operation === "work_finish" ? "result-file" : null;
|
|
1715
|
+
if (publicKey && commandOption(actual, publicKey) !== undefined) {
|
|
1716
|
+
const index = argv.indexOf(`--${publicKey}`);
|
|
1717
|
+
if (index >= 0 && index + 1 < argv.length)
|
|
1718
|
+
argv[index + 1] = commandOption(actual, publicKey);
|
|
1719
|
+
else {
|
|
1720
|
+
const inline = argv.findIndex(value => value.startsWith(`--${publicKey}=`));
|
|
1721
|
+
if (inline >= 0)
|
|
1722
|
+
argv[inline] = `--${publicKey}=${commandOption(actual, publicKey)}`;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return argv;
|
|
1726
|
+
}
|
|
1727
|
+
/** Once native admission is committed, retain the actual public semantics so
|
|
1728
|
+
* an authorized successor cannot silently revert to the issued placeholder. */
|
|
1729
|
+
export function retainObservedLifecycleSemantics(context, id, argv) {
|
|
1730
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
1731
|
+
if (!row || row.status !== "executing")
|
|
1732
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle attempt is not executing", 1, { invocation_id: id });
|
|
1733
|
+
const parsed = invocation(row.command);
|
|
1734
|
+
if (!["work_fail", "work_finish"].includes(parsed.operation))
|
|
1735
|
+
return;
|
|
1736
|
+
const prefix = Object.entries(parsed.env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
1737
|
+
const executable = launchExpression(parsed);
|
|
1738
|
+
const suffix = row.command.slice(shellSuffixOffset(row.command)).trimStart();
|
|
1739
|
+
const command = `${prefix ? `${prefix} ` : ""}${executable} ${quote(argv)}${suffix ? ` ${suffix}` : ""}`;
|
|
1740
|
+
checkCommand(row, command);
|
|
1741
|
+
if (context.db.run("UPDATE lifecycle_invocations SET command = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [command, context.now(), id]).changes !== 1) {
|
|
1742
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle attempt changed before its public input was retained", 1, { invocation_id: id });
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
export function settleLifecyclePreparationRejection(context, id, error) {
|
|
1746
|
+
delete error.details.retry_command;
|
|
1747
|
+
delete error.details.retry_instruction;
|
|
1748
|
+
try {
|
|
1749
|
+
return context.db.writeTransaction(() => {
|
|
1750
|
+
const row = load(context, id);
|
|
1751
|
+
if (row.status === "settled") {
|
|
1752
|
+
const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
|
|
1753
|
+
return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
|
|
1754
|
+
}
|
|
1755
|
+
if (!["issued", "observed"].includes(row.status))
|
|
1756
|
+
return { effect: "unknown", recoverable: false };
|
|
1757
|
+
const scope = JSON.parse(row.scope_json);
|
|
1758
|
+
if (scope.runId)
|
|
1759
|
+
assertLifecycleInvocationCurrent(context, scope, row.command);
|
|
1760
|
+
const signature = preparationRejectionSignature(error);
|
|
1761
|
+
const repeated = context.db.all("SELECT outcome_json FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND id <> ? AND status = 'settled'", [row.scope_json, row.fingerprint, row.id])
|
|
1762
|
+
.some(prior => { try {
|
|
1763
|
+
return JSON.parse(prior.outcome_json ?? "null")?.error?.details?.rejection_signature === signature;
|
|
1764
|
+
}
|
|
1765
|
+
catch {
|
|
1766
|
+
return false;
|
|
1767
|
+
} });
|
|
1768
|
+
const retryable = !repeated && isRetryableLifecycleRejection(error.code, error.details);
|
|
1769
|
+
const details = { ...error.details, effect: "no_effect", recoverable: retryable, rejection_signature: signature,
|
|
1770
|
+
...(repeated ? { repeated_without_progress: true } : {}),
|
|
1771
|
+
...(retryable ? {
|
|
1772
|
+
retry_command: successorLifecycleInvocationCommand(context, row),
|
|
1773
|
+
retry_instruction: "Correct the reported input, then execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state."
|
|
1774
|
+
} : {}) };
|
|
1775
|
+
context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
|
|
1776
|
+
return details;
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
catch (persistence) {
|
|
1780
|
+
error.details.recoverable = false;
|
|
1781
|
+
throw persistence;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
function preparationRejectionSignature(error) {
|
|
1785
|
+
const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature", "hook_retry_signature", "event_key", "tool_call_id", "controller_turn", "timestamp", "created_at", "updated_at", "repeated_without_progress", "recoverable"]);
|
|
1786
|
+
const normalize = (value) => {
|
|
1787
|
+
if (Array.isArray(value))
|
|
1788
|
+
return value.map(normalize);
|
|
1789
|
+
if (!value || typeof value !== "object")
|
|
1790
|
+
return value;
|
|
1791
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !omitted.has(key)).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, normalize(item)]));
|
|
1792
|
+
};
|
|
1793
|
+
return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
|
|
1794
|
+
}
|
|
1795
|
+
export function currentLifecycleFailure(context, input) {
|
|
1796
|
+
if (input.turn !== undefined) {
|
|
1797
|
+
const hooks = context.db.all(`SELECT id, outcome_json FROM hook_events
|
|
1798
|
+
WHERE json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ?
|
|
1799
|
+
AND json_extract(outcome_json, '$.stage') = ? AND json_extract(outcome_json, '$.controller_turn') = ?
|
|
1800
|
+
${input.projectId ? "AND project_id = ?" : ""}
|
|
1801
|
+
ORDER BY id DESC`, [input.runId, input.generation, input.stage, input.turn, ...(input.projectId ? [input.projectId] : [])]);
|
|
1802
|
+
const completed = new Set();
|
|
1803
|
+
const activeAttempt = input.projectId ? stageAttempt(context, input.projectId, input.runId, input.stage) : null;
|
|
1804
|
+
for (const hook of hooks) {
|
|
1805
|
+
let outcome;
|
|
1806
|
+
try {
|
|
1807
|
+
outcome = JSON.parse(hook.outcome_json);
|
|
1808
|
+
}
|
|
1809
|
+
catch {
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
if (activeAttempt && outcome.attempt && outcome.attempt !== activeAttempt)
|
|
1813
|
+
continue;
|
|
1814
|
+
const key = JSON.stringify([outcome.operation ?? "", outcome.session_id ?? null, outcome.correlation_key ?? outcome.fingerprint ?? hook.id]);
|
|
1815
|
+
if (outcome.disposition === "completed") {
|
|
1816
|
+
completed.add(key);
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
if (completed.has(key) || !outcome.error?.code)
|
|
1820
|
+
continue;
|
|
1821
|
+
const continuation = outcome.error.details?.continuation;
|
|
1822
|
+
if (continuation && typeof continuation === "object"
|
|
1823
|
+
&& continuation.run_id === input.runId
|
|
1824
|
+
&& continuation.stage === input.stage
|
|
1825
|
+
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
1826
|
+
continue;
|
|
1827
|
+
return { code: outcome.error.code, message: outcome.error.message ?? "Lifecycle command failed", details: outcome.error.details ?? {}, hook_event_id: hook.id };
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
const attempts = context.db.all("SELECT * FROM lifecycle_invocations ORDER BY updated_at DESC, rowid DESC");
|
|
1831
|
+
const scoped = attempts.filter(row => {
|
|
1832
|
+
try {
|
|
1833
|
+
const scope = JSON.parse(row.scope_json);
|
|
1834
|
+
return scope.runId === input.runId && scope.generation === input.generation && (!input.projectRoot || scope.projectRoot === input.projectRoot);
|
|
1835
|
+
}
|
|
1836
|
+
catch {
|
|
1837
|
+
return false;
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
const fingerprints = [...new Set(scoped.map(row => row.fingerprint))];
|
|
1841
|
+
for (const fingerprint of fingerprints) {
|
|
1842
|
+
const rows = scoped.filter(row => row.fingerprint === fingerprint);
|
|
1843
|
+
// An issued successor has not performed the correction. Once the Turn
|
|
1844
|
+
// ended it cannot erase the predecessor's actual rejection.
|
|
1845
|
+
if (rows.some(row => ["observed", "executing"].includes(row.status)))
|
|
1846
|
+
continue;
|
|
1847
|
+
const row = rows.find(candidate => candidate.status === "settled");
|
|
1848
|
+
if (!row)
|
|
1849
|
+
continue;
|
|
1850
|
+
let outcome;
|
|
1851
|
+
try {
|
|
1852
|
+
outcome = JSON.parse(row.outcome_json ?? "null") ?? {};
|
|
1853
|
+
}
|
|
1854
|
+
catch {
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
if (!outcome.error?.code || !outcome.error.message)
|
|
1858
|
+
continue;
|
|
1859
|
+
let parsed;
|
|
1860
|
+
try {
|
|
1861
|
+
parsed = invocation(row.command);
|
|
1862
|
+
}
|
|
1863
|
+
catch {
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
let stage = parsed.operation.startsWith("merge_") ? "merge" : commandOption(parsed, "stage");
|
|
1867
|
+
if (!stage && parsed.operation.startsWith("work_") && input.runId) {
|
|
1868
|
+
try {
|
|
1869
|
+
const work = resolveWorkReference(context, commandPosition(parsed, 0) ?? "", { runId: input.runId });
|
|
1870
|
+
const membership = context.db.get("SELECT stage FROM stage_work_memberships WHERE run_id = ? AND work_id = ? ORDER BY rowid DESC LIMIT 1", [input.runId, work.work_id]);
|
|
1871
|
+
stage = membership?.stage;
|
|
1872
|
+
}
|
|
1873
|
+
catch { /* A stale or unbound Work is not evidence for this Stage. */ }
|
|
1874
|
+
}
|
|
1875
|
+
if (stage !== input.stage)
|
|
1876
|
+
continue;
|
|
1877
|
+
const continuation = outcome.error.details?.continuation;
|
|
1878
|
+
if (continuation && typeof continuation === "object"
|
|
1879
|
+
&& continuation.run_id === input.runId
|
|
1880
|
+
&& continuation.stage === input.stage
|
|
1881
|
+
&& isRegisteredRepair(context, continuation, input.projectId ? { projectId: input.projectId, runId: input.runId } : undefined))
|
|
1882
|
+
continue;
|
|
1883
|
+
return { code: outcome.error.code, message: outcome.error.message, details: outcome.error.details ?? {}, invocation_id: row.id };
|
|
1884
|
+
}
|
|
1885
|
+
return null;
|
|
1886
|
+
}
|
|
1887
|
+
/** One caller owns execution; a lost process leaves an explicit unknown
|
|
1888
|
+
* outcome, never an automatic replay. Existing recovery decides the next step. */
|
|
1889
|
+
export async function awaitLifecycleInvocation(context, input) {
|
|
1890
|
+
const initial = load(context, input.id);
|
|
1891
|
+
try {
|
|
1892
|
+
if (initial.status === "settled") {
|
|
1893
|
+
checkCommand(initial, input.command);
|
|
1894
|
+
return { outcome: JSON.parse(initial.outcome_json), replay: true };
|
|
1895
|
+
}
|
|
1896
|
+
input.validateArgs?.();
|
|
1897
|
+
checkCommand(initial, input.command);
|
|
1898
|
+
// A terminal command is immutable evidence. Do not require a now-gone
|
|
1899
|
+
// caller payload merely to replay its recorded result.
|
|
1900
|
+
}
|
|
1901
|
+
catch (error) {
|
|
1902
|
+
if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
|
|
1903
|
+
throw error;
|
|
1904
|
+
Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
|
|
1905
|
+
throw error;
|
|
1906
|
+
}
|
|
1907
|
+
if (["issued", "observed"].includes(initial.status)) {
|
|
1908
|
+
try {
|
|
1909
|
+
await input.prepare?.();
|
|
1910
|
+
}
|
|
1911
|
+
catch (error) {
|
|
1912
|
+
if (!(error instanceof AppError) || (error.details.effect !== undefined && error.details.effect !== "no_effect"))
|
|
1913
|
+
throw error;
|
|
1914
|
+
const details = { ...error.details, phase: "prepare", effect: "no_effect" };
|
|
1915
|
+
const recoverable = error.exitCode === 2 && ["validation", "schema_validation"].includes(error.code)
|
|
1916
|
+
? error.details.recoverable !== false
|
|
1917
|
+
: isRetryableLifecycleRejection(error.code, details);
|
|
1918
|
+
Object.assign(error.details, { phase: "prepare", effect: "no_effect", recoverable });
|
|
1919
|
+
Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
|
|
1920
|
+
throw error;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const timeoutMs = input.timeoutMs ?? 30000;
|
|
1924
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
1925
|
+
throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
|
|
1926
|
+
context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
|
|
1927
|
+
for (;;) {
|
|
1928
|
+
const row = load(context, input.id);
|
|
1929
|
+
if (row.status === "settled")
|
|
1930
|
+
return { outcome: JSON.parse(row.outcome_json), replay: true };
|
|
1931
|
+
if (row.status === "executing") {
|
|
1932
|
+
const scope = JSON.parse(row.scope_json);
|
|
1933
|
+
if (invocation(row.command).operation === "work_start" && row.event_key) {
|
|
1934
|
+
input.assertCurrent(scope);
|
|
1935
|
+
const result = recoverCommittedWorkStart(context, row.id, row.event_key, scope.generation);
|
|
1936
|
+
if (result) {
|
|
1937
|
+
const outcome = { result, effect: "committed" };
|
|
1938
|
+
settleLifecycleInvocation(context, row.id, outcome);
|
|
1939
|
+
return { outcome, replay: true };
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id, effect: "unknown" });
|
|
1943
|
+
}
|
|
1944
|
+
input.assertCurrent(JSON.parse(row.scope_json));
|
|
1945
|
+
if (row.status === "observed") {
|
|
1946
|
+
if (row.identity_json) {
|
|
1947
|
+
// Receipt display text is bounded/redacted and may not be valid JSON.
|
|
1948
|
+
// Only the native admission's typed identity binds an execution route.
|
|
1949
|
+
const identity = JSON.parse(row.identity_json);
|
|
1950
|
+
const route = identity[5]?.executionCwd;
|
|
1951
|
+
if (identity.length > 5 && (typeof route !== "string" || !path.isAbsolute(route)))
|
|
1952
|
+
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd is invalid", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1953
|
+
if (typeof route === "string" && canonicalPath(process.cwd()) !== canonicalPath(route))
|
|
1954
|
+
throw new AppError("invocation_directory_mismatch", "Reached CLI cwd differs from the bound shell execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false, expected: route, actual: process.cwd(), actual_provenance: "cli_process_cwd" });
|
|
1955
|
+
}
|
|
1956
|
+
// Another native observation can replace an unclaimed receipt. Claim
|
|
1957
|
+
// exactly the identity/route checked above, or reload and validate it.
|
|
1958
|
+
const workStart = invocation(row.command).operation === "work_start";
|
|
1959
|
+
const birth = workStart ? processStartedAt(process.pid) : null, cwd = workStart ? process.cwd() : "";
|
|
1960
|
+
input.assertCurrent(JSON.parse(row.scope_json));
|
|
1961
|
+
let admittedAt = "";
|
|
1962
|
+
const claimed = context.db.writeTransaction(() => {
|
|
1963
|
+
// Acquire the writer before taking the anchor; waiting for SQLite or
|
|
1964
|
+
// physical birth observation must not make an old Hook appear fresh.
|
|
1965
|
+
admittedAt = context.now();
|
|
1966
|
+
return context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed' AND event_key IS ? AND identity_json IS ? AND scope_json=? AND command=?", [admittedAt, input.id, row.event_key, row.identity_json, row.scope_json, row.command]);
|
|
1967
|
+
});
|
|
1968
|
+
if (claimed.changes === 1) {
|
|
1969
|
+
if (birth) {
|
|
1970
|
+
let owned = currentAdmissions.get(context.db);
|
|
1971
|
+
if (!owned) {
|
|
1972
|
+
owned = new Map();
|
|
1973
|
+
currentAdmissions.set(context.db, owned);
|
|
1974
|
+
}
|
|
1975
|
+
if (owned.size >= 256)
|
|
1976
|
+
owned.delete(owned.keys().next().value);
|
|
1977
|
+
owned.set(input.id, { eventKey: row.event_key, identity: row.identity_json, scope: row.scope_json, command: row.command, cwd, pid: process.pid, birth, admittedAt });
|
|
1978
|
+
}
|
|
1979
|
+
return { eventKey: row.event_key, replay: false };
|
|
1980
|
+
}
|
|
1981
|
+
continue;
|
|
1982
|
+
}
|
|
1983
|
+
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
|
|
1984
|
+
context.db.writeTransaction(() => {
|
|
1985
|
+
const current = load(context, input.id);
|
|
1986
|
+
if (["issued", "expired"].includes(current.status)) {
|
|
1987
|
+
input.assertCurrent(JSON.parse(current.scope_json));
|
|
1988
|
+
settleUnadmittedInvocation(context, current, receiptTimeout(input.id));
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
continue;
|
|
1992
|
+
}
|
|
1993
|
+
await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
export function settleLifecycleInvocation(context, id, outcome) {
|
|
1997
|
+
currentAdmissions.get(context.db)?.delete(id);
|
|
1998
|
+
const encoded = JSON.stringify(outcome);
|
|
1999
|
+
if (encoded === undefined)
|
|
2000
|
+
throw new AppError("invocation_outcome_invalid", "Invocation outcome must be JSON", 1);
|
|
2001
|
+
const changed = context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]);
|
|
2002
|
+
if (changed.changes !== 1) {
|
|
2003
|
+
const row = load(context, id);
|
|
2004
|
+
if (row.status !== "settled" || row.outcome_json !== encoded)
|
|
2005
|
+
throw new AppError("invocation_settlement_conflict", "Attempt cannot be settled with a different outcome", 1, { invocation_id: id });
|
|
2006
|
+
}
|
|
2007
|
+
}
|