@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90

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 (143) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +304 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +115 -0
  8. package/dist/cli/run-cli.js +1409 -382
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  11. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  12. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  13. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  14. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  15. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  16. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  17. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  18. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  19. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  21. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  22. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  23. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  24. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  25. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  26. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  27. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  28. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  29. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  30. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  32. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  33. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  34. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  36. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  37. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  38. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  39. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  40. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  41. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  42. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  43. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  44. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  45. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  46. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  47. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  48. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  49. package/dist/schemas/agent-profile.schema.json +1 -1
  50. package/dist/schemas/code-review-result.schema.json +2 -2
  51. package/dist/schemas/code-work-batch.schema.json +4 -3
  52. package/dist/schemas/code-work-result.schema.json +4 -4
  53. package/dist/schemas/plan-review-result.schema.json +2 -2
  54. package/dist/schemas/run-control-receipt.schema.json +99 -0
  55. package/dist/schemas/run-control-request.schema.json +36 -0
  56. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  57. package/dist/services/canon.js +11 -8
  58. package/dist/services/cleanup.js +57 -13
  59. package/dist/services/cli-operation-classifier.js +22 -24
  60. package/dist/services/code-checks.js +543 -96
  61. package/dist/services/codex-hook-delivery.js +28 -0
  62. package/dist/services/command-context.js +80 -0
  63. package/dist/services/config.js +11 -8
  64. package/dist/services/continuation-outcome.js +20 -0
  65. package/dist/services/controller-fanout.js +132 -0
  66. package/dist/services/dashboard.js +103 -31
  67. package/dist/services/engines.js +121 -76
  68. package/dist/services/eval-snapshots.js +750 -73
  69. package/dist/services/execution-policy.js +170 -0
  70. package/dist/services/external-work-launch.js +110 -0
  71. package/dist/services/harness-adapter.js +147 -24
  72. package/dist/services/harness-config.js +2 -0
  73. package/dist/services/hooks.js +587 -299
  74. package/dist/services/lanes.js +61 -53
  75. package/dist/services/lifecycle-command.js +118 -7
  76. package/dist/services/lifecycle-invocations.js +1055 -0
  77. package/dist/services/managed-daemon-binding.js +38 -0
  78. package/dist/services/managed-processes.js +283 -27
  79. package/dist/services/merge-queue.js +179 -104
  80. package/dist/services/merge-server.js +73 -28
  81. package/dist/services/migrations.js +14 -9
  82. package/dist/services/native-daemon-history.js +49 -0
  83. package/dist/services/native-session-control.js +39 -0
  84. package/dist/services/plan-runtime.js +19 -8
  85. package/dist/services/plans.js +28 -25
  86. package/dist/services/projects.js +18 -5
  87. package/dist/services/prompts.js +23 -16
  88. package/dist/services/protocols.js +64 -20
  89. package/dist/services/recovery-observation-budget.js +61 -0
  90. package/dist/services/recovery-snapshot-database.js +107 -0
  91. package/dist/services/repair-intents.js +40 -0
  92. package/dist/services/run-control-receipt.js +56 -0
  93. package/dist/services/run-control-worker.js +363 -0
  94. package/dist/services/run-control.js +876 -0
  95. package/dist/services/run-controller-adapter.js +222 -0
  96. package/dist/services/run-controller-capture.js +134 -0
  97. package/dist/services/run-controller-process.js +189 -0
  98. package/dist/services/run-controller-recovery.js +222 -0
  99. package/dist/services/run-controller-state.js +36 -0
  100. package/dist/services/run-controller.js +789 -0
  101. package/dist/services/run-engine-bindings.js +1 -1
  102. package/dist/services/run-fork.js +138 -0
  103. package/dist/services/run-observations.js +112 -0
  104. package/dist/services/run-recovery-runtime.js +69 -0
  105. package/dist/services/run-recovery.js +343 -0
  106. package/dist/services/runs.js +368 -74
  107. package/dist/services/runtime-budget.js +434 -0
  108. package/dist/services/runtime-command.js +43 -0
  109. package/dist/services/runtime-scope-capture.js +50 -0
  110. package/dist/services/runtime-scope-control.js +450 -0
  111. package/dist/services/runtime-scope-resume.js +565 -0
  112. package/dist/services/runtime-scope-stop.js +99 -0
  113. package/dist/services/runtime-scope-worker.js +243 -0
  114. package/dist/services/runtime-service.js +97 -0
  115. package/dist/services/schema-validation.js +10 -8
  116. package/dist/services/sessions.js +35 -69
  117. package/dist/services/stage-blocker.js +17 -7
  118. package/dist/services/stage-context.js +44 -18
  119. package/dist/services/stage-lifecycle.js +99 -102
  120. package/dist/services/stage-pause.js +84 -55
  121. package/dist/services/stage-work-graph.js +35 -0
  122. package/dist/services/usage.js +37 -93
  123. package/dist/services/vnext-code-review.js +195 -88
  124. package/dist/services/vnext-code.js +285 -252
  125. package/dist/services/vnext-execution-profile.js +5 -3
  126. package/dist/services/vnext-fanout.js +160 -17
  127. package/dist/services/vnext-merge.js +291 -104
  128. package/dist/services/vnext-plan-review.js +139 -81
  129. package/dist/services/vnext-plan.js +138 -69
  130. package/dist/services/vnext-protocolize.js +129 -56
  131. package/dist/services/vnext-specify.js +88 -65
  132. package/dist/services/work-registry.js +716 -168
  133. package/dist/services/workspace-bootstrap.js +76 -0
  134. package/dist/services/worktrees.js +85 -35
  135. package/dist/shared/entity-references.js +8 -0
  136. package/dist/shared/errors.js +12 -0
  137. package/dist/storage/database.js +459 -32
  138. package/dist/storage/paths.js +17 -4
  139. package/dist/storage/work-references.js +23 -0
  140. package/dist/storage/writer-contract.js +77 -0
  141. package/dist/storage/writer-migration.js +102 -0
  142. package/package.json +24 -13
  143. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,11 +1,14 @@
