@deksden-com/dd-flow-cli 0.9.0-beta.136 → 0.9.0-beta.138

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.
Files changed (230) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +9 -0
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/argument-values.js +5 -5
  5. package/dist/cli/command-inputs.js +89 -20
  6. package/dist/cli/command-metadata.js +56 -3
  7. package/dist/cli/command-option-descriptions.js +5 -0
  8. package/dist/cli/executor-onboarding.js +8 -3
  9. package/dist/cli/flows.js +29 -0
  10. package/dist/cli/help.js +240 -6
  11. package/dist/cli/input-preparation.js +54 -5
  12. package/dist/cli/memory-flow-admission.js +44 -0
  13. package/dist/cli/memory-init-invocation.js +78 -0
  14. package/dist/cli/memory-init.js +240 -0
  15. package/dist/cli/memory-storage-admission.js +53 -0
  16. package/dist/cli/native-hook-ingress.js +1 -1
  17. package/dist/cli/response-file.js +2 -0
  18. package/dist/cli/run-cli.js +283 -160
  19. package/dist/cli/setup.js +9 -7
  20. package/dist/cli.js +54 -2
  21. package/dist/domain/stage-handler-identities.js +20 -0
  22. package/dist/harness-runtime/DD-ZCODE.md +13 -0
  23. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
  24. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  25. package/dist/harness-runtime/lib/daemon-operations.mjs +4 -3
  26. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +4 -2
  27. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +11 -5
  28. package/dist/harness-runtime/lib/dd-codex.mjs +22 -10
  29. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +2 -1
  30. package/dist/harness-runtime/lib/dd-droid.mjs +2 -1
  31. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -2
  32. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +20 -5
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +45 -10
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
  35. package/dist/harness-runtime/lib/delegation-instructions.mjs +5 -3
  36. package/dist/harness-runtime/lib/lifecycle-command.mjs +1 -0
  37. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -0
  38. package/dist/harness-runtime/lib/managed-daemon.mjs +50 -15
  39. package/dist/harness-runtime/lib/managed-flow-env.mjs +15 -0
  40. package/dist/harness-runtime/lib/native-hook-command.mjs +1 -1
  41. package/dist/harness-runtime/lib/native-request-proof.d.mts +2 -0
  42. package/dist/harness-runtime/lib/native-request-proof.mjs +65 -0
  43. package/dist/harness-runtime/lib/opencode-plugin.mjs +5 -1
  44. package/dist/harness-runtime/lib/operation-progress.mjs +3 -1
  45. package/dist/runtime/context.js +1 -1
  46. package/dist/schemas/discovery-result.schema.json +184 -0
  47. package/dist/schemas/execution-bundle.schema.json +205 -0
  48. package/dist/schemas/execution-settings.schema.json +162 -0
  49. package/dist/schemas/flow-descriptor.schema.json +54 -0
  50. package/dist/schemas/flow-run.schema.json +22 -0
  51. package/dist/schemas/harness-catalog.schema.json +80 -0
  52. package/dist/schemas/mb-init-compose-result.schema.json +457 -0
  53. package/dist/schemas/mb-init-discover-result.schema.json +500 -0
  54. package/dist/schemas/mb-init-finish-result.schema.json +422 -0
  55. package/dist/schemas/mb-init-preflight-result.schema.json +367 -0
  56. package/dist/schemas/mb-init-review-result.schema.json +396 -0
  57. package/dist/schemas/mb-init-verify-result.schema.json +391 -0
  58. package/dist/schemas/memory-bootstrap-receipt-v2.schema.json +67 -0
  59. package/dist/schemas/memory-bootstrap-receipt.schema.json +354 -0
  60. package/dist/schemas/memory-composition-work-result.schema.json +38 -0
  61. package/dist/schemas/memory-repair-work-result.schema.json +49 -0
  62. package/dist/schemas/memory-review-correction-plan.schema.json +30 -0
  63. package/dist/schemas/memory-review-work-result.schema.json +43 -0
  64. package/dist/schemas/model-catalog.schema.json +153 -0
  65. package/dist/schemas/profile-settings.schema.json +122 -0
  66. package/dist/schemas/release-manifest.schema.json +291 -0
  67. package/dist/schemas/run-engine-binding.schema.json +1 -1
  68. package/dist/services/check-runner-evidence.js +51 -4
  69. package/dist/services/cli-operation-classifier.js +8 -47
  70. package/dist/services/code-checks.js +387 -34
  71. package/dist/services/controller-child-observer.js +99 -0
  72. package/dist/services/controller-execution.js +88 -0
  73. package/dist/services/controller-fanout.js +79 -13
  74. package/dist/services/dashboard.js +8 -2
  75. package/dist/services/descriptor-coordinator-session.js +35 -0
  76. package/dist/services/descriptor-root-work.js +53 -0
  77. package/dist/services/descriptor-run-settlement.js +105 -0
  78. package/dist/services/descriptor-run-start.js +449 -0
  79. package/dist/services/descriptor-stage-coordinator.js +197 -0
  80. package/dist/services/descriptor-stage-settlement.js +222 -0
  81. package/dist/services/descriptor-stage-start.js +139 -0
  82. package/dist/services/engines.js +39 -22
  83. package/dist/services/eval-snapshots.js +83 -25
  84. package/dist/services/execution-bundles.js +263 -0
  85. package/dist/services/execution-policy.js +20 -16
  86. package/dist/services/external-work-launch.js +91 -11
  87. package/dist/services/flow-catalog.js +21 -0
  88. package/dist/services/flow-descriptors.js +82 -0
  89. package/dist/services/frozen-engine.js +242 -0
  90. package/dist/services/generic-execution.js +164 -0
  91. package/dist/services/harness-adapter.js +28 -2
  92. package/dist/services/hooks.js +24 -6
  93. package/dist/services/hybrid-contracts.js +31 -3
  94. package/dist/services/ids.js +29 -1
  95. package/dist/services/installation-lock.js +7 -4
  96. package/dist/services/installed-flow-source.js +183 -0
  97. package/dist/services/lifecycle-invocations.js +257 -78
  98. package/dist/services/memory-accepted-composition.js +77 -0
  99. package/dist/services/memory-baseline-preservation.js +65 -0
  100. package/dist/services/memory-completed-correction-wave.js +320 -0
  101. package/dist/services/memory-compose-handler.js +258 -0
  102. package/dist/services/memory-composition-acceptance.js +199 -0
  103. package/dist/services/memory-composition-graph.js +134 -0
  104. package/dist/services/memory-composition-registration.js +146 -0
  105. package/dist/services/memory-composition-work-custody.js +151 -0
  106. package/dist/services/memory-composition-work-results.js +96 -0
  107. package/dist/services/memory-corrected-review-acceptance.js +129 -0
  108. package/dist/services/memory-corrected-review-batch.js +30 -0
  109. package/dist/services/memory-corrected-review-findings.js +106 -0
  110. package/dist/services/memory-corrected-review-inputs.js +210 -0
  111. package/dist/services/memory-corrected-review-projection.js +78 -0
  112. package/dist/services/memory-corrected-review-receipt.js +170 -0
  113. package/dist/services/memory-corrected-review-registration.js +191 -0
  114. package/dist/services/memory-correction-check-receipt.js +219 -0
  115. package/dist/services/memory-correction-input-owner.js +6 -0
  116. package/dist/services/memory-discover-handler.js +104 -0
  117. package/dist/services/memory-finish-evidence.js +435 -0
  118. package/dist/services/memory-finish-handler.js +215 -0
  119. package/dist/services/memory-finish-integration.js +280 -0
  120. package/dist/services/memory-finish-readiness.js +122 -0
  121. package/dist/services/memory-git-integration.js +151 -0
  122. package/dist/services/memory-historical-corrected-review.js +206 -0
  123. package/dist/services/memory-historical-original-review.js +130 -0
  124. package/dist/services/memory-historical-repair-registration.js +99 -0
  125. package/dist/services/memory-historical-second-review.js +128 -0
  126. package/dist/services/memory-init-bootstrap-readiness.js +210 -0
  127. package/dist/services/memory-init-contexts.js +89 -0
  128. package/dist/services/memory-init-controller-start.js +84 -0
  129. package/dist/services/memory-init-engine-release.js +223 -0
  130. package/dist/services/memory-init-git-policy.js +136 -0
  131. package/dist/services/memory-init-lint-capability.js +185 -0
  132. package/dist/services/memory-init-native-capacity.js +241 -0
  133. package/dist/services/memory-init-preflight.js +312 -0
  134. package/dist/services/memory-init-project-bootstrap.js +599 -0
  135. package/dist/services/memory-init-request-candidate.js +157 -0
  136. package/dist/services/memory-init-request-input.js +66 -0
  137. package/dist/services/memory-init-resource-storage.js +125 -0
  138. package/dist/services/memory-init-run-allocation.js +81 -0
  139. package/dist/services/memory-init-run-bundle.js +98 -0
  140. package/dist/services/memory-init-start.js +175 -0
  141. package/dist/services/memory-init-startup-custody.js +80 -0
  142. package/dist/services/memory-init-startup-input.js +240 -0
  143. package/dist/services/memory-init-storage.js +81 -0
  144. package/dist/services/memory-init-stores.js +359 -0
  145. package/dist/services/memory-init-worktrunk-capability.js +172 -0
  146. package/dist/services/memory-layer-publication.js +611 -0
  147. package/dist/services/memory-layer.js +156 -0
  148. package/dist/services/memory-lint-config.js +85 -0
  149. package/dist/services/memory-native-review-copy.js +165 -0
  150. package/dist/services/memory-normal-flow-entry.js +75 -0
  151. package/dist/services/memory-ordinary-defaults.js +112 -0
  152. package/dist/services/memory-preflight-bootstrap-custody.js +115 -0
  153. package/dist/services/memory-preflight-handler.js +259 -0
  154. package/dist/services/memory-repair-current-view.js +299 -0
  155. package/dist/services/memory-repair-document-delta.js +451 -0
  156. package/dist/services/memory-repair-history.js +491 -0
  157. package/dist/services/memory-repair-physical-binding.js +312 -0
  158. package/dist/services/memory-repair-publication-files.js +152 -0
  159. package/dist/services/memory-repair-publication-intent.js +27 -0
  160. package/dist/services/memory-repair-publication-plan.js +160 -0
  161. package/dist/services/memory-repair-publication-source.js +225 -0
  162. package/dist/services/memory-repair-publication.js +794 -0
  163. package/dist/services/memory-repair-registration.js +239 -0
  164. package/dist/services/memory-repair-work-baseline.js +413 -0
  165. package/dist/services/memory-repair-work-completion.js +97 -0
  166. package/dist/services/memory-repair-work-custody.js +73 -0
  167. package/dist/services/memory-repair-work-results.js +89 -0
  168. package/dist/services/memory-repair-workspace.js +252 -0
  169. package/dist/services/memory-repair-writer-copy.js +735 -0
  170. package/dist/services/memory-result-binding.js +173 -0
  171. package/dist/services/memory-retained-review-authority.js +129 -0
  172. package/dist/services/memory-retained-review-results.js +70 -0
  173. package/dist/services/memory-retained-second-baseline.js +102 -0
  174. package/dist/services/memory-retained-second-repairs.js +156 -0
  175. package/dist/services/memory-review-acceptance.js +214 -0
  176. package/dist/services/memory-review-correction-plan.js +171 -0
  177. package/dist/services/memory-review-handler.js +585 -0
  178. package/dist/services/memory-review-inputs.js +200 -0
  179. package/dist/services/memory-review-receipt.js +180 -0
  180. package/dist/services/memory-review-registration.js +108 -0
  181. package/dist/services/memory-review-work-custody.js +124 -0
  182. package/dist/services/memory-review-work-input.js +72 -0
  183. package/dist/services/memory-review-work-results.js +73 -0
  184. package/dist/services/memory-second-corrected-review-inputs.js +165 -0
  185. package/dist/services/memory-second-correction-plan.js +76 -0
  186. package/dist/services/memory-second-repair-baseline.js +91 -0
  187. package/dist/services/memory-second-repair-launch.js +614 -0
  188. package/dist/services/memory-second-repair-registration.js +88 -0
  189. package/dist/services/memory-second-repair-source.js +187 -0
  190. package/dist/services/memory-second-review-acceptance.js +115 -0
  191. package/dist/services/memory-source-observations.js +117 -0
  192. package/dist/services/memory-stage-dispatch.js +28 -0
  193. package/dist/services/memory-stage-results.js +199 -0
  194. package/dist/services/memory-verification-acceptance.js +135 -0
  195. package/dist/services/memory-verification-checks.js +252 -0
  196. package/dist/services/memory-verification-receipt.js +159 -0
  197. package/dist/services/memory-verify-handler.js +135 -0
  198. package/dist/services/memory-work-result-preparation.js +63 -0
  199. package/dist/services/projects.js +17 -2
  200. package/dist/services/recovery-snapshot-database.js +416 -1
  201. package/dist/services/repair-intents.js +1 -1
  202. package/dist/services/review-copy.js +10 -2
  203. package/dist/services/run-control.js +80 -9
  204. package/dist/services/run-controller-capture.js +10 -3
  205. package/dist/services/run-controller-process.js +21 -0
  206. package/dist/services/run-controller-recovery.js +3 -1
  207. package/dist/services/run-controller-state.js +1 -1
  208. package/dist/services/run-controller.js +184 -48
  209. package/dist/services/run-recovery-guard.js +2 -0
  210. package/dist/services/run-recovery.js +145 -5
  211. package/dist/services/run-start-custody.js +177 -0
  212. package/dist/services/runs.js +319 -25
  213. package/dist/services/stage-context.js +9 -1
  214. package/dist/services/stage-handlers.js +74 -0
  215. package/dist/services/stage-lifecycle.js +2 -1
  216. package/dist/services/stage-report-renderer.js +76 -0
  217. package/dist/services/stage-work-graph.js +17 -0
  218. package/dist/services/vnext-code.js +3 -3
  219. package/dist/services/vnext-execution-profile.js +2 -2
  220. package/dist/services/vnext-fanout.js +136 -10
  221. package/dist/services/vnext-merge.js +8 -3
  222. package/dist/services/vnext-plan-review.js +6 -4
  223. package/dist/services/vnext-protocolize.js +60 -2
  224. package/dist/services/work-registry.js +616 -71
  225. package/dist/services/workspace-files.js +1 -1
  226. package/dist/storage/database.js +84 -6
  227. package/dist/storage/writer-contract.js +11 -7
  228. package/package.json +13 -3
  229. package/tools/create-canon-release.mjs +33 -0
  230. package/tools/produce-hybrid-release.mjs +41 -0
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs";
2
+ import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
3
+ /** A durable observation is a dispatch fence, not authority to complete Work. */
4
+ export function retainedChildObserver(input) {
5
+ let active = true, pending;
6
+ let gap = null;
7
+ const retained = new Map();
8
+ const sameScope = (scope) => scope?.controller_id === input.scope.controller_id && scope.generation === input.scope.generation && scope.stage === input.scope.stage && scope.root_session_id === input.scope.root_session_id;
9
+ const normalized = (child) => {
10
+ if (!child || child.parent_session_id !== input.scope.root_session_id || child.status !== "failed" || typeof child.terminal_turn_id !== "string" || ![child.session_id, child.terminal_turn_id, child.source].every(value => typeof value === "string" && value.length > 0 && value.length <= 256))
11
+ throw new Error("native_child_observation_invalid");
12
+ const provider_error = safeNativeFailure(child.provider_error);
13
+ return { session_id: child.session_id, parent_session_id: child.parent_session_id, status: "failed", source: child.source, terminal_turn_id: child.terminal_turn_id,
14
+ ...(typeof child.terminal_observed_at === "string" && Number.isFinite(Date.parse(child.terminal_observed_at)) ? { terminal_observed_at: child.terminal_observed_at } : {}),
15
+ ...(provider_error ? { provider_error } : {}) };
16
+ };
17
+ const accept = (children) => {
18
+ for (const raw of children) {
19
+ const child = normalized(raw);
20
+ retained.set(`${child.session_id}:${child.terminal_turn_id}`, child);
21
+ }
22
+ };
23
+ if (fs.existsSync(input.file))
24
+ for (const line of fs.readFileSync(input.file, "utf8").split("\n").filter(Boolean)) {
25
+ const observation = JSON.parse(line);
26
+ if (sameScope(observation.scope)) {
27
+ if (Array.isArray(observation.children))
28
+ accept(observation.children);
29
+ if (Object.hasOwn(observation, "gap"))
30
+ gap = observation.gap ?? null;
31
+ }
32
+ }
33
+ const complete = (children) => {
34
+ if (!active)
35
+ return;
36
+ input.assertOwner();
37
+ const failed = children.filter(child => child.parent_session_id === input.scope.root_session_id && child.status === "failed" && child.terminal_turn_id && !retained.has(`${child.session_id}:${child.terminal_turn_id}`)).map(normalized);
38
+ if (!failed.length && !gap)
39
+ return;
40
+ const fd = fs.openSync(input.file, "a", 0o600);
41
+ try {
42
+ fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, children: failed, gap: null })}\n`);
43
+ fs.fsyncSync(fd);
44
+ }
45
+ finally {
46
+ fs.closeSync(fd);
47
+ }
48
+ accept(failed);
49
+ gap = null;
50
+ };
51
+ return {
52
+ failed: () => [...retained.values()],
53
+ gap: () => gap,
54
+ drain: () => pending ?? Promise.resolve(),
55
+ completePostTurn(receipt) {
56
+ const evidence = receipt.evidence;
57
+ const tree = evidence?.subagents;
58
+ if (receipt.provider_session_id !== input.scope.root_session_id || receipt.observation_error || evidence?.observation_error || !evidence?.read?.projection || !evidence.read.session || !tree || tree.observation_error || !Array.isArray(tree.running))
59
+ return false;
60
+ if (tree.ended ? !Array.isArray(tree.ended.items) || tree.ended.total !== tree.ended.items.length || tree.ended.nextCursor : !Array.isArray(tree.completed))
61
+ return false;
62
+ const children = normalizeNativeChildren(receipt, input.scope.root_session_id);
63
+ if (children.some(child => child.status === "failed" && !child.terminal_turn_id))
64
+ return false;
65
+ complete(children);
66
+ return true;
67
+ },
68
+ tick() {
69
+ if (!active || pending)
70
+ return;
71
+ pending = (async () => {
72
+ const children = await input.read();
73
+ if (!active || children === null)
74
+ return;
75
+ complete(children);
76
+ })().catch(error => {
77
+ if (!active)
78
+ return;
79
+ try {
80
+ input.assertOwner();
81
+ const code = String(error?.code ?? "observation_unavailable");
82
+ gap = /^[\w-]{1,128}$/.test(code) ? code : "observation_unavailable";
83
+ const fd = fs.openSync(input.file, "a", 0o600);
84
+ try {
85
+ fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, gap })}\n`);
86
+ fs.fsyncSync(fd);
87
+ }
88
+ finally {
89
+ fs.closeSync(fd);
90
+ }
91
+ }
92
+ catch { /* Lost owner cannot publish; the gap remains an explicit caller error. */ }
93
+ input.gap(error);
94
+ }).finally(() => { pending = undefined; });
95
+ },
96
+ // The caller aborts its request; a late reply cannot publish after closure.
97
+ close() { active = false; return pending ?? Promise.resolve(); },
98
+ };
99
+ }
@@ -0,0 +1,88 @@
1
+ import path from "node:path";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { FrozenGenericExecution } from "./generic-execution.js";
4
+ import { resolveExecutionPolicy, resolveStageTransition, stageExecutionCwd } from "./execution-policy.js";
5
+ import { readFlowRunExecutionBundle } from "./runs.js";
6
+ import { hybridDigest } from "./hybrid-contracts.js";
7
+ /** Adapt generic routing to shared fanout APIs, not to SDLC settings. No alias,
8
+ * operator file, merge policy or project default participates in this selection.
9
+ */
10
+ export function genericControllerRouting(execution) {
11
+ if (!(execution instanceof FrozenGenericExecution) || execution.settings.execution !== "managed") {
12
+ throw new AppError("controller_execution_invalid", "Managed controller requires managed frozen execution", 1, { effect: "no_effect" });
13
+ }
14
+ const snapshot = execution.snapshot();
15
+ const profiles = Object.fromEntries(snapshot.profiles.map(value => [value.id, structuredClone(value.profile)]));
16
+ const delegation = { mode: execution.settings.delegation.mode,
17
+ ...(execution.settings.delegation.mode === "external" ? { max_parallel: execution.settings.delegation.max_parallel } : {}) };
18
+ const policy = { agent_profile_id: execution.settings.root_profile, delegation,
19
+ stage_overrides: Object.fromEntries(Object.entries(execution.settings.stage_profiles).map(([stage, id]) => [stage, { agent_profile_id: id }])) };
20
+ for (const profile of Object.values(profiles))
21
+ Object.freeze(profile);
22
+ Object.freeze(profiles);
23
+ for (const override of Object.values(policy.stage_overrides))
24
+ Object.freeze(override);
25
+ Object.freeze(policy.stage_overrides);
26
+ Object.freeze(delegation);
27
+ Object.freeze(policy);
28
+ return {
29
+ kind: "descriptor", policy, profiles, mergeMode: null,
30
+ transition(stage, fromStage) {
31
+ const selected = execution.stage(stage);
32
+ if (fromStage)
33
+ execution.stage(fromStage);
34
+ return { ...resolveExecutionPolicy({ ddFlowHome: "", policy, profiles, stage }), session_mode: selected.session_mode };
35
+ },
36
+ cwd(stage) { execution.stage(stage); return path.resolve(snapshot.scope.workspace); }
37
+ };
38
+ }
39
+ /** Restore execution from RUN authority, never from a generic manifest copy.
40
+ * Legacy SDLC manifests retain their original settings and routing behavior.
41
+ */
42
+ export function selectControllerExecution(context, run, manifest) {
43
+ if (manifest?.schema_id === "dd-flow/run-controller-manifest@2" || ["mb-init", "mb-upgrade"].includes(run.flow_kind)) {
44
+ const bundle = readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id });
45
+ if (!bundle || bundle.descriptor.descriptor.id !== run.flow_kind || bundle.descriptor.descriptor.id === "sdlc")
46
+ throw new AppError("controller_execution_invalid", "Generic controller requires its retained non-SDLC bundle", 1);
47
+ const binding = { bundle_sha256: bundle.sha256, descriptor_sha256: bundle.descriptor.sha256, execution_sha256: bundle.execution.sha256 };
48
+ if (manifest && (manifest.schema_id !== "dd-flow/run-controller-manifest@2" || manifest.execution_profile !== undefined
49
+ || !manifest.execution_binding || Object.keys(manifest.execution_binding).length !== 3
50
+ || Object.entries(binding).some(([key, value]) => manifest.execution_binding[key] !== value)
51
+ || manifest.request?.project_root !== run.project_root || manifest.request?.run_id !== run.id)) {
52
+ throw new AppError("controller_execution_invalid", "Controller execution binding differs from retained RUN authority", 1);
53
+ }
54
+ return { ...genericControllerRouting(bundle.execution), bundle, binding, legacy: undefined };
55
+ }
56
+ const index = JSON.parse(run.index_json);
57
+ const legacy = manifest ? manifest.execution_profile : index.execution_profile;
58
+ if (!legacy?.settings?.execution || !legacy.agent_profiles)
59
+ throw new AppError("execution_profile_not_frozen", "Managed launch requires the RUN's frozen execution profiles", 1);
60
+ const policy = legacy.settings.execution, profiles = legacy.agent_profiles;
61
+ const bundle = index.execution_bundle ? readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id }) : undefined;
62
+ if (index.execution_bundle && (!bundle || bundle.descriptor.descriptor.id !== "sdlc"))
63
+ throw new AppError("controller_execution_invalid", "SDLC controller lost its retained bundle authority", 1);
64
+ if (bundle) {
65
+ if (legacy.settings.stage_session_mode !== bundle.execution.settings.stage_session_mode)
66
+ throw new AppError("controller_execution_invalid", "SDLC session policy differs from its retained execution", 1);
67
+ for (const stage of bundle.descriptor.descriptor.stages) {
68
+ const selected = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy, profiles, stage: stage.id });
69
+ const expected = bundle.execution.work(stage.id, selected.worker.id);
70
+ if (hybridDigest(selected.coordinator) !== hybridDigest(expected.coordinator) || hybridDigest(selected.worker) !== hybridDigest(expected.worker)
71
+ || selected.delegation.mode !== expected.delegation.mode
72
+ || selected.delegation.mode === "external" && selected.delegation.max_parallel !== expected.delegation.max_parallel) {
73
+ throw new AppError("controller_execution_invalid", "SDLC routing differs from its retained execution", 1, { stage: stage.id });
74
+ }
75
+ }
76
+ }
77
+ return {
78
+ kind: "sdlc", policy, profiles, bundle: bundle ?? undefined, binding: undefined, legacy,
79
+ mergeMode: legacy.settings.merge_mode,
80
+ transition(stage, fromStage) {
81
+ return resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(fromStage ? { fromStage } : {}), stage,
82
+ projectRoot: run.project_root, workspaceRoot: run.workspace_root, stageSessionMode: legacy.settings.stage_session_mode, mergeMode: legacy.settings.merge_mode });
83
+ },
84
+ cwd(stage, mergeTargetWorkspace) {
85
+ return stageExecutionCwd({ stage, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(mergeTargetWorkspace ? { mergeTargetWorkspace } : {}) });
86
+ }
87
+ };
88
+ }
@@ -1,13 +1,15 @@
1
1
  import { AppError, errorRecord } from "../shared/errors.js";
