@deksden-com/dd-flow-cli 0.9.0-beta.151 → 0.9.0-beta.155

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 (227) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/build-info.json +8 -8
  3. package/dist/cli/command-inputs.js +33 -3
  4. package/dist/cli/command-metadata.js +73 -2
  5. package/dist/cli/delivery.js +108 -0
  6. package/dist/cli/diagnostic-invocation.js +23 -7
  7. package/dist/cli/diagnostic.js +14 -3
  8. package/dist/cli/help.js +117 -1
  9. package/dist/cli/hook-ingress.js +36 -49
  10. package/dist/cli/interactive-invocation.js +33 -0
  11. package/dist/cli/interactive.js +114 -0
  12. package/dist/cli/memory-fix-invocation.js +52 -0
  13. package/dist/cli/memory-fix.js +100 -0
  14. package/dist/cli/memory-flow-admission.js +7 -3
  15. package/dist/cli/memory-init.js +5 -4
  16. package/dist/cli/native-hook-ingress.js +5 -1
  17. package/dist/cli/native-hook-preparation.js +31 -4
  18. package/dist/cli/run-cli.js +273 -19
  19. package/dist/cli.js +17 -3
  20. package/dist/domain/delivery-flows.js +8 -0
  21. package/dist/domain/distill-flow.js +20 -0
  22. package/dist/domain/interactive-flow.js +1 -0
  23. package/dist/domain/memory-fix-flow.js +2 -0
  24. package/dist/domain/review-fix-flow.js +2 -0
  25. package/dist/domain/source-snapshot-flows.js +4 -0
  26. package/dist/harness-runtime/bin/dd-agy.mjs +18 -3
  27. package/dist/harness-runtime/bin/dd-grok.mjs +15 -5
  28. package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
  29. package/dist/harness-runtime/lib/agy-error-boundary.mjs +18 -10
  30. package/dist/harness-runtime/lib/agy-native-inspection.mjs +12 -3
  31. package/dist/harness-runtime/lib/agy-native-inventory.mjs +19 -0
  32. package/dist/harness-runtime/lib/agy-terminal-envelope.mjs +13 -6
  33. package/dist/harness-runtime/lib/agy-transcript-stream.mjs +54 -0
  34. package/dist/harness-runtime/lib/daemon-observation.d.mts +1 -0
  35. package/dist/harness-runtime/lib/daemon-observation.mjs +12 -4
  36. package/dist/harness-runtime/lib/daemon-operations.mjs +20 -5
  37. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +1 -0
  38. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +371 -114
  39. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +43 -3
  40. package/dist/harness-runtime/lib/dd-codex.mjs +36 -4
  41. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +25 -11
  42. package/dist/harness-runtime/lib/dd-droid.mjs +124 -11
  43. package/dist/harness-runtime/lib/dd-grok-daemon.d.mts +1 -0
  44. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +137 -25
  45. package/dist/harness-runtime/lib/dd-grok.mjs +4 -2
  46. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +170 -25
  47. package/dist/harness-runtime/lib/dd-opencode.mjs +40 -19
  48. package/dist/harness-runtime/lib/dd-zcode-daemon.d.mts +1 -0
  49. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +115 -22
  50. package/dist/harness-runtime/lib/dd-zcode.mjs +75 -36
  51. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -0
  52. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -3
  53. package/dist/harness-runtime/lib/delivery-command.mjs +84 -0
  54. package/dist/harness-runtime/lib/driver-recovery.mjs +0 -19
  55. package/dist/harness-runtime/lib/droid-observation.mjs +9 -5
  56. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +1 -0
  57. package/dist/harness-runtime/lib/lifecycle-contract.mjs +2 -1
  58. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -2
  59. package/dist/harness-runtime/lib/native-hook-command.mjs +63 -8
  60. package/dist/harness-runtime/lib/native-streams.mjs +3 -10
  61. package/dist/harness-runtime/lib/opencode-plugin.mjs +36 -9
  62. package/dist/harness-runtime/lib/operation-errors.mjs +13 -0
  63. package/dist/harness-runtime/lib/operation-progress.mjs +6 -2
  64. package/dist/harness-runtime/lib/process-json.mjs +4 -5
  65. package/dist/harness-runtime/lib/tool-observations.mjs +20 -10
  66. package/dist/harness-runtime/lib/zcode-native-hook-ownership.mjs +109 -0
  67. package/dist/schemas/code-work-result.schema.json +2 -1
  68. package/dist/schemas/delivery-stage-result.schema.json +286 -0
  69. package/dist/schemas/distill-stage-result.schema.json +667 -0
  70. package/dist/schemas/distill-work-result.schema.json +428 -0
  71. package/dist/schemas/execution-bundle.schema.json +6 -2
  72. package/dist/schemas/interactive-stage-result.schema.json +47 -0
  73. package/dist/schemas/interactive-work-result.schema.json +63 -0
  74. package/dist/schemas/mb-fix-stage-result.schema.json +93 -0
  75. package/dist/schemas/project-flow-pack-manifest.schema.json +2 -2
  76. package/dist/schemas/review-fix-stage-result.schema.json +210 -0
  77. package/dist/schemas/run-control-receipt.schema.json +1 -0
  78. package/dist/services/assigned-lifecycle.js +382 -0
  79. package/dist/services/check-runner-evidence.js +77 -10
  80. package/dist/services/check-runner-proof.js +69 -1
  81. package/dist/services/cli-operation-classifier.js +6 -1
  82. package/dist/services/code-checks.js +56 -23
  83. package/dist/services/codex-hook-delivery.js +11 -2
  84. package/dist/services/controller-execution.js +4 -1
  85. package/dist/services/controller-fanout.js +9 -4
  86. package/dist/services/controller-memory-worker.js +6 -4
  87. package/dist/services/controller-result-authority.js +122 -0
  88. package/dist/services/dashboard.js +4 -2
  89. package/dist/services/delivery-access.js +123 -0
  90. package/dist/services/delivery-approval.js +68 -0
  91. package/dist/services/delivery-artifact.js +86 -0
  92. package/dist/services/delivery-checks.js +178 -0
  93. package/dist/services/delivery-execution.js +181 -0
  94. package/dist/services/delivery-intents.js +212 -0
  95. package/dist/services/delivery-operation.js +81 -0
  96. package/dist/services/delivery-policy.js +91 -0
  97. package/dist/services/delivery-procedures.js +43 -0
  98. package/dist/services/delivery-request.js +99 -0
  99. package/dist/services/delivery-result.js +144 -0
  100. package/dist/services/delivery-stage-commands.js +26 -0
  101. package/dist/services/delivery-stage-entry.js +56 -0
  102. package/dist/services/delivery-stage-handlers.js +135 -0
  103. package/dist/services/delivery-stage-results.js +94 -0
  104. package/dist/services/descriptor-coordinator-session.js +3 -1
  105. package/dist/services/descriptor-run-start.js +24 -4
  106. package/dist/services/descriptor-stage-coordinator.js +78 -10
  107. package/dist/services/descriptor-stage-settlement.js +59 -14
  108. package/dist/services/diagnostic-repair-intake.js +123 -0
  109. package/dist/services/diagnostic-repair-source-intake.js +102 -0
  110. package/dist/services/diagnostic-report.js +30 -0
  111. package/dist/services/diagnostic-run-bundle.js +48 -9
  112. package/dist/services/diagnostic-snapshot.js +2 -2
  113. package/dist/services/diagnostic-stage-handlers.js +3 -2
  114. package/dist/services/diagnostic-stage-results.js +26 -0
  115. package/dist/services/diagnostic-start.js +56 -6
  116. package/dist/services/diagnostic-work-collection.js +3 -3
  117. package/dist/services/distill-document-publication.js +280 -0
  118. package/dist/services/distill-promotion.js +197 -0
  119. package/dist/services/distill-publication-custody.js +162 -0
  120. package/dist/services/distill-stage-handlers.js +249 -0
  121. package/dist/services/distill-stage-results.js +105 -0
  122. package/dist/services/distill-user-inputs.js +4 -0
  123. package/dist/services/distill-work-batch.js +42 -0
  124. package/dist/services/distill-work-collection.js +74 -0
  125. package/dist/services/distill-work-registration.js +93 -0
  126. package/dist/services/distill-work-results.js +98 -0
  127. package/dist/services/eval-snapshots.js +17 -7
  128. package/dist/services/external-work-launch.js +8 -2
  129. package/dist/services/flow-catalog.js +24 -0
  130. package/dist/services/frozen-engine.js +19 -2
  131. package/dist/services/generic-execution.js +4 -3
  132. package/dist/services/harness-adapter.js +2 -14
  133. package/dist/services/hook-memory-routing.js +1 -1
  134. package/dist/services/hooks.js +31 -6
  135. package/dist/services/hybrid-contracts.js +6 -1
  136. package/dist/services/installed-flow-source.js +37 -2
  137. package/dist/services/interactive-attached-session.js +215 -0
  138. package/dist/services/interactive-baseline-custody.js +54 -0
  139. package/dist/services/interactive-before-versions.js +148 -0
  140. package/dist/services/interactive-bundle-data.js +85 -0
  141. package/dist/services/interactive-checks.js +179 -0
  142. package/dist/services/interactive-execution.js +33 -0
  143. package/dist/services/interactive-finish-integration.js +236 -0
  144. package/dist/services/interactive-git-baseline.js +230 -0
  145. package/dist/services/interactive-git-dispatch.js +278 -0
  146. package/dist/services/interactive-input-delivery.js +82 -0
  147. package/dist/services/interactive-mutation-scope.js +231 -0
  148. package/dist/services/interactive-request.js +287 -0
  149. package/dist/services/interactive-run-bundle.js +109 -0
  150. package/dist/services/interactive-stage-entry.js +41 -0
  151. package/dist/services/interactive-stage-handlers.js +289 -0
  152. package/dist/services/interactive-stage-observation.js +136 -0
  153. package/dist/services/interactive-stage-results.js +111 -0
  154. package/dist/services/interactive-user-inputs.js +142 -0
  155. package/dist/services/interactive-work-batch.js +43 -0
  156. package/dist/services/interactive-work-collection.js +119 -0
  157. package/dist/services/interactive-work-completion.js +180 -0
  158. package/dist/services/interactive-work-context.js +249 -0
  159. package/dist/services/interactive-work-history.js +112 -0
  160. package/dist/services/interactive-work-registration.js +124 -0
  161. package/dist/services/interactive-work-results.js +115 -0
  162. package/dist/services/interactive-work-start.js +155 -0
  163. package/dist/services/lifecycle-invocations.js +214 -24
  164. package/dist/services/managed-daemon-binding.js +52 -1
  165. package/dist/services/memory-compose-handler.js +2 -22
  166. package/dist/services/memory-finish-integration.js +58 -17
  167. package/dist/services/memory-fix-finish-integration.js +129 -0
  168. package/dist/services/memory-fix-git-dispatch.js +139 -0
  169. package/dist/services/memory-fix-intake-custody.js +11 -0
  170. package/dist/services/memory-fix-intake-settlement.js +127 -0
  171. package/dist/services/memory-fix-publication.js +196 -0
  172. package/dist/services/memory-fix-repair-admission.js +116 -0
  173. package/dist/services/memory-fix-request.js +141 -0
  174. package/dist/services/memory-fix-run-bundle.js +83 -0
  175. package/dist/services/memory-fix-scope.js +78 -0
  176. package/dist/services/memory-fix-sources.js +85 -0
  177. package/dist/services/memory-fix-stage-entry.js +24 -0
  178. package/dist/services/memory-fix-stage-handlers.js +282 -0
  179. package/dist/services/memory-fix-stage-results.js +106 -0
  180. package/dist/services/memory-fix-workspace.js +127 -0
  181. package/dist/services/memory-flow-user-inputs.js +88 -0
  182. package/dist/services/memory-init-contexts.js +55 -5
  183. package/dist/services/memory-init-controller-start.js +20 -4
  184. package/dist/services/memory-layer.js +7 -3
  185. package/dist/services/memory-native-review-copy.js +27 -3
  186. package/dist/services/memory-repair-document-delta.js +1 -0
  187. package/dist/services/project-flow-source.js +195 -0
  188. package/dist/services/project-git-integration.js +92 -0
  189. package/dist/services/repair-intents.js +2 -4
  190. package/dist/services/retained-native-hook.js +245 -0
  191. package/dist/services/review-fix-handoff.js +82 -0
  192. package/dist/services/review-fix-intake-custody.js +17 -0
  193. package/dist/services/review-fix-stage-entry.js +43 -0
  194. package/dist/services/review-fix-stage-handlers.js +176 -0
  195. package/dist/services/review-fix-stage-results.js +86 -0
  196. package/dist/services/run-control-worker.js +42 -1
  197. package/dist/services/run-control.js +40 -5
  198. package/dist/services/run-controller-adapter.js +37 -3
  199. package/dist/services/run-controller-process.js +12 -4
  200. package/dist/services/run-controller-recovery.js +46 -6
  201. package/dist/services/run-controller-state.js +3 -2
  202. package/dist/services/run-controller.js +209 -43
  203. package/dist/services/run-recovery.js +31 -2
  204. package/dist/services/run-timeline.js +59 -0
  205. package/dist/services/runs.js +18 -31
  206. package/dist/services/sessions.js +2 -1
  207. package/dist/services/stage-handlers.js +55 -3
  208. package/dist/services/stage-lifecycle.js +4 -3
  209. package/dist/services/stage-pause.js +26 -9
  210. package/dist/services/stage-report-renderer.js +2 -6
  211. package/dist/services/technical-notes.js +31 -0
  212. package/dist/services/verification-matrix.js +1 -1
  213. package/dist/services/version-status.js +1 -1
  214. package/dist/services/vnext-code-review.js +74 -5
  215. package/dist/services/vnext-code.js +9 -32
  216. package/dist/services/vnext-fanout.js +53 -8
  217. package/dist/services/vnext-merge.js +10 -5
  218. package/dist/services/vnext-plan-review.js +1 -1
  219. package/dist/services/vnext-plan.js +1 -1
  220. package/dist/services/vnext-protocolize.js +1 -1
  221. package/dist/services/vnext-specify.js +10 -5
  222. package/dist/services/work-registry.js +252 -66
  223. package/dist/services/worktrees.js +7 -4
  224. package/dist/storage/database.js +2 -1
  225. package/dist/verification-components.json +82 -0
  226. package/package.json +4 -3
  227. package/tools/verification-jobs.mjs +342 -0