1
+ import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { AppError } from "../shared/errors.js";
6
+ import { runtimeBudgetForRun } from "./runtime-budget.js";
5
7
  import { flowCommand } from "./stage-pause.js";
6
- import { validateSchema } from "./schema-validation.js";
7
- import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
8
+ import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
8
9
  import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
10
+ import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
11
+ import { loadAgentProfile, resolveExecutionPolicy, runtimeHarness } from "./execution-policy.js";
9
12
  /** A deterministic dispatcher; only the launched harness Session performs agent work. */
10
13
  export async function serveMergeRequests(context, input) {
11
14
  const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
@@ -58,42 +61,90 @@ export async function serveMergeRequests(context, input) {
58
61
  export function mergeServerStatus(context) { return { ok: true, servers: context.db.all("SELECT server_id, profile_id, status, pid, started_at, heartbeat_at, stopped_at, journal_path FROM merge_servers ORDER BY started_at DESC") }; }
59
62
  export function stopMergeServer(context, input) { const changed = context.db.run("UPDATE merge_servers SET status = 'stopping', heartbeat_at = ? WHERE server_id = ? AND status = 'running'", [context.now(), input.serverId]); if (changed.changes !== 1)
60
63
  throw new AppError("not_found", "Running merge-server was not found", 1, { server_id: input.serverId }); return { ok: true, server_id: input.serverId, status: "stopping" }; }
64
+ /** Both dispatchers enter the same project FIFO before creating any provider Session. */
65
+ export function claimQueuedMergeRequest(context, input) {
66
+ context.db.beginWriteTransaction();
67
+ try {
68
+ const request = context.db.get("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE merge_request_id = ?", [input.requestId]);
69
+ if (!request)
70
+ throw new AppError("not_found", "MERGE request was not found", 1);
71
+ const generation = assertRunMutationAllowed(context, request.project_id, request.run_id, input.expectedGeneration);
72
+ const earlier = context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]);
73
+ if (request.status !== "queued" || request.execution_route !== "server" || earlier) {
74
+ context.db.exec("COMMIT");
75
+ return null;
76
+ }
77
+ const token = crypto.randomUUID();
78
+ context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued'", [input.ownerId, token, new Date(Date.now() + 120_000).toISOString(), context.now(), request.merge_request_id]);
79
+ context.db.exec("COMMIT");
80
+ return { request, token, generation };
81
+ }
82
+ catch (error) {
83
+ context.db.exec("ROLLBACK");
84
+ throw error;
85
+ }
86
+ }
61
87
  async function dispatch(context, input) {
62
- const token = crypto.randomUUID();
63
- const leaseUntil = new Date(Date.now() + 120_000).toISOString();
64
- const claimed = context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued' AND execution_route = 'server'", [input.serverId, token, leaseUntil, context.now(), input.request.merge_request_id]);
65
- if (claimed.changes !== 1)
88
+ const run = context.db.get("SELECT index_json, project_root FROM runs WHERE project_id = ? AND id = ?", [input.request.project_id, input.request.run_id]);
89
+ if (!run)
90
+ throw new AppError("not_found", "MERGE RUN was not found", 1, { run_id: input.request.run_id });
91
+ const snapshot = JSON.parse(run.index_json).execution_profile;
92
+ if (snapshot?.settings?.execution) {
93
+ if (!snapshot.agent_profiles)
94
+ throw new AppError("execution_profile_not_frozen", "MERGE dispatch requires frozen RUN agent profiles", 1, { run_id: input.request.run_id });
95
+ const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: snapshot.settings.execution, profiles: snapshot.agent_profiles, stage: "merge" });
96
+ input = { ...input, profile: resolved.coordinator };
97
+ }
98
+ const claim = claimQueuedMergeRequest(context, { requestId: input.request.merge_request_id, ownerId: input.serverId });
99
+ if (!claim)
66
100
  return;