2
2
  import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
3
- import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey } from "./vnext-fanout.js";
4
- import { getFlowRunVariables } from "./runs.js";
3
+ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, getStageFanoutCapacity, verifiedNativeWorkFailure } from "./vnext-fanout.js";
5
4
  import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
6
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
7
6
  import { lifecycleCommandCompletionInstruction, renderDelegationInstructions, stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
8
- import { assertWorkLaunchReady } from "./work-registry.js";
7
+ import { assertWorkLaunchReady, workStartCommand } from "./work-registry.js";
8
+ import { prepareMemoryNativeReviewCopy } from "./memory-native-review-copy.js";
9
+ import { registerMemoryRepairBaselineFromStage, reserveMemoryRepairWriterCopyFromStage, materializeMemoryRepairWriterCopyFromStage, prepareMemoryRepairDispatchFromStage, registerMemorySecondRepairBaselineFromStage, reserveMemorySecondRepairCopyFromStage, materializeMemorySecondRepairCopyFromStage, prepareMemorySecondRepairDispatchFromStage, registerMemoryRepairCurrentViewFromStage, registerMemorySecondRepairCurrentViewFromStage } from "./memory-review-handler.js";
9
10
  import { requireProjectByRoot } from "./projects.js";
10
- import { stageWorkIds } from "./stage-work-graph.js";
11
+ import { stageAttempt, stageWorkIds } from "./stage-work-graph.js";
12
+ import { runtimeCommand } from "./runtime-command.js";
11
13
  function semanticCompletionPrompt(stage, completed) {
12
14
  const task = stage === "plan-review"
13
15
  ? "Classify the reviewer findings, apply every accepted correction in the current PLAN-REVIEW Work, and write the authoritative review decision."
@@ -54,8 +56,11 @@ export async function nextControllerFanout(context, input) {
54
56
  throw new AppError("fanout_reconciliation_required", "Native children have unresolved lifecycle ownership", 1, { issues });
55
57
  }
56
58
  let fanout = getVnextFanoutStatus(context, input).orchestration;
57
- if (!fanout)
59
+ if (!fanout) {
60
+ if (input.dispatchFence)
61
+ throw new AppError("fanout_reconciliation_required", "Observed native failure has no current graph; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
58
62
  return { kind: "none" };
63
+ }
59
64
  if (fanout.state === "compatibility_required")
60
65
  throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
61
66
  if (children.length) {
@@ -67,6 +72,8 @@ export async function nextControllerFanout(context, input) {
67
72
  return { kind: "wait", reason: "native_children_unsettled" };
68
73
  fanout = getVnextFanoutStatus(context, input).orchestration;
69
74
  }
75
+ if (input.dispatchFence && !fanout.works.failed)
76
+ throw new AppError("fanout_reconciliation_required", "Observed native failure has no proven failed Work; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
70
77
  if (fanout.state === "dispatch_required") {
71
78
  dispatchVnextPlanReview(context, input);
72
79
  fanout = getVnextFanoutStatus(context, input).orchestration;
@@ -75,7 +82,7 @@ export async function nextControllerFanout(context, input) {
75
82
  }
76
83
  const prompt = (action, text) => ({ kind: "prompt", action, prompt: action === "dispatch_work" ? text : `${text}\n\n${lifecycleCommandCompletionInstruction}`, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision }) });
77
84
  if (fanout.state === "coordinator_required")
78
- return prompt("materialize_graph", `Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph with real dependencies. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
85
+ return prompt("materialize_graph", `Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph, declaring real dependencies at registration. If the packet's CLI command registers the graph, use its returned Work graph unchanged: do not add or alter dependencies after registration. Accepted predecessor Stages already fence their completed producers; do not add cross-stage edges merely to restate input provenance. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
79
86
  if (fanout.works.failed || fanout.works.cancelled) {
80
87
  const projectId = requireProjectByRoot(context, input.projectRoot).id;
81
88
  const members = stageWorkIds(context, projectId, input.runId, input.stage);
@@ -88,7 +95,9 @@ export async function nextControllerFanout(context, input) {
88
95
  }
89
96
  catch { /* Missing legacy metadata is unknown. */ }
90
97
  const providerError = safeNativeFailure(receipt.provider_error);
98
+ const nativeBinding = verifiedNativeWorkFailure(context, { projectRoot: input.projectRoot, runId: input.runId, stage: input.stage, parentSessionId: input.sessionId, parentWorkId: fanout.parent_work_id, workId: work.work_id, receipt });
91
99
  return { work_id: work.work_id, status: work.status, ...(providerError ? { provider_error: providerError } : {}),
100
+ ...(nativeBinding ? { native_binding: nativeBinding } : {}),
92
101
  ...Object.fromEntries(["terminal_turn_id", "terminal_observed_at"].filter(key => typeof receipt[key] === "string" && receipt[key].length <= 256).map(key => [key, receipt[key]])) };
93
102
  });
94
103
  throw new AppError("fanout_child_failed", "A child Work failed or was cancelled; no retry authority has been issued", 1, { stage: input.stage, run_id: input.runId, parent_work_id: fanout.parent_work_id, failed: fanout.works.failed, cancelled: fanout.works.cancelled, failures, effect: "unknown" });
@@ -99,22 +108,62 @@ export async function nextControllerFanout(context, input) {
99
108
  const override = payload ? JSON.parse(payload).execution_override : undefined;
100
109
  return { work, resolved: resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: input.policy, profiles: input.profiles, stage: input.stage, ...(override ? { work: override } : {}) }) };
101
110
  });
111
+ const prepareRepair = (work) => {
112
+ const row = context.db.get("SELECT payload_json,result_schema FROM works WHERE work_id=?", [work.work_id]);
113
+ const payload = JSON.parse(row?.payload_json ?? "null");
114
+ if (payload?.kind !== "memory-repair" || row?.result_schema !== "dd-flow/memory-repair-work-result@1")
115
+ return null;
116
+ const scope = { projectRoot: input.projectRoot, runId: input.runId, workId: work.work_id };
117
+ const second = payload.memory_repair?.wave === 2;
118
+ if (!second && payload.memory_repair?.wave !== 1)
119
+ throw new AppError("fanout_contract_invalid", "Memory repair wave is invalid", 1);
120
+ const registerBaseline = second ? registerMemorySecondRepairBaselineFromStage : registerMemoryRepairBaselineFromStage;
121
+ try {
122
+ registerBaseline(context, scope);
123
+ }
124
+ catch (error) {
125
+ const missing = error instanceof AppError && (second
126
+ ? error.code === "memory_second_repair_source_invalid" && error.details.reason === "registered_second_current_view_required"
127
+ : error.code === "memory_review_handler_invalid" && error.details.reason === "current_view_registration_required");
128
+ if (!missing)
129
+ throw error;
130
+ (second ? registerMemorySecondRepairCurrentViewFromStage : registerMemoryRepairCurrentViewFromStage)(context, scope);
131
+ registerBaseline(context, scope);
132
+ }
133
+ (second ? reserveMemorySecondRepairCopyFromStage : reserveMemoryRepairWriterCopyFromStage)(context, scope);
134
+ (second ? materializeMemorySecondRepairCopyFromStage : materializeMemoryRepairWriterCopyFromStage)(context, scope);
135
+ return (second ? prepareMemorySecondRepairDispatchFromStage : prepareMemoryRepairDispatchFromStage)(context, scope);
136
+ };
102
137
  const external = choices.filter(choice => choice.resolved.delegation.mode === "external");
103
138
  if (external.length) {
104
139
  const maximum = Math.min(...external.map(choice => choice.resolved.delegation.max_parallel ?? 1));
105
- return { kind: "external", works: external.slice(0, maximum).map(choice => choice.work) };
140
+ const selected = external.slice(0, maximum).map(choice => choice.work);
141
+ for (const work of selected)
142
+ prepareRepair(work);
143
+ return { kind: "external", works: selected };
106
144
  }
107
- const variables = getFlowRunVariables(context, input);
108
- const capacity = variables.variables[subagentCapacityKey];
145
+ const capacity = getStageFanoutCapacity(context, { ...input, nativeOnly: true }).available_slots;
109
146
  if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1)