@@ -0,0 +1,155 @@
1
+ import { createHash } from "node:crypto";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { hybridDigest } from "./hybrid-contracts.js";
4
+ import { readFlowRunExecutionBundle, assertPreparedRunBundleSqlAuthority } from "./runs.js";
5
+ import { readRegisteredInteractiveWork } from "./interactive-work-registration.js";
6
+ import { PreparedInteractiveWorkspaceChanges } from "./interactive-mutation-scope.js";
7
+ import { PreparedInteractiveGitBaseline } from "./interactive-git-baseline.js";
8
+ import { readDescriptorStageInput } from "./descriptor-stage-coordinator.js";
9
+ import { stageGraphScope } from "./stage-work-graph.js";
10
+ import { assertRunMutationAllowed } from "./run-recovery-guard.js";
11
+ import { readBoundedReleaseFile } from "./release-source.js";
12
+ const event = "interactive.work_start";
13
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
14
+ function refuse(reason) {
15
+ throw new AppError("interactive_work_start_invalid", "Interactive Work requires its issued input and actual physical start", 2, { reason, effect: "no_effect" });
16
+ }
17
+ export function readInteractiveWorkGit(bundle) {
18
+ const git = JSON.parse(bundle.blob("runtime/git-policy.json").toString("utf8"));
19
+ if (!git || typeof git.git_executable !== "string" || !/^[a-f0-9]{64}$/.test(git.git_sha256)
20
+ || hash(readBoundedReleaseFile(git.git_executable, 64 * 1024 * 1024)) !== git.git_sha256)
21
+ refuse("selected_git_changed");
22
+ return { git_executable: git.git_executable, git_sha256: git.git_sha256 };
23
+ }
24
+ /** Historical SQL-only start custody. Never recapture already edited sources. */
25
+ export function readInteractiveWorkStart(context, scope, bundle, workSessionId) {
26
+ const assignment = readRegisteredInteractiveWork(context, scope, bundle, scope.workId);
27
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.work_session_id')=? LIMIT 2", [scope.projectId, event, workSessionId]);
28
+ if (rows.length !== 1 || Buffer.byteLength(rows[0].payload_json) > 33 * 1024 * 1024)
29
+ refuse("start_custody_missing");
30
+ const receipt = JSON.parse(rows[0].payload_json);
31
+ if (!receipt || Object.keys(receipt).sort().join() !== "actor,attempt,bundle_sha256,hook_event_id,hook_event_key,packet_sha256,run_id,schema_id,snapshot,snapshot_sha256,stage,work_id,work_session_id"
32
+ || receipt.schema_id !== "dd-flow/interactive-work-start@1" || receipt.run_id !== scope.runId || receipt.work_id !== scope.workId
33
+ || receipt.work_session_id !== workSessionId || receipt.stage !== assignment.packet.stage || receipt.attempt !== assignment.receipt.attempt
34
+ || receipt.packet_sha256 !== assignment.packet.packet_sha256 || receipt.bundle_sha256 !== bundle.sha256
35
+ || !receipt.actor || Object.keys(receipt.actor).sort().join() !== "harness,provider_session_id,session_id")
36
+ refuse("start_binding_changed");
37
+ const actor = receipt.actor;
38
+ if (!context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN hook_events h ON h.project_id=w.project_id AND h.event_key=ws.hook_event_id WHERE w.project_id=? AND w.run_id=? AND w.work_id=? AND ws.id=? AND ws.session_id=? AND ws.hook_event_id=? AND h.id=? AND h.status='claimed' AND h.session_id=ws.session_id AND h.harness=? AND h.provider_session_id=?", [scope.projectId, scope.runId, scope.workId, workSessionId, actor.session_id, receipt.hook_event_key, receipt.hook_event_id, actor.harness, actor.provider_session_id]))
39
+ refuse("physical_start_custody_changed");
40
+ const baseline = PreparedInteractiveGitBaseline.restore(Buffer.from(JSON.stringify(receipt.snapshot)), receipt.snapshot_sha256);
41
+ const start = context.db.get("SELECT start_receipt_json FROM work_sessions WHERE id=? AND work_id=?", [workSessionId, scope.workId]);
42
+ if (!start?.start_receipt_json || Buffer.byteLength(start.start_receipt_json) > 33 * 1024 * 1024)
43
+ refuse("start_packet_custody_missing");
44
+ const packetReceipt = JSON.parse(start.start_receipt_json);
45
+ if (typeof packetReceipt.context !== "string" || hash(Buffer.from(packetReceipt.context)) !== packetReceipt.context_sha256)
46
+ refuse("start_packet_changed");
47
+ const startContext = JSON.parse(packetReceipt.context);
48
+ if (startContext.run_id !== scope.runId || startContext.work_id !== scope.workId
49
+ || hybridDigest(startContext.interactive_start) !== hybridDigest({ schema_id: "dd-flow/interactive-work-start-binding@1",
50
+ packet_sha256: assignment.packet.packet_sha256, snapshot_sha256: baseline.sha256 }))
51
+ refuse("start_snapshot_binding_changed");
52
+ const frozen = bundle.execution.snapshot().scope;
53
+ if (baseline.snapshot().project_root !== frozen.projectRoot || baseline.snapshot().workspace !== frozen.workspace)
54
+ refuse("physical_start_scope_changed");
55
+ return { receipt, baseline, assignment };
56
+ }
57
+ /** Physical preparation precedes the ordinary hook/Session claim writer.
58
+ * The original Work snapshot is retained with that claim, never after edits. */
59
+ export class PreparedInteractiveWorkStart {
60
+ #scope;
61
+ #bundle;
62
+ #assignment;
63
+ #changes;
64
+ #baseline;
65
+ #git;
66
+ constructor(scope, bundle, assignment, changes, git) {
67
+ this.#scope = { ...scope };
68
+ this.#bundle = bundle;
69
+ this.#assignment = assignment;
70
+ this.#changes = changes;
71
+ const bytes = Buffer.from(JSON.stringify(changes.currentSnapshot()));
72
+ this.#baseline = PreparedInteractiveGitBaseline.restore(bytes, hash(bytes));
73
+ this.#git = { ...git };
74
+ Object.freeze(this);
75
+ }
76
+ static prepare(context, input) {
77
+ if (context.db.isOwnedWriteTransaction?.())
78
+ refuse("preparation_outside_writer_required");
79
+ const bundle = readFlowRunExecutionBundle(context, input);
80
+ if (!bundle)
81
+ refuse("bundle_required");
82
+ const assignment = readRegisteredInteractiveWork(context, input, bundle, input.workId), frozen = bundle.execution.snapshot().scope;
83
+ const git = readInteractiveWorkGit(bundle);
84
+ const changes = PreparedInteractiveWorkspaceChanges.prepare(context, input, bundle, { projectRoot: frozen.projectRoot,
85
+ workspace: frozen.workspace, gitExecutable: git.git_executable, env: context.env });
86
+ const prepared = new PreparedInteractiveWorkStart(input, bundle, assignment, changes, git);
87
+ const patch = changes.snapshot(), packet = assignment.packet;
88
+ if (hybridDigest(patch.delta.changes) !== packet.file_changes_sha256 || patch.scope_revision !== packet.scope_revision
89
+ || patch.user_inputs_sha256 !== packet.user_inputs_sha256)
90
+ refuse("issued_context_changed_before_start");
91
+ // Unchanged files are also selected reviewer/writer inputs, not just patch paths.
92
+ const files = new Map(changes.currentSnapshot().entries.filter(item => item.kind === "file").map(item => [item.path, item.sha256]));
93
+ if (packet.read_inputs.some(ref => ref.root === "workspace" && files.get(ref.path) !== ref.sha256))
94
+ refuse("issued_source_changed_before_start");
95
+ context.db.readSnapshot(() => prepared.assertSelection(context));
96
+ prepared.revalidate();
97
+ return prepared;
98
+ }
99
+ revalidate() {
100
+ if (hash(readBoundedReleaseFile(this.#git.git_executable, 64 * 1024 * 1024)) !== this.#git.git_sha256)
101
+ refuse("selected_git_changed");
102
+ this.#changes.revalidate();
103
+ }
104
+ binding() {
105
+ return { schema_id: "dd-flow/interactive-work-start-binding@1", packet_sha256: this.#assignment.packet.packet_sha256, snapshot_sha256: this.#baseline.sha256 };
106
+ }
107
+ assertSelection(context) {
108
+ const { projectId, runId, workId } = this.#scope, packet = this.#assignment.packet;
109
+ const current = readRegisteredInteractiveWork(context, this.#scope, this.#bundle, workId);
110
+ if (hybridDigest(current.receipt) !== hybridDigest(this.#assignment.receipt)
111
+ || hybridDigest(current.custody) !== hybridDigest(this.#assignment.custody)
112
+ || current.packet.packet_sha256 !== this.#assignment.packet.packet_sha256)
113
+ refuse("assignment_changed_before_start");
114
+ const run = assertPreparedRunBundleSqlAuthority(context, this.#scope, this.#bundle), index = JSON.parse(run.index_json);
115
+ const graph = stageGraphScope(context, projectId, runId, packet.owner_stage), stage = readDescriptorStageInput(context, { ...this.#scope, stage: packet.owner_stage }, this.#bundle);
116
+ if (run.status !== "running" || index.current_stage !== packet.owner_stage
117
+ || !index.stage_runs?.some((item) => item.stage === packet.owner_stage && item.status === "running" && item.attempt === graph.attempt)
118
+ || graph.attempt !== current.custody.scope.attempt || graph.cycle !== current.custody.scope.cycle
119
+ || stage.attempt !== graph.attempt || stage.inputSha256 !== packet.input_sha256)
120
+ refuse("active_assignment_required");
121
+ assertRunMutationAllowed(context, projectId, runId);
122
+ }
123
+ assertCustody(context) {
124
+ if (!context.db.isOwnedWriteTransaction?.())
125
+ refuse("owned_writer_required");
126
+ this.assertSelection(context);
127
+ this.#changes.assertCustody(context);
128
+ }
129
+ recordStart(context, workSessionId, hookEventKey, identity) {
130
+ this.assertCustody(context);
131
+ const { projectId, runId, workId } = this.#scope;
132
+ const link = context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.project_id=w.project_id AND s.session_id=ws.session_id JOIN hook_events h ON h.project_id=w.project_id AND h.event_key=ws.hook_event_id WHERE w.project_id=? AND w.run_id=? AND w.work_id=? AND w.status='running' AND ws.id=? AND ws.status='running' AND ws.session_id=? AND ws.hook_event_id=? AND h.status='claimed' AND h.id=? AND h.session_id=ws.session_id AND s.harness=? AND s.provider_session_id=? AND h.harness=s.harness AND h.provider_session_id=s.provider_session_id", [projectId, runId, workId, workSessionId, identity.sessionId, hookEventKey, identity.hookEventId, identity.harness, identity.providerSessionId]);
133
+ if (!link)
134
+ refuse("claimed_work_session_required");
135
+ if (this.#assignment.retained.body.work_history.works.some(prior => prior.actor?.harness === identity.harness
136
+ && prior.actor.provider_session_id === identity.providerSessionId))
137
+ refuse("independent_physical_session_required");
138
+ if (context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.project_id=w.project_id AND s.session_id=ws.session_id WHERE w.project_id=? AND w.run_id=? AND ws.id<>? AND s.harness=? AND s.provider_session_id=? LIMIT 1", [projectId, runId, workSessionId, identity.harness, identity.providerSessionId]))
139
+ refuse("independent_physical_session_required");
140
+ if (context.db.get("SELECT 1 FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.work_session_id')=?", [projectId, event, workSessionId]))
141
+ refuse("start_already_retained");
142
+ const packet = this.#assignment.packet;
143
+ const receipt = { schema_id: "dd-flow/interactive-work-start@1", run_id: runId, work_id: workId, work_session_id: workSessionId,
144
+ hook_event_key: hookEventKey, hook_event_id: identity.hookEventId, stage: packet.stage, attempt: this.#assignment.receipt.attempt,
145
+ packet_sha256: packet.packet_sha256, bundle_sha256: this.#bundle.sha256,
146
+ actor: { session_id: identity.sessionId, harness: identity.harness, provider_session_id: identity.providerSessionId },
147
+ snapshot_sha256: this.#baseline.sha256, snapshot: this.#baseline.snapshot() };
148
+ // Same bounded snapshot as the RUN baseline; SQL custody is not worker transport.
149
+ const json = JSON.stringify(receipt);
150
+ if (Buffer.byteLength(json) > 33 * 1024 * 1024)
151
+ refuse("unbounded_start_receipt");
152
+ context.db.run("INSERT INTO audit_events(project_id,event_type,payload_json,created_at) VALUES(?,?,?,?)", [projectId, event, json, context.now()]);
153
+ readInteractiveWorkStart(context, this.#scope, this.#bundle, workSessionId);
154
+ }
155
+ }
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import { checkRepairDisposition } from "./check-runner-evidence.js";
2
+ import { checkDiagnosisDisposition } from "./check-runner-evidence.js";
3
3
  import path from "node:path";
4
4
  import fs from "node:fs";
5
5
  import { quote } from "shell-quote";
@@ -21,6 +21,12 @@ import { stageAttempt } from "./stage-work-graph.js";
21
21
  import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
22
22
  import { canonicalPath } from "../storage/paths.js";
23
23
  import { runtimeCommand, shellArgument } from "./runtime-command.js";
24
+ import { assignedLifecycleBinding, claimAssignedLifecycle, currentAssignedLifecycle, bindAssignedCoordinatorPacket, inheritAssignedPackets, retainControllerStageAssignment, admitAssignedAuxiliary, bindAssignedStageResume, readAcceptedAssignedWork, assertTerminalSettlementUnreserved } from "./assigned-lifecycle.js";
25
+ import { appendAudit } from "./audit.js";
26
+ import { recordTechnicalNote } from "./technical-notes.js";
27
+ import { ObservationClock } from "../harness-runtime/lib/observation-clock.mjs";
28
+ export { currentAssignedLifecycle, assignedSessionIdentity, assertAssignedSettlementCurrent } from "./assigned-lifecycle.js";
29
+ export const bindControllerStageAssignment = retainControllerStageAssignment;
24
30
  // Process-local custody: an executing SQL row or ambient variable alone cannot
25
31
  // recreate admission after a CLI restart, or in another resource database.
26
32
  const currentAdmissions = new WeakMap();
@@ -48,6 +54,11 @@ export function assertAuxiliaryCommandScope(context, command, projectRoot) {
48
54
  const daemonId = context.env.DD_FLOW_DAEMON_ID;
49
55
  if (!auxiliary || !daemonId)
50
56
  return;
57
+ const assignedScope = cliManagedInvocationScope(context);
58
+ if (assignedScope && admitAssignedAuxiliary(context, command, assignedScope, projectRoot)) {
59
+ assertRunMutationAllowed(context, requireProjectByRoot(context, assignedScope.projectRoot).id, assignedScope.runId, assignedScope.generation);
60
+ return;
61
+ }
51
62
  const event = observedLifecycleNativeEvent(context, command, daemonId);
52
63
  if (!event)
53
64
  throw new AppError("invocation_receipt_missing", "Auxiliary command has no exact native receipt", 1, { phase: "prepare", effect: "no_effect" });
@@ -96,7 +107,7 @@ export function isRetryableLifecycleRejection(code, details = {}) {
96
107
  if (["invocation_command_mismatch", "invocation_argument_mismatch"].includes(code))
97
108
  return details.recoverable !== false;
98
109
  if (["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed"].includes(code))
99
- return Array.isArray(details.failures) && details.failures.length > 0 && details.failures.every(receipt => checkRepairDisposition(receipt) === "source_repair");
110
+ return Array.isArray(details.failures) && details.failures.length > 0 && details.failures.every(receipt => checkDiagnosisDisposition(receipt) === "diagnosis_required");
100
111
  return details.recoverable === true || ["review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
101
112
  "review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
102
113
  "evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
@@ -289,6 +300,7 @@ export function bindLifecyclePublishedPacket(context, prompt) {
289
300
  const issued = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'issued' ORDER BY rowid", [scope]);
290
301
  const ids = issued.filter(row => prompt.includes(publicInvocationCommand(context, row, retainedCommandWithoutInvocationId(row)))).map(row => row.id);
291
302
  bindLifecyclePacketAssignments(context, prompt, ids);
303
+ bindAssignedCoordinatorPacket(context, prompt);
292
304
  }
293
305
  function retainedCommandWithoutInvocationId(row) {
294
306
  const marker = ` --invocation-id ${row.id}`;
@@ -391,6 +403,24 @@ export function assertLifecycleOutcomes(context, scope) {
391
403
  /** Hooks already own native identity. Retain command outcomes there, with an
392
404
  * atomic controller notification; missing notifications reconcile from the row. */
393
405
  export function observeLifecycleCommand(context, command, nativeEvent) {
406
+ const assigned = currentAssignedLifecycle(context);
407
+ if (assigned)
408
+ return outcome => {
409
+ try {
410
+ const project = requireProjectByRoot(context, assigned.scope.projectRoot);
411
+ try {
412
+ appendAudit(context, { projectId: project.id, eventType: "lifecycle.assigned_outcome", payload: { run_id: assigned.scope.runId,
413
+ generation: assigned.scope.generation, invocation_id: assigned.invocation_id, operation: assigned.operation,
414
+ work_id: assigned.work_id, ...(outcome.error ? { error: { code: outcome.error.code, message: outcome.error.message } } : { accepted: true }) } });
415
+ }
416
+ catch {
417
+ recordTechnicalNote(context, project.id, assigned.scope.runId, { code: "assigned_outcome_audit_unavailable", message: "Optional assigned-operation audit could not be written; invocation outcome remains authoritative", phase: "settlement", generation: assigned.scope.generation, source_operation_id: assigned.invocation_id, consequence: "none" });
418
+ }
419
+ }
420
+ catch {
421
+ process.stderr.write(`${JSON.stringify({ event: "assigned_outcome_audit_unavailable", invocation_id: assigned.invocation_id })}\n`);
422
+ }
423
+ };
394
424
  const parsed = parseLifecycleCommand(command);
395
425
  const auxiliary = continuationCommand(command);
396
426
  if (parsed.kind !== "standalone" && !auxiliary)
@@ -677,9 +707,41 @@ export function lifecycleRetryCommands(context, stage) {
677
707
  }
678
708
  }))];
679
709
  }
680
- function currentIssuedCommand(context, scope, command) {
710
+ /** A resumed coordinator retains intent, not the old route's mutation authority. */
711
+ export function rebindAssignedCoordinatorCommands(context) {
712
+ const assigned = currentAssignedLifecycle(context);
713
+ if (assigned?.operation !== "stage_resume" || !assigned.work_session_id)
714
+ return [];
715
+ const rows = context.db.all(`SELECT DISTINCT li.* FROM lifecycle_invocations li JOIN lifecycle_packet_assignments pa ON pa.invocation_id=li.id
716
+ WHERE li.status='issued' AND (pa.packet_sha256=(SELECT json_extract(start_receipt_json,'$.prompt_sha256') FROM work_sessions WHERE id=?)
717
+ OR EXISTS (SELECT 1 FROM audit_events a WHERE a.event_type='lifecycle.coordinator_packet'
718
+ AND json_extract(a.payload_json,'$.work_session_id')=? AND json_extract(a.payload_json,'$.packet_sha256')=pa.packet_sha256))`, [assigned.work_session_id, assigned.work_session_id]);
719
+ const commands = [];
720
+ context.db.writeTransaction(() => {
721
+ for (const row of rows) {
722
+ const parsed = invocation(row.command), scope = JSON.parse(row.scope_json);
723
+ if (!["stage_finish", "stage_pause"].includes(parsed.operation) || commandOption(parsed, "stage") !== assigned.stage
724
+ || scope.projectRoot !== assigned.scope.projectRoot || scope.runId !== assigned.scope.runId || scope.generation > assigned.scope.generation)
725
+ continue;
726
+ if (row.scope_json === JSON.stringify(assigned.scope)) {
727
+ commands.push(publicInvocationCommand(context, row, row.command));
728
+ continue;
729
+ }
730
+ const command = canonicalManagedCommand(context, retainedCommandWithoutInvocationId(row), assigned.scope);
731
+ const next = managedLifecycleCommand(context, command);
732
+ const successor = currentIssuedCommand(context, JSON.stringify(assigned.scope), next);
733
+ if (!successor)
734
+ throw new AppError("invocation_assignment_unproven", "Resume has no exact current coordinator successor", 1, { effect: "no_effect", reconciliation_required: true });
735
+ inheritAssignedPackets(context, row.id, successor.id);
736
+ context.db.run("UPDATE lifecycle_invocations SET status='expired',updated_at=? WHERE id=? AND status='issued'", [context.now(), row.id]);
737
+ commands.push(next);
738
+ }
739
+ });
740
+ return [...new Set(commands)];
741
+ }
742
+ function currentIssuedCommand(context, scope, command, excludeId) {
681
743
  const rows = context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND status = 'issued'", [scope, scopedCommandFingerprint(context, scope, command)])
682
- .filter(row => row.deadline === null || row.deadline > Date.now());
744
+ .filter(row => row.id !== excludeId && (row.deadline === null || row.deadline > Date.now()));
683
745
  if (rows.length > 1)
684
746
  throw new AppError("invocation_ambiguous", "Multiple current lifecycle successors match this command", 1);
685
747
  return rows[0] ?? null;
@@ -805,6 +867,7 @@ export function managedLifecycleCommand(context, command, options = {}) {
805
867
  if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
806
868
  throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
807
869
  const issued = issueLifecycleInvocation(context, command, scope);
870
+ bindAssignedStageResume(context, load(context, issued.id));
808
871
  return publicInvocationCommand(context, load(context, issued.id), command);
809
872
  });
810
873
  }
@@ -841,9 +904,13 @@ function successorLifecycleInvocationCommand(context, prior, checkReceiptId) {
841
904
  throw new AppError("invocation_command_invalid", "Retained retry option cannot be rewritten unambiguously", 1);
842
905
  retained = retained.replace(fragments[0], "");
843
906
  }
844
- command = retained.replace(` --invocation-id ${prior.id}`, ` --retry-check ${shellArgument(checkReceiptId)} --reason ${shellArgument("Recheck after candidate-source correction")}`);
907
+ command = retained.replace(` --invocation-id ${prior.id}`, ` --retry-check ${shellArgument(checkReceiptId)} --reason ${shellArgument("Recheck after diagnosis and correction of the failed check")}`);
845
908
  }
846
- return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
909
+ const successor = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
910
+ const next = stagedContinuations.get(context.db)?.get(prior.id) ?? currentIssuedCommand(context, prior.scope_json, successor, prior.id);
911
+ if (next && context.db.get("SELECT 1 FROM lifecycle_invocations WHERE id=?", [next.id]))
912
+ inheritAssignedPackets(context, prior.id, next.id);
913
+ return successor;
847
914
  }
848
915
  // Caller holds the write transaction and has checked authority. Issued (or
849
916
  // legacy expired) attempts have never admitted an effect; observed/executing
@@ -862,6 +929,16 @@ const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "
862
929
  * A crash must leave either the old attempt executing, or both the settled
863
930
  * outcome and its successor; never a usable successor beside an unknown effect. */
864
931
  export function settleLifecycleRejection(context, id, error) {
932
+ const accepted = readAcceptedAssignedWork(context, id);
933
+ if (accepted) {
934
+ const row = load(context, id), scope = JSON.parse(row.scope_json);
935
+ settleLifecycleInvocation(context, id, { result: accepted });
936
+ Object.assign(error.details, { effect: "committed", recoverable: false, work_result_accepted: true, reconciliation_required: true });
937
+ const project = requireProjectByRoot(context, scope.projectRoot);
938
+ recordTechnicalNote(context, project.id, scope.runId, { code: "work_post_commit_pending", message: "Work result was accepted; derivative publication or projection needs reconciliation", phase: "publication", generation: scope.generation,
939
+ source_operation_id: id, work_id: String(accepted.work_id), consequence: "dependency_blocked" });
940
+ return;
941
+ }
865
942
  storage(context);
866
943
  // Only this settlement may publish a successor; inherited help is not authority.
867
944
  delete error.details.retry_command;
@@ -935,24 +1012,33 @@ export function hasLifecyclePacketAssignments(context, originalPrompt) {
935
1012
  }
936
1013
  export function recoverLifecycleCommands(context, originalPrompt, scope) {
937
1014
  const digest = crypto.createHash("sha256").update(originalPrompt).digest("hex");
938
- const ids = context.db.all("SELECT invocation_id FROM lifecycle_packet_assignments WHERE packet_sha256 = ? ORDER BY rowid", [digest]).map(row => row.invocation_id);
939
- if (!ids.length)
1015
+ const packet = context.db.all("SELECT li.* FROM lifecycle_packet_assignments pa JOIN lifecycle_invocations li ON li.id=pa.invocation_id WHERE pa.packet_sha256 = ? ORDER BY pa.rowid", [digest]);
1016
+ if (!packet.length)
940
1017
  throw new AppError("invocation_recovery_unbound", "Original packet has no verified lifecycle assignment binding; reconcile it before continuing", 1);
1018
+ const origins = new Map();
1019
+ for (const row of packet) {
1020
+ const priorScope = JSON.parse(row.scope_json);
1021
+ if (priorScope.projectRoot !== scope.projectRoot || priorScope.runId !== scope.runId || priorScope.rootSessionId !== scope.rootSessionId
1022
+ || priorScope.generation > scope.generation || priorScope.generation === scope.generation && row.scope_json !== JSON.stringify(scope))
1023
+ throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: row.id });
1024
+ // Recovery inherits the immutable packet binding. Those new rows are
1025
+ // successors of its original commands, not additional commands to recover.
1026
+ if (priorScope.generation < scope.generation && !origins.has(row.fingerprint))
1027
+ origins.set(row.fingerprint, row);
1028
+ }
1029
+ if (!origins.size)
1030
+ throw new AppError("invocation_recovery_scope_mismatch", "Recovery requires an earlier packet generation", 1);
941
1031
  const recovered = [];
942
- for (const id of ids) {
943
- let prior = load(context, id);
944
- const oldScope = JSON.parse(prior.scope_json);
945
- if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
946
- || oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
947
- throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
948
- }
1032
+ for (const origin of origins.values()) {
1033
+ const id = origin.id;
949
1034
  // The immutable prompt can predate several recoveries. Follow the newest
950
1035
  // retained attempt, including an unknown successor, rather than reviving
951
1036
  // an older unexecuted command after the successor already produced effects.
952
- prior = context.db.get(`SELECT * FROM lifecycle_invocations WHERE fingerprint = ?
953
- AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') IS ?
954
- AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ?
955
- ORDER BY json_extract(scope_json, '$.generation') DESC, rowid DESC LIMIT 1`, [prior.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation]);
1037
+ const prior = context.db.get(`SELECT li.* FROM lifecycle_invocations li JOIN lifecycle_packet_assignments pa ON pa.invocation_id=li.id
1038
+ WHERE pa.packet_sha256=? AND li.fingerprint = ?
1039
+ AND json_extract(li.scope_json, '$.projectRoot') = ? AND json_extract(li.scope_json, '$.runId') IS ?
1040
+ AND json_extract(li.scope_json, '$.rootSessionId') = ? AND (json_extract(li.scope_json, '$.generation') < ? OR li.scope_json = ?)
1041
+ ORDER BY json_extract(li.scope_json, '$.generation') DESC, li.rowid DESC LIMIT 1`, [digest, origin.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation, JSON.stringify(scope)]);
956
1042
  if (prior.status === "executing" || prior.status === "settled") {
957
1043
  const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
958
1044
  recovered.push({ invocation_id: id, status: prior.status, outcome,
@@ -968,6 +1054,9 @@ export function recoverLifecycleCommands(context, originalPrompt, scope) {
968
1054
  const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
969
1055
  const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${launchExpression(parsed)} ${quote(argv)} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
970
1056
  const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
1057
+ const successor = currentIssuedCommand(context, JSON.stringify(scope), prepared);
1058
+ if (successor)
1059
+ inheritAssignedPackets(context, prior.id, successor.id);
971
1060
  recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
972
1061
  }
973
1062
  return recovered;
@@ -1029,6 +1118,28 @@ function checkCommand(row, command) {
1029
1118
  throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
1030
1119
  }
1031
1120
  }
1121
+ /** Match a daemon-attested native tool against its exact issued command.
1122
+ * This is only a correlation check: claim/owner/generation are verified again
1123
+ * by the normal invocation path before the command can mutate anything. */
1124
+ export function matchesIssuedNativeCommand(context, id, command) {
1125
+ const row = load(context, id);
1126
+ const analysis = parseLifecycleCommand(command);
1127
+ if (analysis.kind === "none" || analysis.supported === false)
1128
+ return false;
1129
+ try {
1130
+ const scope = JSON.parse(row.scope_json);
1131
+ const parsed = invocation(canonicalManagedCommand(context, analysis.invocation.command, scope));
1132
+ assertSuppliedLifecycleTarget(row, parsed);
1133
+ // Public Work packets deliberately omit the internal UUID. The caller
1134
+ // has already selected the unique issued assignment in its managed scope;
1135
+ // this comparison supplies no authority and must still pass native proof/CAS.
1136
+ const supplied = commandOption(parsed, "invocation-id");
1137
+ return (supplied === id || supplied === undefined && parsed.operation === "work_start") && fingerprint(parsed) === row.fingerprint;
1138
+ }
1139
+ catch {
1140
+ return false;
1141
+ }
1142
+ }
1032
1143
  /** Public commands may omit the coordinator RUN. A supplied target remains
1033
1144
  * input to validate, not text to silently overwrite with retained authority. */
1034
1145
  function assertSuppliedLifecycleTarget(row, actual) {
@@ -1480,6 +1591,32 @@ export function observeLifecycleInvocation(context, input) {
1480
1591
  }
1481
1592
  function observeLifecycleInvocationReceipt(context, input) {
1482
1593
  storage(context);
1594
+ const delivered = parseLifecycleCommand(input.command);
1595
+ if (delivered.kind === "standalone") {
1596
+ const id = commandOption(delivered.invocation, "invocation-id");
1597
+ const direct = context.db.all("SELECT * FROM lifecycle_invocations WHERE admission_json IS NOT NULL AND status IN ('executing','settled')")
1598
+ .filter(row => {
1599
+ const scope = JSON.parse(row.scope_json);
1600
+ if (id && row.id !== id || scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
1601
+ return false;
1602
+ // A fresh native call must bind the explicitly committed successor,
1603
+ // not short-circuit to its predecessor's successful repair response.
1604
+ // Exact delivery/explicit-ID replay remains historical read-only.
1605
+ const successor = row.status === "settled" && row.outcome_json ? committedSuccessor(context, row, JSON.parse(row.outcome_json)) : null;
1606
+ if (!id && row.identity_json && successor && ["issued", "observed"].includes(successor.status)
1607
+ && !sameNativeCall(row.identity_json, JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId])))
1608
+ return false;
1609
+ try {
1610
+ return fingerprint(invocation(canonicalManagedCommand(context, delivered.invocation.command, scope))) === row.fingerprint;
1611
+ }
1612
+ catch {
1613
+ return false;
1614
+ }
1615
+ });
1616
+ if (direct.length === 1)
1617
+ return { eventKey: direct[0].event_key, duplicate: true,
1618
+ disposition: direct[0].status === "settled" ? "settled_read" : "in_flight", invocationId: direct[0].id };
1619
+ }
1483
1620
  const projectRoot = input.projectId ? context.db.get("SELECT root FROM projects WHERE id = ?", [input.projectId])?.root : undefined;
1484
1621
  const provenScope = projectRoot && input.harness ? managedNativeInvocationScope(context, projectRoot, input.identity, input.harness) : null;
1485
1622
  const owner = provenScope && input.harness && input.projectId ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
@@ -1587,6 +1724,10 @@ function observeLifecycleInvocationReceipt(context, input) {
1587
1724
  // public ID is corrected only by an exact operation/target match inside the
1588
1725
  // trusted native root. Never fuzzy-match UUID text or select a global latest.
1589
1726
  const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
1727
+ // A live observation arriving after direct admission is optional correlation,
1728
+ // never a second authority or an executing -> observed transition.
1729
+ if (initial?.admission_json && ["executing", "settled"].includes(initial.status))
1730
+ return { eventKey: initial.event_key, duplicate: true, disposition: initial.status === "settled" ? "settled_read" : "in_flight", invocationId: initial.id };
1590
1731
  if (!supplied && candidates.length > 1) {
1591
1732
  input.recordReceipt();
1592
1733
  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 } } : {}) });
@@ -2024,13 +2165,50 @@ export async function awaitLifecycleInvocation(context, input) {
2024
2165
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
2025
2166
  throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
2026
2167
  context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
2168
+ const initialClock = new ObservationClock({ timeoutMs }), seenProgress = new Set();
2169
+ const receiptClock = new ObservationClock({ timeoutMs, snapshotIsLive: true, saved: { ...initialClock.state(),
2170
+ elapsed_ms: Math.max(0, timeoutMs - Math.max(0, (load(context, input.id).deadline ?? Date.now()) - Date.now())) } });
2027
2171
  for (;;) {
2028
- const row = load(context, input.id);
2172
+ let row = load(context, input.id);
2173
+ if (row.status === "issued" && invocation(row.command).operation === "work_start" && input.reconcileNativeObservation) {
2174
+ const scope = JSON.parse(row.scope_json);
2175
+ input.assertCurrent(scope);
2176
+ await input.reconcileNativeObservation(scope, progress => {
2177
+ input.assertCurrent(scope);
2178
+ if (seenProgress.has(progress.cursor))
2179
+ return;
2180
+ const current = load(context, input.id);
2181
+ if (current.status !== 'issued' || current.scope_json !== row.scope_json || current.deadline === null
2182
+ || !Number.isFinite(progress.observed_at) || progress.observed_at >= current.deadline || progress.observed_at > Date.now())
2183
+ return;
2184
+ const expired = receiptClock.sample(progress);
2185
+ const state = receiptClock.state();
2186
+ if (expired || state.cursor !== progress.cursor)
2187
+ return;
2188
+ seenProgress.add(progress.cursor);
2189
+ context.db.run("UPDATE lifecycle_invocations SET deadline = ?, updated_at = ? WHERE id = ? AND status = 'issued' AND scope_json = ?", [Date.now() + Math.max(1, timeoutMs - state.elapsed_ms), context.now(), input.id, row.scope_json]);
2190
+ input.progress?.("native_verification_progress");
2191
+ });
2192
+ // A native receipt can arrive while it is being read. Reload it before
2193
+ // deciding whether this exact invocation is eligible for admission.
2194
+ row = load(context, input.id);
2195
+ if (row.status !== "issued")
2196
+ continue;
2197
+ }
2029
2198
  if (row.status === "settled")
2030
2199
  return { outcome: JSON.parse(row.outcome_json), replay: true };
2031
2200
  if (row.status === "executing") {
2032
2201
  const scope = JSON.parse(row.scope_json);
2033
2202
  const parsed = invocation(row.command);
2203
+ if (row.admission_json && ["work_finish", "work_fail"].includes(parsed.operation)) {
2204
+ const { recoverCommittedAssignedWork } = await import("./work-registry.js");
2205
+ const result = recoverCommittedAssignedWork(context, row.id);
2206
+ if (result) {
2207
+ const outcome = { result };
2208
+ settleLifecycleInvocation(context, row.id, outcome);
2209
+ return { outcome, replay: true };
2210
+ }
2211
+ }
2034
2212
  if (parsed.operation === "stage_resume" && scope.runId) {
2035
2213
  input.assertCurrent(scope);
2036
2214
  const { recoverCommittedStageResume } = await import("./stage-pause.js");
@@ -2073,6 +2251,14 @@ export async function awaitLifecycleInvocation(context, input) {
2073
2251
  throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id, effect: "unknown" });
2074
2252
  }
2075
2253
  input.assertCurrent(JSON.parse(row.scope_json));
2254
+ if (["issued", "observed"].includes(row.status) && assignedLifecycleBinding(context, row)) {
2255
+ if (claimAssignedLifecycle(context, row))
2256
+ return { kind: "managed_assignment", replay: false };
2257
+ continue;
2258
+ }
2259
+ if (row.status === "issued" && context.env.DD_FLOW_DAEMON_ID && ["work_finish", "work_fail", "stage_finish", "stage_pause", "stage_resume", "stage_start"].includes(invocation(row.command).operation)) {
2260
+ throw new AppError("invocation_assignment_unproven", "Current managed assignment is unavailable; reconcile this operation without repeating Work", 1, { invocation_id: row.id, effect: "no_effect", recoverable: true, reconciliation_required: true });
2261
+ }
2076
2262
  if (row.status === "observed") {
2077
2263
  if (row.identity_json) {
2078
2264
  // Receipt display text is bounded/redacted and may not be valid JSON.
@@ -2086,11 +2272,13 @@ export async function awaitLifecycleInvocation(context, input) {
2086
2272
  }
2087
2273
  // Another native observation can replace an unclaimed receipt. Claim
2088
2274
  // exactly the identity/route checked above, or reload and validate it.
2089
- const workStart = invocation(row.command).operation === "work_start";
2090
- const birth = workStart ? processStartedAt(process.pid) : null, cwd = workStart ? process.cwd() : "";
2275
+ const operation = invocation(row.command).operation;
2276
+ const physicalAdmission = operation === "work_start" || operation.startsWith("delivery_");
2277
+ const birth = physicalAdmission ? processStartedAt(process.pid) : null, cwd = physicalAdmission ? process.cwd() : "";
2091
2278
  input.assertCurrent(JSON.parse(row.scope_json));
2092
2279
  let admittedAt = "";
2093
2280
  const claimed = context.db.writeTransaction(() => {
2281
+ assertTerminalSettlementUnreserved(context, row);
2094
2282
  // Acquire the writer before taking the anchor; waiting for SQLite or
2095
2283
  // physical birth observation must not make an old Hook appear fresh.
2096
2284
  admittedAt = context.now();
@@ -2107,7 +2295,7 @@ export async function awaitLifecycleInvocation(context, input) {
2107
2295
  owned.delete(owned.keys().next().value);
2108
2296
  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 });
2109
2297
  }
2110
- return { eventKey: row.event_key, replay: false };
2298
+ return { kind: "native_hook", eventKey: row.event_key, replay: false };
2111
2299
  }
2112
2300
  continue;
2113
2301
  }
@@ -2139,8 +2327,10 @@ export function settleLifecycleInvocation(context, id, outcome) {
2139
2327
  }
2140
2328
  if (row.status !== "executing")
2141
2329
  throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
2142
- if (successor)
2330
+ if (successor) {
2143
2331
  insertIssuedInvocation(context, successor);
2332
+ inheritAssignedPackets(context, id, successor.id);
2333
+ }
2144
2334
  if (context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]).changes !== 1)
2145
2335
  throw new AppError("invocation_settlement_conflict", "Lifecycle outcome changed before commit", 1, { invocation_id: id });
2146
2336
  });
@@ -1,7 +1,31 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
- import { managedProcessRuntimeHome, managedProcessStatus } from "./managed-processes.js";
4
+ import { managedProcessRuntimeHome, managedProcessStatus, processTreeIsAlive } from "./managed-processes.js";
5
+ /** Native execution and AGY's potentially scheduled writers are separate facts. */
6
+ export function managedExecutionReusable(state, harness) {
7
+ const shutdown = state.shutdown, result = shutdown?.result;
8
+ if (state.active_tree !== false || state.unclaimed_activity === true || shutdown?.phases?.execution !== true
9
+ || result?.native_settled !== true || result.settled !== true || result.native_settlement?.confirmed === false
10
+ || state.native_execution_revision !== undefined && state.native_execution_revision !== result.native_settlement?.revision)
11
+ return false;
12
+ // These adapters do not yet publish a revision for every possible late
13
+ // child/tool event. A completed native observation alone cannot exclude a
14
+ // newly scheduled writer; the caller can still use physical absence.
15
+ if (["zcode-acp", "droid-cli"].includes(harness))
16
+ return false;
17
+ if (harness !== "antigravity-cli")
18
+ return true;
19
+ const daemon = state.daemon_id ?? state.config?.daemonId, proof = shutdown.native_resource_reconciliation, resources = result.native_resources;
20
+ return Boolean(daemon && shutdown.phases.native_resources_reconciled === true && proof?.daemon_id === daemon
21
+ && proof.dispatch_generation === shutdown.generation && Number.isSafeInteger(proof.dispatch_generation)
22
+ && proof.native_revision === state.native_execution_revision && Number.isSafeInteger(proof.native_revision)
23
+ && resources?.schema_id === "native-resources@1" && resources.owner_daemon_id === daemon
24
+ && resources.revision === proof.native_revision && Number.isSafeInteger(resources.generation)
25
+ && result.native_settlement?.owner_daemon_id === daemon && result.native_settlement.generation === resources.generation
26
+ && Array.isArray(resources.entries)
27
+ && resources.entries.every(item => item.owner_daemon_id === daemon && item.generation === resources.generation && item.state === "stopped"));
28
+ }
5
29
  export function readNativeDaemonState(stateDir) {
6
30
  if (!path.isAbsolute(stateDir))
7
31
  throw new AppError("daemon_owner_mismatch", "Native state directory must be absolute", 1);
@@ -36,3 +60,30 @@ export function requireManagedDaemonBinding(context, input) {
36
60
  }
37
61
  return { state, process: record };
38
62
  }
63
+ /** A dead bridge is not enough: its provider may own a detached process group. */
64
+ export function managedDaemonExecutionAbsent(context, input) {
65
+ const binding = requireManagedDaemonBinding(context, input);
66
+ const records = managedProcessStatus(context).filter(record => {
67
+ try {
68
+ return JSON.parse(record.metadata_json).state_dir === input.stateDir;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ });
74
+ if (!records.length || records.some(record => {
75
+ const group = JSON.parse(record.metadata_json).process_group_id;
76
+ // A dead leader without its retained tree boundary cannot exclude surviving
77
+ // children. Fall back to strict native reconciliation, not assumed absence.
78
+ return record.project_id !== input.projectId || record.run_id !== input.runId
79
+ || managedProcessRuntimeHome(record, context) !== context.ddFlowHome || !record.pid || !record.pid_started_at
80
+ || !Number.isSafeInteger(group) || group <= 0 || processTreeIsAlive(record);
81
+ }))
82
+ return false;
83
+ if (binding.state.provider_process || binding.state.provider_pid) {
84
+ const provider = records.find(record => record.id === binding.state.provider_process?.id);
85
+ if (!provider || !managedProviderBelongsToDaemon(provider, binding.process, binding.state))
86
+ return false;
87
+ }
88
+ return true;
89
+ }