67
- const leaseHeartbeat = setInterval(() => { const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
68
- leaseHeartbeat.unref();
101
+ const { generation, token } = claim;
69
102
  const stateDir = path.join(input.root, input.request.merge_request_id);
70
103
  fs.mkdirSync(stateDir, { recursive: true });
71
104
  const promptFile = path.join(stateDir, "launch.md");
72
105
  const adapterJournal = path.join(stateDir, "adapter.events.jsonl");
73
- const stageCommand = `${flowCommand(context)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(input.request.target_workspace)} --json --progress-jsonl`;
74
- fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
106
+ const flowExecutable = prepareHarnessFlowExecutable(context, stateDir);
107
+ let runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
108
+ const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${input.request.run_id}:merge:${input.request.merge_request_id}`, role: "merge", project_id: input.request.project_id, run_id: input.request.run_id, work_id: input.request.executor_work_id, operation_id: `${input.request.merge_request_id}:${token}`, state_dir: stateDir, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, input.request.project_id, input.request.run_id) };
75
109
  event(input.journal, { type: "launch_intent", server_id: input.serverId, request_id: input.request.merge_request_id, token, profile: input.profile, prompt_file: promptFile, at: context.now() });
76
110
  input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
77
111
  const executable = resolveHarnessCommand(context.ddFlowHome, input.profile.harness, "adapter");
78
- const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", input.request.target_workspace, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, input.profile.harness), "--json"];
112
+ const common = ["--dd-flow-bin", flowExecutable, "--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, input.profile.harness), "--json"];
113
+ const retained = { profile: input.profile, executable, owner: runtimeOwner, session_id: null, state_dir: stateDir, create_operation_id: `${input.request.merge_request_id}:${token}:session.create`, prompt_operation_id: `${input.request.merge_request_id}:${token}:session.prompt` };
114
+ const retain = () => { context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify(retained), context.now(), input.request.merge_request_id, input.serverId, token]); };
115
+ retain();
116
+ const leaseHeartbeat = setInterval(() => { if (guarded(context, input.request) || (recoveryGuard(context, input.request.project_id, input.request.run_id)?.generation ?? 0) !== generation)
117
+ return; const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
118
+ leaseHeartbeat.unref();
79
119
  try {
80
- const adapterCall = (args, timeoutMs, progressMessage) => runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) });
120
+ const adapterCall = (args, timeoutMs, progressMessage) => { assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation); return runHarnessAdapter({ executable, args, env: runtimeContext.env, runtimeOwner, operationId: `${input.request.merge_request_id}:${token}:${args[0]}.${args[1]}`, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) }); };
81
121
  await adapterCall(["daemon", "start", ...common]);
82
122
  const created = await adapterCall(["session", "create", ...common]);
83
123
  const sessionId = adapterSessionId(created);
84
124
  if (!sessionId)
85
125
  throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
126
+ Object.assign(retained, { session_id: sessionId, create: created });
127
+ retain();
128
+ runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(input.profile), stateDir, sessionId, projectRoot: run.project_root, runId: input.request.run_id, generation });
129
+ const stageCommand = managedLifecycleCommand(runtimeContext, `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
130
+ fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
86
131
  const prompted = await adapterCall(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60 * 1000, `MERGE ${input.request.merge_request_id} Session ${sessionId} is still running`);
132
+ Object.assign(retained, { response: prompted });
133
+ retain();
134
+ assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation);
87
135
  const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
88
- context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ profile: input.profile, executable, session_id: sessionId, response: prompted }), context.now(), input.request.merge_request_id]);
89
136
  event(input.journal, { type: "adapter_settled", request_id: input.request.merge_request_id, session_id: sessionId, request_status: request?.status ?? null, at: context.now() });
90
137
  if (request?.status === "dispatching")
91
- context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
138
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
92
139
  }
93
140
  catch (error) {
141
+ if (error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code)) {
142
+ event(input.journal, { type: "recovery_dispatch_blocked", request_id: input.request.merge_request_id, code: error.code, at: context.now() });
143
+ throw error;
144
+ }
94
145
  const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
95
146
  if (request?.status === "dispatching")
96
- context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
147
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
97
148
  else if (request?.status === "active")
98
149
  context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_lost_after_stage_start", error: String(error) }), context.now(), input.request.merge_request_id]);
99
150
  throw error;
@@ -102,23 +153,17 @@ async function dispatch(context, input) {
102
153
  clearInterval(leaseHeartbeat);
103
154
  }
104
155
  }
105
- function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
156
+ function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (guarded(context, request) || selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
106
157
  return false; selected.add(request.project_id); return true; }).slice(0, limit); }
107
- function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
158
+ function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id, project_id, run_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
159
+ if (guarded(context, request))
160
+ continue;
108
161
  const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
109
- if (work?.status === "created")
110
- context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_requeued" }), context.now(), request.merge_request_id]);
111
- else
112
- context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_with_started_work", work_status: work?.status ?? null }), context.now(), request.merge_request_id]);
162
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_outcome_unknown", work_status: work?.status ?? null }), context.now(), request.merge_request_id]);
113
163
  } }
114
- function loadProfile(context, id) { const file = path.join(context.ddFlowHome, "agent-profiles", `${id}.json`); let value; try {
115
- value = JSON.parse(fs.readFileSync(file, "utf8"));
116
- }
117
- catch (error) {
118
- throw new AppError("agent_profile_missing", "Merge-server agent profile is unavailable", 1, { id, file, cause: String(error) });
119
- } validateSchema({ schemaName: "agent-profile", file }); if (value.id !== id)
120
- throw new AppError("agent_profile_invalid", "Agent profile id does not match its requested filename", 2, { file, expected: id, actual: value.id ?? null }); return value; }
164
+ function loadProfile(context, id) { return loadAgentProfile(context.ddFlowHome, id); }
121
165
  function event(file, value) { fs.appendFileSync(file, `${JSON.stringify(value)}\n`); }
166
+ function guarded(context, request) { const guard = recoveryGuard(context, request.project_id, request.run_id); return Boolean(guard && ["draining", "sealed", "resuming"].includes(guard.status)); }
122
167
  function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