110
147
  return { kind: "wait", reason: "qualified_capacity_required" };
111
- const wave = choices.slice(0, capacity).map(choice => choice.work);
148
+ const wave = choices.slice(0, capacity).map(choice => {
149
+ const work = choice.work;
150
+ const row = context.db.get("SELECT payload_json,result_schema FROM works WHERE work_id=?", [work.work_id]);
151
+ const payload = JSON.parse(row?.payload_json ?? "null");
152
+ if (payload?.kind === "memory-review" && row?.result_schema === "dd-flow/memory-review-work-result@1") {
153
+ const copy = prepareMemoryNativeReviewCopy(context, work.work_id, true);
154
+ return { ...work, start_command: `cd '${copy.root.replaceAll("'", "'\\''")}' && ${workStartCommand(context, assertWorkLaunchReady(context, work.work_id))}` };
155
+ }
156
+ const packet = prepareRepair(work);
157
+ const command = workStartCommand(context, assertWorkLaunchReady(context, work.work_id));
158
+ if (!packet)
159
+ return { ...work, start_command: command };
160
+ // Ordinary literal shell grammar, not an invented native spawn CWD field.
161
+ const quotedRoot = "'" + packet.copy_root.replaceAll("'", "'\\''") + "'";
162
+ return { ...work, start_command: `cd ${quotedRoot} && ${command}` };
163
+ });
112
164
  for (const work of wave)