123
168
  function processAlive(pid) { if (!pid)
124
169
  return false; try {
@@ -10,6 +10,7 @@ import { resolveProjectRoot } from "../storage/paths.js";
10
10
  import { classifyCliOperation } from "./cli-operation-classifier.js";
11
11
  import { compatibilityContexts, selectEngine } from "./engines.js";
12
12
  import { getCliBuildInfo } from "./build-info.js";
13
+ import { prepareJsonFile } from "../cli/input-preparation.js";
13
14
  export function assessMigrationImpact(context, input) {
14
15
  const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
15
16
  if (!root.root)
@@ -147,7 +148,7 @@ export function planMigration(context, input) {
147
148
  }
148
149
  };
149
150
  }
150
- export function applyMigration(context, input) {
151
+ export function prepareMigrationApply(context, input) {
151
152
  const classification = input.classification ?? classifyCliOperation(["migration", "apply"], context.env);
152
153
  if (classification.mode !== "mb_upgrade") {
153
154
  throw new AppError("upgrade_authorization_required", "Migration apply requires explicit mb-upgrade authorization", 1, {
@@ -197,6 +198,13 @@ export function applyMigration(context, input) {
197
198
  });
198
199
  }
199
200
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
201
+ const current = activeStateSummary(context, project.id);
202
+ if (activeStateBlockers(current).length)
203
+ throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: current });
204
+ return { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain };
205
+ }
206
+ export function applyMigration(context, input, prepared = prepareMigrationApply(context, input)) {
207
+ const { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain } = prepared;
200
208
  const writeDb = getDatabase(context.ddFlowHome, "migrate");
201
209
  const already = writeDb.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, sourceVersion, targetVersion)]);
202
210
  if (already) {
@@ -217,7 +225,10 @@ export function applyMigration(context, input) {
217
225
  chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
218
226
  };
219
227
  try {
220
- writeDb.exec("BEGIN IMMEDIATE");
228
+ writeDb.beginWriteTransaction();
229
+ const current = activeStateSummary({ ...context, db: writeDb }, project.id);
230
+ if (activeStateBlockers(current).length)
231
+ throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: current });
221
232
  writeDb.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, [applyKey(project.id, sourceVersion, targetVersion), JSON.stringify(result), context.now(), context.now()]);
222
233
  writeDb.exec("COMMIT");
223
234
  }
@@ -513,13 +524,7 @@ function activeStateBlockers(active) {
513
524
  return blockers;
514
525
  }
515
526
  function readJsonObject(filePath) {
516
- let parsed;
517
- try {
518
- parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
519
- }
520
- catch (error) {
521
- throw new AppError("invalid_json", `Cannot read migration report: ${filePath}`, 2, { cause: String(error) });
522
- }
527
+ const { value: parsed } = prepareJsonFile(filePath, "Migration report");
523
528
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
524
529
  throw new AppError("validation", "Migration report must be a JSON object", 2, { file: filePath });
525
530
  }
@@ -0,0 +1,49 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome } from "./managed-processes.js";
6
+ import { retainedDaemonOperations } from "./run-control.js";
7
+ import { managedProviderBelongsToDaemon } from "./managed-daemon-binding.js";
8
+ /** A replaced daemon is a settled resource, never another Session to resume. */
9
+ export function historicalDaemonSettlement(context, scopeId, record) {
10
+ const metadata = JSON.parse(record.metadata_json);
11
+ if (!record.kind.endsWith("-daemon") || !metadata.state_dir || !path.isAbsolute(metadata.state_dir))
12
+ throw new AppError("runtime_scope_owner_unknown", "Native history requires an absolute daemon directory", 1);
13
+ const current = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
14
+ if (current.resource_process?.id === record.id)
15
+ return null;
16
+ if (record.state !== "stopped" || record.termination_reason !== "daemon_stopped" || !record.pid || !record.pid_started_at || processTreeIsAlive(record))
17
+ throw new AppError("runtime_scope_owner_unknown", "Previous daemon has no physical completion proof", 1);
18
+ const directory = path.join(metadata.state_dir, "daemon-history");
19
+ const matches = fs.readdirSync(directory).filter(name => /^[a-zA-Z0-9-]+\.json$/.test(name)).map(name => {
20
+ const file = path.join(directory, name);
21
+ if (!fs.lstatSync(file).isFile())
22
+ throw new AppError("runtime_scope_owner_unknown", "Daemon history must contain regular receipts", 1);
23
+ const bytes = fs.readFileSync(file, "utf8");
24
+ return { file, bytes, state: JSON.parse(bytes) };
25
+ }).filter(candidate => candidate.state.resource_process?.id === record.id);
26
+ if (matches.length !== 1)
27
+ throw new AppError("runtime_scope_owner_unknown", "Previous daemon has no unique retained receipt", 1);
28
+ const { file, bytes, state } = matches[0], owner = state.config?.runtime_owner;
29
+ if (!state.daemon_id || path.basename(file) !== `${state.daemon_id}.json` || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.dd_flow_home !== metadata.dd_flow_home || owner.resource_home !== resourceHome(context) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.lease_token !== record.lease_token || state.pid !== record.pid)
30
+ throw new AppError("runtime_scope_owner_unknown", "Archived daemon does not match its registration", 1);
31
+ const closed = state.shutdown_state === "clean" && state.active_tree === false
32
+ ? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
33
+ const fresh = managedProcessStatus(context).find(item => item.id === record.id);
34
+ if (!closed || !fresh || fresh.lease_token !== record.lease_token || fresh.pid !== record.pid || fresh.pid_started_at !== record.pid_started_at || fresh.metadata_json !== record.metadata_json || fresh.state !== "stopped" || fresh.termination_reason !== "daemon_stopped" || processTreeIsAlive(fresh) || fs.readFileSync(file, "utf8") !== bytes)
35
+ throw new AppError("runtime_scope_outcome_unknown", "Previous daemon lacks stable clean shutdown evidence", 1);
36
+ const providers = managedProcessStatus(context).filter(item => managedProviderBelongsToDaemon(item, fresh, state));
37
+ if (state.provider_process && (providers.length !== 1 || providers[0].state !== "stopped" || providers[0].termination_reason !== "daemon_stopped" || !providers[0].operation_id?.startsWith(`${owner.operation_id}:`) || processTreeIsAlive(providers[0])))
38
+ throw new AppError("runtime_scope_outcome_unknown", "Archived provider lacks its exact physical completion proof", 1);
39
+ return { historical: true, process_id: record.id, role: metadata.role, state_dir: metadata.state_dir, daemon_id: state.daemon_id,
40
+ processes: providers.map(item => ({ process_id: item.id, native_settled: true, binding: managedProcessBinding(item, resourceHome(context)) })),
41
+ settled: true, native_settled: true, operation_id: String(closed.operation_id), owner_operation_id: owner.operation_id,
42
+ inventory_version: crypto.createHash("sha256").update(JSON.stringify([bytes, closed])).digest("hex") };
43
+ }
44
+ export function assertHistoricalDaemonSettlement(context, scopeId, proof) {
45
+ const record = managedProcessStatus(context).find(item => item.id === proof.process_id);
46
+ const fresh = record && historicalDaemonSettlement(context, scopeId, record);
47
+ if (!fresh || proof.historical !== true || proof.native_settled !== true || fresh.inventory_version !== proof.inventory_version)
48
+ throw new AppError("runtime_scope_inventory_changed", "Archived daemon settlement changed after observation", 1);
49
+ }
@@ -0,0 +1,39 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
3
+ export async function cancelControlledNativeSession(input) {
4
+ input.assertCurrent();
5
+ const reply = await runHarnessAdapter({ executable: input.executable,
6
+ args: input.observe ? ["daemon", "operation", "--operation-id", input.operationId, "--state-dir", input.stateDir, "--json"] : ["session", "cancel", "--session-id", input.sessionId, "--state-dir", input.stateDir, "--json"],
7
+ env: input.env, operationId: input.observe ? `${input.operationId}:observe` : input.operationId, timeoutMs: 30_000 });
8
+ if (input.observe && (reply.operation_id !== input.operationId || reply.operation !== "session.cancel" || reply.session_id !== input.sessionId))
9
+ throw new AppError("controller_operation_mismatch", "Cancellation observation belongs to another operation or Session", 1);
10
+ if (input.observe && reply.state === "failed") {
11
+ if (!reply.error || typeof reply.error !== "object" || Array.isArray(reply.error))
12
+ throw new AppError("controller_operation_mismatch", "Cancellation failure has no terminal error", 1);
13
+ input.assertCurrent();
14
+ return { status: "failed", receipt: reply };
15
+ }
16
+ const result = input.observe ? reply.state === "completed" ? reply.result : null : reply;
17
+ // A completed cancel command is an immutable ACK, not proof that the
18
+ // provider tree has stopped. Callers must use a separate fresh inspection.
19
+ if (!input.observe || reply.state === "completed")
20
+ assertNativeCancellationReceipt(result, input.sessionId);
21
+ input.assertCurrent();
22
+ return { status: result ? "completed" : "outcome_unknown", receipt: (result ?? reply) };
23
+ }
24
+ export function assertNativeCancellationReceipt(value, sessionId) {
25
+ const identity = adapterSessionId(value);
26
+ if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId)
27
+ throw new AppError("controller_operation_mismatch", "Cancellation receipt has no valid result for its target Session", 1);
28
+ }
29
+ /** Native observation is shared by RUN and non-Work EVAL owners. */
30
+ export async function inspectControlledNativeSession(input) {
31
+ input.assertCurrent();
32
+ const receipt = await runHarnessAdapter({ executable: input.executable, args: ["session", "inspect", "--session-id", input.sessionId, "--state-dir", input.stateDir, "--json"], env: input.env, operationId: input.operationId, timeoutMs: 30_000 });
33
+ if (adapterSessionId(receipt) !== input.sessionId)
34
+ throw new AppError("controller_operation_mismatch", "Session observation belongs to another target", 1);
35
+ if (input.harness && receipt.harness !== undefined && receipt.harness !== input.harness)
36
+ throw new AppError("controller_operation_mismatch", "Session observation belongs to another harness", 1);
37
+ input.assertCurrent();
38
+ return receipt;
39
+ }
@@ -6,17 +6,23 @@ import { parseJsonObject } from "../shared/json.js";
6
6
  import { refreshRunSessionProjection } from "./run-projection.js";