113
165
  if (!work.work_id || !work.start_command)
114
166
  throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
115
- if (input.stage === "plan-review" || input.stage === "code-review")
116
- for (const work of wave)
117
- assertWorkLaunchReady(context, work.work_id);
118
167
  const totalWorks = fanout.works.created + fanout.works.running + fanout.works.completed + fanout.works.failed + fanout.works.cancelled;
119
168
  return prompt("dispatch_work", renderDelegationInstructions({ harness: input.harness, stage: input.stage, capacity, works: wave, totalWorks, remainingWorks: fanout.works.created - wave.length }));
120
169
  }
@@ -122,6 +171,23 @@ export async function nextControllerFanout(context, input) {
122
171
  return { kind: "wait", reason: "work_receipts_pending" };
123
172
  if (fanout.works.created)
124
173
  throw new AppError("fanout_graph_stalled", "Created Work has no ready or running dependency path", 1);
174
+ if (input.stage === "review") {
175
+ const projectId = requireProjectByRoot(context, input.projectRoot).id;
176
+ const run = context.db.get("SELECT flow_kind FROM runs WHERE project_id=? AND id=?", [projectId, input.runId]);
177
+ if (run?.flow_kind === "mb-init") {
178
+ const attempt = stageAttempt(context, projectId, input.runId, "review");
179
+ const repair = context.db.get(`SELECT CASE event_type WHEN 'memory.second_repair_registration' THEN 2 ELSE 1 END AS wave
180
+ FROM audit_events WHERE project_id=? AND event_type IN ('memory.repair_registration','memory.second_repair_registration')
181
+ AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.attempt')=?
182
+ AND json_extract(payload_json,'$.scope.parent_work_id')=? ORDER BY wave DESC LIMIT 1`, [projectId, input.runId, attempt, fanout.parent_work_id]);
183
+ if (repair && !context.db.get(`SELECT 1 FROM audit_events WHERE project_id=? AND event_type='memory.corrected_review_registration'
184
+ AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.attempt')=?
185
+ AND json_extract(payload_json,'$.scope.parent_work_id')=? AND json_extract(payload_json,'$.wave')=?`, [projectId, input.runId, attempt, fanout.parent_work_id, repair.wave])) {
186
+ const args = ["--run", input.runId, "--project-root", input.projectRoot, "--wave", String(repair.wave), "--json"];
187
+ return prompt("semantic_completion", `Correction wave ${repair.wave} Work has settled. Before Stage finish, run ${runtimeCommand(context, ["memory", "corrections", "checks", ...args])}, then ${runtimeCommand(context, ["memory", "review", "selective-register", ...args])}. Read both complete responses, then end this Turn so the controller can dispatch the independent selective reviewers. Do not finish the Stage or treat repair Work completion as independent acceptance.`);
188
+ }
189
+ }
190
+ }
125
191
  return prompt("semantic_completion", semanticCompletionPrompt(input.stage, fanout.works.completed));
126
192
  }