7
7
  export function readCanonicalPlan(planPath, protocolId) {
8
8
  if (!fs.existsSync(planPath) || !fs.statSync(planPath).isFile()) {
9
- throw new AppError("not_found", `Canonical protocol plan is missing: ${planPath}`, 1, { plan_path: planPath });
9
+ throw new AppError("runtime_artifact_missing", `Canonical protocol plan is missing: ${planPath}`, 1, { plan_path: planPath });
10
10
  }
11
11
  const raw = fs.readFileSync(planPath, "utf8");
12
- const value = parseJsonObject(raw, planPath);
13
- const plan = validatePlan(value, protocolId);
14
- const revisionValue = value.revision;
15
- const revision = typeof revisionValue === "number" && Number.isInteger(revisionValue) && revisionValue > 0 ? revisionValue : 1;
16
- return { plan, raw, sha256: crypto.createHash("sha256").update(raw).digest("hex"), revision };
12
+ try {
13
+ const value = parseJsonObject(raw, planPath);
14
+ const plan = validatePlan(value, protocolId);
15
+ const revisionValue = value.revision;
16
+ const revision = typeof revisionValue === "number" && Number.isInteger(revisionValue) && revisionValue > 0 ? revisionValue : 1;
17
+ return { plan, raw, sha256: crypto.createHash("sha256").update(raw).digest("hex"), revision };
18
+ }
19
+ catch (error) {
20
+ if (!(error instanceof AppError) || error.code !== "validation")
21
+ throw error;
22
+ throw new AppError("runtime_artifact_invalid", "Canonical protocol plan is invalid", 1, { plan_path: planPath, cause_code: error.code });
23
+ }
17
24
  }
18
- export function boundCanonicalPlan(context, input) {
19
- const canonical = readCanonicalPlan(input.planPath, input.protocolId);
25
+ export function prepareCanonicalPlan(context, input, canonical = readCanonicalPlan(input.planPath, input.protocolId)) {
20
26
  const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
21
27
  if (binding && (binding.plan_revision !== canonical.revision || binding.plan_sha256 !== canonical.sha256)) {
22
28
  throw new AppError("plan_stale", "Canonical plan changed after the runtime binding was created", 1, {
@@ -28,6 +34,11 @@ export function boundCanonicalPlan(context, input) {
28
34
  plan_path: input.planPath
29
35
  });
30
36
  }
37
+ return canonical;
38
+ }
39
+ export function boundCanonicalPlan(context, input, prepared) {
40
+ const canonical = prepareCanonicalPlan(context, input, prepared);
41
+ const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
31
42
  const now = context.now();
32
43
  if (!binding) {
33
44
  context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)
@@ -4,7 +4,7 @@ import { appendAudit } from "./audit.js";
4
4
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
7
+ import { planSummary, planWithProgress, prepareCanonicalPlan, updatePlanProgress } from "./plan-runtime.js";
8
8
  export function getPlanStatus(context, input) {
9
9
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
10
10
  const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false });
@@ -20,40 +20,43 @@ export function getPlanStatus(context, input) {
20
20
  };
21
21
  }
22
22
  export function startPlanItem(context, input) {
23
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
24
- assertDependenciesClosed(plan, item);
25
- return { ...item, status: "in_progress", summary: item.summary || "Started." };
26
- });
23
+ return updatePlanItem(context, { ...input, action: "start" });
27
24
  }
28
25
  export function completePlanItem(context, input) {
29
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
30
- assertDependenciesClosed(plan, item);
31
- return { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] };
32
- });
26
+ return updatePlanItem(context, { ...input, action: "done" });
33
27
  }
34
28
  export function blockPlanItem(context, input) {
35
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
36
- ...item,
37
- status: "blocked",
38
- summary: input.reason,
39
- block_reason: input.reason,
40
- user_required: input.userRequired
41
- }));
29
+ return updatePlanItem(context, { ...input, action: "block" });
42
30
  }
43
31
  export function skipPlanItem(context, input) {
44
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
45
- ...item,
46
- status: "skipped",
47
- summary: input.reason
48
- }));
32
+ return updatePlanItem(context, { ...input, action: "skip" });
49
33
  }
50
- function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
34
+ export function preparePlanItemMutation(context, input) {
35
+ const { projectRoot, protocolId, itemId } = input;
36
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
37
+ if (!text(itemId) || !["start", "done", "block", "skip"].includes(input.action) ||
38
+ (input.action === "done" && (!text(input.summary) || !Array.isArray(input.evidence) || !input.evidence.every(text))) ||
39
+ ((input.action === "block" || input.action === "skip") && !text(input.reason)) ||
40
+ (input.action === "block" && typeof input.userRequired !== "boolean")) {
41
+ throw new AppError("validation", "Plan item requires a valid action, item ID, nonempty summary/reason, text evidence and boolean userRequired", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
42
+ }
51
43
  const protocol = scopedProtocol(context, projectRoot, protocolId);
52
- const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
44
+ const scope = { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path };
45
+ const current = planWithProgress(context, scope, { bind: false });
46
+ prepareCanonicalPlan(context, scope, current.canonical);
53
47
  const item = current.plan.items.find((candidate) => candidate.id === itemId);
54
48
  if (!item)
55
49
  throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
56
- const nextItem = transform(item, current.plan);
50
+ if (input.action === "start" || input.action === "done")
51
+ assertDependenciesClosed(current.plan, item);
52
+ const nextItem = input.action === "start" ? { ...item, status: "in_progress", summary: item.summary || "Started." }
53
+ : input.action === "done" ? { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] }
54
+ : input.action === "block" ? { ...item, status: "blocked", summary: input.reason, block_reason: input.reason, user_required: input.userRequired }
55
+ : { ...item, status: "skipped", summary: input.reason };
56
+ return { protocol, item, nextItem };
57
+ }
58
+ export function updatePlanItem(context, input) {
59
+ const { protocol, item, nextItem } = preparePlanItemMutation(context, input);
57
60
  updatePlanProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path, item: nextItem });
58
61
  if (nextItem.status === "done") {
59
62
  context.db.run("UPDATE flow_jobs SET status = 'done', finished_at = COALESCE(finished_at, ?), updated_at = ? WHERE project_id = ? AND protocol_id = ? AND plan_item_id = ?", [context.now(), context.now(), protocol.project_id, protocol.id, nextItem.id]);
@@ -63,7 +66,7 @@ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
63
66
  protocolId: protocol.id,
64
67
  projectId: protocol.project_id,
65
68
  eventType: "plan.progress.updated",
66
- payload: { protocol_id: protocol.id, item_id: itemId, from: item.status, to: nextItem.status }
69
+ payload: { protocol_id: protocol.id, item_id: input.itemId, from: item.status, to: nextItem.status }
67
70
  });
68
71
  return { ok: true, protocol_id: protocol.id, item: nextItem, plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) };
69
72
  }
@@ -37,8 +37,13 @@ export function registerProject(context, input) {
37
37
  }
38
38
  return { ok: true, project: { ...project, status: "active", updated_at: now }, db_path: context.db.path };
39
39
  }
40
- export function archiveProject(context, input) {
41
- const reason = input.reason.trim();
40
+ export function prepareProjectArchive(context, input) {
41
+ if (Boolean(input.idOrAlias) === Boolean(input.root))
42
+ throw new AppError("usage", "Project archive requires exactly one project ID/alias or --root", 2);
43
+ for (const value of [input.idOrAlias, input.root])
44
+ if (value !== undefined && (typeof value !== "string" || !value.trim()))
45
+ throw new AppError("validation", "Project archive target must be a non-empty string", 2);
46
+ const reason = typeof input.reason === "string" ? input.reason.trim() : "";
42
47
  if (!reason) {
43
48
  throw new AppError("validation", "project archive requires --reason", 2);
44
49
  }
@@ -50,6 +55,10 @@ export function archiveProject(context, input) {
50
55
  if (!project) {
51
56
  throw new AppError("not_found", "Project is not registered.", 1, { root: input.root, input: input.idOrAlias });
52
57
  }
58
+ return { project, reason };
59
+ }
60
+ export function archiveProject(context, input) {
61
+ const { project, reason } = prepareProjectArchive(context, input);
53
62
  const now = context.now();
54
63
  const result = context.db.run("UPDATE projects SET status = 'archived', updated_at = ? WHERE id = ? AND status <> 'archived'", [now, project.id]);
55
64
  if (result.changes === 1) {
@@ -140,7 +149,7 @@ export function migrateProjectIds(context, input) {
140
149
  throw new AppError("conflict", `Target project id already exists: ${target.id}`, 1, plan);
141
150
  }
142
151
  context.db.exec("PRAGMA foreign_keys = OFF");
143
- context.db.exec("BEGIN IMMEDIATE");
152
+ context.db.beginWriteTransaction();
144
153
  try {
145
154
  context.db.run("UPDATE projects SET id = ?, short_id = ?, slug = ?, state_root = ?, updated_at = ? WHERE id = ?", [target.id, target.short_id, target.slug, target.state_root, now, oldId]);
146
155
  for (const table of projectReferenceTables()) {
@@ -232,9 +241,13 @@ export function findProjectByRoot(context, root) {
232
241
  return context.db.get("SELECT * FROM projects WHERE root = ?", [root]);
233
242
  }
234
243
  export function requireProjectByRoot(context, root) {
235
- const project = findProjectByRoot(context, root);
244
+ // Callers may receive a path through a native adapter or shell. Resolve
245
+ // aliases such as macOS `/var` -> `/private/var` before comparing with the
246
+ // canonical root persisted in the project registry.
247
+ const canonical = normalizeStoredRoot(root);
248
+ const project = findProjectByRoot(context, canonical);
236
249
  if (!project) {
237
- throw new AppError("not_found", `Project is not registered: ${root}`, 1);
250
+ throw new AppError("not_found", `Project is not registered: ${canonical}`, 1);
238
251
  }
239
252
  return project;
240
253
  }
@@ -9,7 +9,8 @@ import { validatePlanItem } from "../domain/validation.js";
9
9
  import { AppError } from "../shared/errors.js";
10
10
  import { parseJsonObject } from "../shared/json.js";
11
11
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
12
- import { planWithProgress } from "./plan-runtime.js";
12
+ import { boundCanonicalPlan, planWithProgress, prepareCanonicalPlan } from "./plan-runtime.js";
13
+ import { prepareTextFile } from "../cli/input-preparation.js";
13
14
  import { registerFlowJob } from "./sessions.js";
14
15
  const profiles = {
15
16
  code_implementation: {
@@ -22,7 +23,7 @@ const profiles = {
22
23
  static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/verify.md"]
23
24
  }
24
25
  };
25
- export function renderWorkerPrompt(context, input) {
26
+ export function prepareWorkerPrompt(context, input) {
26
27
  const projectRoot = resolveProjectRoot(input.projectRoot);
27
28
  const project = requireProjectByRoot(context, projectRoot);
28
29
  const protocol = requireProtocol(context, protocolIdForRun(context, project.id, input.runId), project.id);
@@ -43,8 +44,10 @@ export function renderWorkerPrompt(context, input) {
43
44
  projectId: protocol.project_id,
44
45
  protocolId: protocol.id,
45
46
  planPath: protocol.plan_path
46
- });
47
+ }, { bind: false });
47
48
  const plan = planRuntime?.plan ?? null;
49
+ if (planRuntime)
50
+ prepareCanonicalPlan(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, planRuntime.canonical);
48
51
  const genericTask = input.taskFile ? readWorkerTask(input.taskFile, runHomePath(run)) : null;
49
52
  const item = genericTask?.item ?? plan?.items.find((candidate) => candidate.id === input.planItemId);
50
53
  if (!item)
@@ -64,13 +67,6 @@ export function renderWorkerPrompt(context, input) {
64
67
  const profile = profiles[input.profile];
65
68
  if (!profile)
66
69
  throw new AppError("prompt_profile_unsupported", `Unsupported prompt profile: ${input.profile}`, 2);
67
- const job = registerFlowJob(context, {
68
- projectId: protocol.project_id,
69
- runId: run.id,
70
- protocolId: protocol.id,
71
- planItemId: item.id,
72
- groupId: item.execution_context.planned_write_areas[0] ?? null
73
- });
74
70
  const staticInputs = profile.static_files.map((file) => readStaticInput(projectRoot, file));
75
71
  const requiredRead = item.execution_context.required_read.map((file) => checkedReference(projectRoot, runHomePath(run), file, "required_read", true));
76
72
  const discoveryBoundary = item.execution_context.discovery_boundary.map((file) => checkedReference(projectRoot, runHomePath(run), file, "discovery_boundary", false));
@@ -78,7 +74,6 @@ export function renderWorkerPrompt(context, input) {
78
74
  const runHome = runHomePath(run);
79
75
  const outputDir = path.join(runHome, stage.dir, "subagents", item.id);
80
76
  assertWithin(runHome, outputDir, "output directory");
81
- ensureDir(outputDir);
82
77
  const prompt = renderPrompt({
83
78
  item,
84
79
  input,
@@ -90,6 +85,20 @@ export function renderWorkerPrompt(context, input) {
90
85
  staticInputs,
91
86
  ...(genericTask ? { handoff: genericTask.handoff } : {})
92
87
  });
88
+ return { protocol, run, item, plan, planRuntime, genericTask, workspaceRoot, staticInputs, requiredRead, discoveryBoundary, plannedWriteAreas, outputDir, prompt, canonVersion: readCanonVersion(projectRoot) };
89
+ }
90
+ export function renderWorkerPrompt(context, input, prepared = prepareWorkerPrompt(context, input)) {
91
+ const { protocol, run, item, plan, planRuntime, genericTask, workspaceRoot, staticInputs, requiredRead, discoveryBoundary, plannedWriteAreas, outputDir, prompt, canonVersion } = prepared;
92
+ if (planRuntime)
93
+ boundCanonicalPlan(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, planRuntime.canonical);
94
+ const job = registerFlowJob(context, {
95
+ projectId: protocol.project_id,
96
+ runId: run.id,
97
+ protocolId: protocol.id,
98
+ planItemId: item.id,
99
+ groupId: item.execution_context.planned_write_areas[0] ?? null
100
+ });
101
+ ensureDir(outputDir);
93
102
  const promptPath = path.join(outputDir, "launch-prompt.md");
94
103
  const stackPath = path.join(outputDir, "prompt-stack.json");
95
104
  const reportPath = path.join(outputDir, "render-report.json");
@@ -104,7 +113,7 @@ export function renderWorkerPrompt(context, input) {
104
113
  plan_item_id: input.taskFile ? null : item.id,
105
114
  task_id: input.taskFile ? item.id : null,
106
115
  job_id: job.job_id,
107
- canon_version: readCanonVersion(projectRoot),
116
+ canon_version: canonVersion,
108
117
  renderer_version: getCliVersionReport().cli.version,
109
118
  static_inputs: staticInputs.map(({ path: inputPath, sha256 }) => ({ path: inputPath, sha256 })),
110
119
  validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, planned_write_areas: plannedWriteAreas },
@@ -143,9 +152,7 @@ function runHomePath(run) {
143
152
  function readWorkerTask(taskFile, runHome) {
144
153
  const resolved = path.resolve(taskFile);
145
154
  assertWithin(runHome, resolved, "worker task file");
146
- if (!fs.existsSync(resolved))
147
- throw new AppError("not_found", "Worker task file is missing", 1, { path: taskFile });
148
- const task = parseJsonObject(fs.readFileSync(resolved, "utf8"), resolved);
155
+ const task = parseJsonObject(prepareTextFile(resolved, "worker task").text, resolved);
149
156
  if (task.schema_id !== "dd-flow/worker-task@1" || !task.task || typeof task.task !== "object") {
150
157
  throw new AppError("validation", "Worker task must use dd-flow/worker-task@1 with a task object", 2, { path: taskFile });
151
158
  }
@@ -180,7 +187,7 @@ function validateWorkerTaskHandoff(value, runHome) {
180
187
  const reportPath = path.resolve(report.report_path);
181
188
  assertWithin(runHome, reportPath, "predecessor report");
182
189
  if (!fs.existsSync(reportPath))
183
- throw new AppError("not_found", "Accepted predecessor report is missing", 1, { path: report.report_path });
190
+ throw new AppError("runtime_artifact_missing", "Accepted predecessor report is missing", 1, { path: report.report_path });
184
191
  return { aspect_id: report.aspect_id, verdict, report_path: reportPath };
185
192
  });
186
193
  const recovery = handoff.recovery_attempt_paths ?? [];