127
193
  /** Shared pinned runtime normalization; preserve structured errors at the TS boundary. */
@@ -6,7 +6,7 @@ import { isActiveProtocolStatus, isActiveQueueStatus, isQueueHistoryStatus } fro
6
6
  import { AppError } from "../shared/errors.js";
7
7
  import { prepareOutputFile } from "../cli/input-preparation.js";
8
8
  import { ensureDir, resolveProjectRoot, resolveProtocolPlanPath } from "../storage/paths.js";
9
- import { dashboardMarkdownPath, globalDashboardHtmlPath, globalDashboardJsonPath, globalDashboardMarkdownPath, projectDashboardHtmlPath, projectDashboardJsonPath, projectSummaryJsonPath, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
9
+ import { dashboardMarkdownPath, globalDashboardHtmlPath, globalDashboardJsonPath, globalDashboardMarkdownPath, projectDashboardHtmlPath, projectDashboardJsonPath, projectDashboardDir, projectSummaryJsonPath, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
10
10
  import { queueForProject } from "./merge-queue.js";
11
11
  import { publishProjectSummary } from "./project-summary.js";
12
12
  import { requireProjectByRoot } from "./projects.js";
@@ -215,7 +215,13 @@ export function autoRefreshDashboards(context, input) {
215
215
  // Their derived timestamp must not mutate the captured workspace.
216
216
  if (projectRecoveryGuarded(context, project.id))
217
217
  return { ok: true, skipped: true, reason: "run_recovery_guarded" };
218
- const config = readProjectConfig(context, project.id);
218
+ const configured = readProjectConfig(context, project.id);
219
+ // Automatic observation must not change the source baseline owned by init.
220
+ // Keep projections in Home; do not hide runtime-created paths in Git or
221
+ // weaken source preservation by excluding user-owned .tasks directories.
222
+ const initializing = context.db.get("SELECT 1 FROM runs WHERE project_id=? AND flow_kind='mb-init' AND (id=? OR status IN ('created','running','paused','waiting_for_user','blocked')) LIMIT 1", [project.id, input.runId ?? null]);
223
+ const config = initializing ? { ...configured, dashboard: { ...configured.dashboard,
224
+ markdown_path: path.join(projectDashboardDir(context.ddFlowHome, project.id), "project-dashboard.md") } } : configured;
219
225
  if (!config.dashboard.auto_refresh) {
220
226
  return { ok: true, skipped: true, reason: "dashboard_auto_refresh_disabled" };
221
227
  }
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { readDescriptorStageInput, readDescriptorStageInputForRecovery } from "./descriptor-stage-coordinator.js";
4
+ import { runtimeHarness } from "./execution-policy.js";
5
+ import { stageGraphScope } from "./stage-work-graph.js";
6
+ import { hybridDigest } from "./hybrid-contracts.js";
7
+ /** SQL-only physical coordinator proof, independent of allocation/prompt
8
+ * custody. It creates no Session, hook claim or provider operation.
9
+ */
10
+ export function readDescriptorCoordinatorSession(context, input, refuse = reason => {
11
+ throw new AppError("descriptor_coordinator_session_invalid", "Coordinator has no matching physical Session custody", 1, { reason, effect: "no_effect" });
12
+ }) {
13
+ const { scope, bundle, workId } = input;
14
+ const rows = context.db.all("SELECT ws.id,ws.session_id,ws.hook_event_id,ws.prompt_path,ws.result_path,s.harness,s.provider_session_id FROM work_sessions ws JOIN sessions s ON s.session_id=ws.session_id AND s.project_id=? WHERE ws.work_id=? AND ws.status='running' AND s.run_id=? AND s.current_stage=? AND s.status='active' LIMIT 2", [scope.projectId, workId, scope.runId, scope.stage]);
15
+ if (rows.length !== 1)
16
+ refuse("active_coordinator_session_required");
17
+ const session = rows[0], route = bundle.execution.stage(scope.stage);
18
+ const selected = input.recoveryRead ? readDescriptorStageInputForRecovery(context, scope, bundle, input.recoveryRead) : readDescriptorStageInput(context, scope, bundle), directory = bundle.descriptor.stage(scope.stage).directory;
19
+ if (session.prompt_path !== path.join(selected.runRoot, directory, "coordinator-prompt.md")
20
+ || session.result_path !== path.join(selected.runRoot, directory, "result-input.json"))
21
+ refuse("coordinator_packet_link_changed");
22
+ if (session.harness !== runtimeHarness(route.profile)
23
+ || !context.db.get("SELECT 1 FROM hook_events WHERE project_id=? AND event_key=? AND session_id=? AND harness=? AND provider_session_id=? AND status='claimed' AND event_name='PreToolUse'", [scope.projectId, session.hook_event_id, session.session_id, session.harness, session.provider_session_id]))
24
+ refuse("coordinator_start_custody_missing");
25
+ const bindings = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type='stage.coordinator_binding' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.stage')=? AND json_extract(payload_json,'$.work_session_id')=? LIMIT 33", [scope.projectId, scope.runId, scope.stage, session.id]);
26
+ if (!bindings.length || bindings.length > 32 || bindings.some(row => Buffer.byteLength(row.payload_json) > 32768))
27
+ refuse("physical_binding_missing");
28
+ const binding = JSON.parse(bindings[0].payload_json);
29
+ if (bindings.some(row => hybridDigest(JSON.parse(row.payload_json)) !== hybridDigest(binding))
30
+ || binding.work_id !== workId || binding.session_id !== session.session_id || binding.harness !== session.harness || binding.provider_session_id !== session.provider_session_id
31
+ || binding.execution_sha256 !== bundle.execution.sha256 || binding.profile_id !== route.profile.id || binding.profile_sha256 !== hybridDigest(route.profile)
32
+ || binding.session_mode !== route.session_mode || hybridDigest(binding.scope) !== hybridDigest({ ...stageGraphScope(context, scope.projectId, scope.runId, scope.stage), stage: scope.stage }))
33
+ refuse("physical_binding_changed");
34
+ return hybridDigest({ session, binding });
35
+ }
@@ -0,0 +1,53 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { assertPreparedRunBundleSqlAuthority } from "./runs.js";
3
+ import { hybridDigest } from "./hybrid-contracts.js";
4
+ import { nextWorkId } from "./ids.js";
5
+ import { appendAudit } from "./audit.js";
6
+ const event = "flow_run.descriptor_root_work";
7
+ function refuse(reason) {
8
+ throw new AppError("descriptor_root_work_invalid", "RUN root Work lacks independent allocation custody", 1, { reason, effect: "no_effect" });
9
+ }
10
+ const binding = (scope, bundle) => ({ projectId: scope.projectId, runId: scope.runId, bundle_sha256: bundle.sha256 });
11
+ /** SQL-only read. Root identity is retained independently, never discovered
12
+ * from an arbitrary root row or reconstructed after receipt loss.
13
+ */
14
+ export function readDescriptorRootWork(context, scope, bundle) {
15
+ return context.db.readSnapshot(() => {
16
+ const run = assertPreparedRunBundleSqlAuthority(context, scope, bundle);
17
+ const receipts = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.run_id')=? LIMIT 2", [scope.projectId, event, scope.runId]);
18
+ if (receipts.length !== 1 || Buffer.byteLength(receipts[0].payload_json) > 16 * 1024)
19
+ refuse("root_work_receipt_missing");
20
+ const receipt = JSON.parse(receipts[0].payload_json);
21
+ const expected = binding(scope, bundle);
22
+ if (typeof receipt.work_id !== "string" || hybridDigest(receipt) !== hybridDigest({ run_id: scope.runId, work_id: receipt.work_id, binding: expected }))
23
+ refuse("root_work_changed");
24
+ const roots = context.db.all("SELECT work_id,task,payload_json,status,launch_policy,result_schema,depends_on_json FROM works WHERE project_id=? AND run_id=? AND parent_work_id IS NULL LIMIT 2", [scope.projectId, scope.runId]);
25
+ if (roots.length !== 1)
26
+ refuse("ambiguous_root_work");
27
+ const root = roots[0];
28
+ if (root.work_id !== receipt.work_id || root.task !== `Coordinate ${bundle.descriptor.descriptor.id}`
29
+ || root.payload_json !== JSON.stringify({ descriptor_run_coordinator: expected })
30
+ || root.launch_policy !== "reuse_allowed" || root.result_schema !== null || root.depends_on_json !== "[]")
31
+ refuse("root_work_custody_missing");
32
+ if (JSON.parse(run.index_json).root_work_id !== root.work_id)
33
+ refuse("root_work_anchor_missing");
34
+ return { workId: root.work_id, status: root.status };
35
+ });
36
+ }
37
+ /** Called only in the same writer as fresh descriptor RUN allocation. Stage
38
+ * handlers consume this identity; they never allocate a second RUN root.
39
+ */
40
+ export function allocateDescriptorRootWork(context, scope, bundle) {
41
+ assertPreparedRunBundleSqlAuthority(context, scope, bundle);
42
+ if (context.db.get("SELECT 1 FROM works WHERE project_id=? AND run_id=? AND parent_work_id IS NULL", [scope.projectId, scope.runId])
43
+ || context.db.get("SELECT 1 FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.run_id')=?", [scope.projectId, event, scope.runId]))
44
+ refuse("root_work_already_allocated");
45
+ const workId = nextWorkId(context, scope.projectId, bundle.descriptor.descriptor.id), now = context.now();
46
+ const expected = binding(scope, bundle);
47
+ context.db.run("INSERT INTO works (work_id,project_id,run_id,parent_work_id,task,launch_policy,depends_on_json,status,payload_json,created_at,updated_at) VALUES (?,?,?,NULL,?,'reuse_allowed','[]','created',?,?,?)", [workId, scope.projectId, scope.runId, `Coordinate ${bundle.descriptor.descriptor.id}`, JSON.stringify({ descriptor_run_coordinator: expected }), now, now]);
48
+ context.db.run("UPDATE runs SET index_json=json_set(index_json,'$.root_work_id',?) WHERE project_id=? AND id=?", [workId, scope.projectId, scope.runId]);
49
+ appendAudit(context, { projectId: scope.projectId, eventType: event, payload: { run_id: scope.runId, work_id: workId, binding: expected } });
50
+ if (readDescriptorRootWork(context, scope, bundle).workId !== workId)
51
+ refuse("root_work_not_retained");
52
+ return workId;
53
+ }
@@ -0,0 +1,105 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { requireProjectByRoot } from "./projects.js";
3
+ import { resolveProjectRoot } from "../storage/paths.js";
4
+ import { appendFlowRunTimelineEvent, assertPreparedRunBundleSqlAuthority, completeDescriptorRunState, gitFacts, publishRunProjections, readFlowRunExecutionBundle, withStageSettlement } from "./runs.js";
5
+ import { readDescriptorRootWork } from "./descriptor-root-work.js";
6
+ import { readAcceptedDescriptorStageSettlement } from "./descriptor-stage-settlement.js";
7
+ import { entityReferenceVariants } from "../shared/entity-references.js";
8
+ import { stageLifecycleMatchKey } from "./hooks.js";
9
+ import { readAcceptedStageData } from "./stage-report-renderer.js";
10
+ import { assertRunMutationAllowed } from "./run-recovery-guard.js";
11
+ import { appendAudit } from "./audit.js";
12
+ import { hybridDigest } from "./hybrid-contracts.js";
13
+ const event = "flow_run.descriptor_run_settlement";
14
+ function refuse(reason) {
15
+ throw new AppError("descriptor_run_settlement_invalid", "Terminal descriptor RUN lacks exact settlement custody", 1, { reason, effect: "no_effect" });
16
+ }
17
+ function selected(context, scope, bundle, matchKeys) {
18
+ assertPreparedRunBundleSqlAuthority(context, scope, bundle);
19
+ const root = readDescriptorRootWork(context, scope, bundle);
20
+ const run = context.db.get("SELECT status,index_json FROM runs WHERE project_id=? AND id=?", [scope.projectId, scope.runId]);
21
+ const index = JSON.parse(run.index_json);
22
+ const terminal = index.stage_runs.at(-1);
23
+ if (!bundle.descriptor.descriptor.terminal_stages.includes(scope.stage)
24
+ || terminal?.stage !== scope.stage || terminal.status !== "done")
25
+ refuse("terminal_stage_not_accepted");
26
+ const data = readAcceptedStageData(context, scope);
27
+ const stageReceipt = readAcceptedDescriptorStageSettlement(context, scope, bundle, matchKeys);
28
+ if (context.db.get("SELECT 1 FROM works WHERE project_id=? AND run_id=? AND work_id<>? AND status IN ('created','running','paused') LIMIT 1", [scope.projectId, scope.runId, root.workId])
29
+ || context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id WHERE w.project_id=? AND w.run_id=? AND ws.status='running' LIMIT 1", [scope.projectId, scope.runId]))
30
+ refuse("work_still_active");
31
+ const prior = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.run_id')=? LIMIT 2", [scope.projectId, event, scope.runId]);
32
+ if (prior.length > 1 || prior.some(row => Buffer.byteLength(row.payload_json) > 32 * 1024))
33
+ refuse("ambiguous_receipt");
34
+ if (root.status !== (prior.length ? "completed" : "running") || run.status !== (prior.length ? "done" : "running"))
35
+ refuse("terminal_state_changed");
36
+ return { rootId: root.workId, result: data.bytes.toString("utf8"), binding: { ...scope, work_id: root.workId, bundle_sha256: bundle.sha256, result_sha256: data.result.sha256, input_sha256: data.input_sha256, stage_receipt_sha256: hybridDigest(stageReceipt) }, prior: prior[0] ? JSON.parse(prior[0].payload_json) : null };
37
+ }
38
+ /** Runtime closure only. A terminal handler must first verify semantic/check/
39
+ * review/Git acceptance, revalidate physical proof, and supply its SQL fence.
40
+ * Transcript usage collection is deliberately outside this atomic writer.
41
+ */
42
+ export class PreparedDescriptorRunSettlement {
43
+ #scope;
44
+ #bundle;
45
+ #owner;
46
+ #git;
47
+ #generation;
48
+ #matchKeys;
49
+ constructor(scope, bundle, owner, git, generation, matchKeys) {
50
+ this.#scope = scope;
51
+ this.#bundle = bundle;
52
+ this.#owner = owner;
53
+ this.#git = git;
54
+ this.#generation = generation;
55
+ this.#matchKeys = matchKeys;
56
+ Object.freeze(this);
57
+ }
58
+ static prepare(context, input) {
59
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)), bundle = readFlowRunExecutionBundle(context, input);
60
+ if (!bundle)
61
+ refuse("bundle_required");
62
+ const scope = { projectId: project.id, runId: input.runId, stage: input.stage };
63
+ const matchKeys = entityReferenceVariants(input.runId).map(reference => stageLifecycleMatchKey("stage_finish", reference, input.stage, input.projectRoot));
64
+ const snapshot = context.db.readSnapshot(() => ({ owner: selected(context, scope, bundle, matchKeys), generation: assertRunMutationAllowed(context, scope.projectId, scope.runId) }));
65
+ const git = snapshot.owner.prior?.git ?? gitFacts(bundle.execution.snapshot().scope.workspace);
66
+ return new PreparedDescriptorRunSettlement(scope, bundle, snapshot.owner, git, snapshot.generation, matchKeys);
67
+ }
68
+ apply(context, assertHandlerCustody) {
69
+ if (typeof assertHandlerCustody !== "function")
70
+ refuse("handler_fence_required");
71
+ const bundle = readFlowRunExecutionBundle(context, { projectRoot: this.#bundle.execution.snapshot().scope.projectRoot, runId: this.#scope.runId });
72
+ if (!bundle || bundle.sha256 !== this.#bundle.sha256)
73
+ refuse("bundle_changed");
74
+ const observed = context.db.readSnapshot(() => selected(context, this.#scope, this.#bundle, this.#matchKeys));
75
+ if (!observed.prior && hybridDigest(gitFacts(this.#bundle.execution.snapshot().scope.workspace)) !== hybridDigest(this.#git))
76
+ refuse("git_observation_changed");
77
+ const receipt = { run_id: this.#scope.runId, work_id: this.#owner.rootId, binding: this.#owner.binding, git: this.#git };
78
+ const changed = withStageSettlement(context, () => {
79
+ assertRunMutationAllowed(context, this.#scope.projectId, this.#scope.runId, this.#generation);
80
+ assertHandlerCustody(context);
81
+ const current = selected(context, this.#scope, this.#bundle, this.#matchKeys);
82
+ if (hybridDigest(current.binding) !== hybridDigest(this.#owner.binding) || current.result !== this.#owner.result)
83
+ refuse("terminal_custody_changed");
84
+ if (current.prior) {
85
+ if (hybridDigest(current.prior) !== hybridDigest(receipt)
86
+ || context.db.get("SELECT result FROM works WHERE work_id=?", [current.rootId])?.result !== current.result)
87
+ refuse("settlement_changed");
88
+ return false;
89
+ }
90
+ if (this.#owner.prior)
91
+ refuse("settlement_receipt_missing");
92
+ const now = context.now();
93
+ if (context.db.run("UPDATE works SET status='completed',result=?,completed_at=?,updated_at=? WHERE work_id=? AND project_id=? AND run_id=? AND status='running'", [current.result, now, now, current.rootId, this.#scope.projectId, this.#scope.runId]).changes !== 1)
94
+ refuse("root_changed");
95
+ appendFlowRunTimelineEvent(context, this.#scope.projectId, this.#scope.runId, { at: now, type: "work_completed", work_id: current.rootId, descriptor_terminal: true });
96
+ completeDescriptorRunState(context, this.#scope, this.#bundle, this.#git);
97
+ appendAudit(context, { projectId: this.#scope.projectId, eventType: event, payload: receipt });
98
+ return true;
99
+ });
100
+ // A committed terminal receipt can outlive an interrupted file publisher.
101
+ // Replay restores projections from current SQL, without settling again.
102
+ publishRunProjections(context, this.#scope.projectId, this.#scope.runId);
103
+ return { changed, run_id: this.#scope.runId, work_id: this.#owner.rootId, status: "done" };
104
+ }
105
+ }