@deksden-com/dd-flow-cli 0.9.0-beta.63 → 0.9.0-beta.64

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 (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/run-cli.js +45 -4
  4. package/dist/harness-runtime/lib/native-hook-command.mjs +1 -0
  5. package/dist/harness-runtime/lib/process-json.mjs +15 -6
  6. package/dist/services/cleanup.js +1 -1
  7. package/dist/services/code-checks.js +1 -1
  8. package/dist/services/eval-snapshots.js +2 -2
  9. package/dist/services/harness-adapter.js +61 -9
  10. package/dist/services/hooks.js +26 -0
  11. package/dist/services/lanes.js +1 -1
  12. package/dist/services/lifecycle-invocations.js +194 -22
  13. package/dist/services/managed-processes.js +2 -2
  14. package/dist/services/merge-queue.js +6 -6
  15. package/dist/services/merge-server.js +1 -1
  16. package/dist/services/migrations.js +1 -1
  17. package/dist/services/projects.js +1 -1
  18. package/dist/services/protocols.js +2 -2
  19. package/dist/services/run-control-worker.js +3 -3
  20. package/dist/services/run-control.js +1 -1
  21. package/dist/services/run-controller-adapter.js +8 -3
  22. package/dist/services/run-controller-capture.js +2 -2
  23. package/dist/services/run-controller-process.js +2 -2
  24. package/dist/services/run-controller-recovery.js +3 -3
  25. package/dist/services/run-controller.js +297 -180
  26. package/dist/services/run-recovery-runtime.js +1 -1
  27. package/dist/services/run-recovery.js +3 -3
  28. package/dist/services/runs.js +13 -14
  29. package/dist/services/runtime-budget.js +4 -4
  30. package/dist/services/runtime-scope-control.js +5 -5
  31. package/dist/services/runtime-scope-resume.js +7 -7
  32. package/dist/services/runtime-scope-worker.js +2 -2
  33. package/dist/services/stage-pause.js +2 -2
  34. package/dist/services/vnext-code-review.js +9 -4
  35. package/dist/services/vnext-code.js +10 -3
  36. package/dist/services/vnext-merge.js +16 -21
  37. package/dist/services/vnext-plan-review.js +4 -2
  38. package/dist/services/vnext-plan.js +1 -1
  39. package/dist/services/work-registry.js +198 -121
  40. package/dist/storage/database.js +112 -20
  41. package/dist/storage/writer-contract.js +2 -1
  42. package/dist/storage/writer-migration.js +2 -2
  43. package/package.json +1 -1
@@ -109,7 +109,7 @@ export function beginRunRecovery(context, input) {
109
109
  const interruption = parseObject(input.interruption, "interruption");
110
110
  const recoveryId = `RCV-${crypto.randomUUID()}`, now = context.now();
111
111
  let generation;
112
- context.db.exec("BEGIN IMMEDIATE");
112
+ context.db.beginWriteTransaction();
113
113
  try {
114
114
  const prior = recoveryGuard(context, project.id, run.id);
115
115
  const supersedes = prior?.status === "resuming" && interruption.recovery_parent_id === prior.recovery_id;
@@ -206,7 +206,7 @@ export function resumeRunRecovery(context, input) {
206
206
  verifyRecoverySnapshot(guard.capture_path, { runId: run.id, recoveryId: guard.recovery_id, projectRoot: run.project_root, workspaceRoot: run.workspace_root });
207
207
  const now = context.now();
208
208
  let running = [];
209
- context.db.exec("BEGIN IMMEDIATE");
209
+ context.db.beginWriteTransaction();
210
210
  try {
211
211
  const current = recoveryGuard(context, project.id, run.id);
212
212
  if (!current || current.recovery_id !== guard.recovery_id || current.generation !== guard.generation || current.status !== "sealed" || current.capture_path !== guard.capture_path)
@@ -261,7 +261,7 @@ export function acceptRunRecovery(context, input) {
261
261
  const identity = hookSessionIdentity(context, project.id, eventKey);
262
262
  const now = context.now();
263
263
  let packetReceipt;
264
- context.db.exec("BEGIN IMMEDIATE");
264
+ context.db.beginWriteTransaction();
265
265
  try {
266
266
  const guard = recoveryGuard(context, project.id, run.id);
267
267
  const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
@@ -414,7 +414,7 @@ export function reviseFlowRunFlags(context, input) {
414
414
  const run = resolveRun(context, project.id, input.runId);
415
415
  assertRunMutationAllowed(context, project.id, run.id);
416
416
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
417
- context.db.exec("BEGIN IMMEDIATE");
417
+ context.db.beginWriteTransaction();
418
418
  let committed = false;
419
419
  try {
420
420
  const existingMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
@@ -585,19 +585,18 @@ export function attachFlowRunStage(context, input) {
585
585
  }
586
586
  /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
587
587
  export function withStageSettlement(context, operation) {
588
- context.db.exec("SAVEPOINT stage_settlement");
589
- try {
590
- const result = operation();
591
- if (result instanceof Promise)
592
- throw new Error("Stage settlement must be synchronous");
593
- context.db.exec("RELEASE stage_settlement");
594
- return result;
595
- }
596
- catch (error) {
597
- context.db.exec("ROLLBACK TO stage_settlement");
598
- context.db.exec("RELEASE stage_settlement");
599
- throw error;
600
- }
588
+ return context.db.writeTransaction(operation);
589
+ }
590
+ /** Capture accepted inputs before asynchronous validation; compare under the
591
+ * settlement writer reservation instead of accepting a stale read snapshot. */
592
+ export function stageInputGuard(context, projectId, runId, readFiles) {
593
+ const read = () => JSON.stringify({
594
+ works: context.db.all("SELECT work_id, parent_work_id, task, status, result, payload_json, depends_on_json FROM works WHERE project_id = ? AND run_id = ? ORDER BY work_id", [projectId, runId]),
595
+ files: readFiles()
596
+ });
597
+ const accepted = read();
598
+ return () => { if (read() !== accepted)
599
+ throw new AppError("stage_inputs_changed", "Accepted stage inputs changed during validation; settle only newly validated inputs", 1, { run_id: runId, effect: "no_effect" }); };
601
600
  }
602
601
  export function completeFlowRunStage(context, input) {
603
602
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -69,7 +69,7 @@ export function requestRuntimeScopeControl(context, scopeId, requestId, mode, ma
69
69
  throw new AppError("runtime_budget_invalid", "Scope control requires a bounded request identity and pause or stop", 2);
70
70
  const db = getResourceDatabase(resourceHome(context));
71
71
  const requestKey = digest(JSON.stringify([scopeId, requestId]));
72
- db.exec("BEGIN IMMEDIATE");
72
+ db.beginWriteTransaction();
73
73
  try {
74
74
  if (manifest !== undefined) {
75
75
  const declaration = validateRuntimeScopeManifest(manifest, scopeId), payload = JSON.stringify(declaration);
@@ -131,7 +131,7 @@ export function scopeLocalProcessRole(record) {
131
131
  /** Local checks and EVAL observers share the scope fence, not provider capacity. */
132
132
  export function admitRuntimeCheck(context, id, leaseToken) {
133
133
  const db = getResourceDatabase(resourceHome(context));
134
- db.exec("BEGIN IMMEDIATE");
134
+ db.beginWriteTransaction();
135
135
  try {
136
136
  const record = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]);
137
137
  if (!record || record.lease_token !== leaseToken || !["eval-baseline", "eval-observer", "check"].includes(record.kind) || record.state !== "running" || !processIsAlive(record))
@@ -189,7 +189,7 @@ export function fenceRuntimeBudget(context, scopeId, requestId) {
189
189
  if (!requestId || requestId.length > 256)
190
190
  throw new AppError("runtime_budget_invalid", "Scope fence requires a bounded request identity", 2);
191
191
  const db = getResourceDatabase(resourceHome(context));
192
- db.exec("BEGIN IMMEDIATE");
192
+ db.beginWriteTransaction();
193
193
  try {
194
194
  const existing = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]);
195
195
  const now = context.now();
@@ -248,7 +248,7 @@ export function reserveProviderTurn(context, record, input) {
248
248
  const db = getResourceDatabase(resourceHome(context));
249
249
  const key = digest(JSON.stringify([record.id, input.operationId]));
250
250
  const policy = JSON.stringify(budget), now = context.now();
251
- db.exec("BEGIN IMMEDIATE");
251
+ db.beginWriteTransaction();
252
252
  try {
253
253
  const current = db.get("SELECT * FROM managed_processes WHERE id = ?", [record.id]);
254
254
  if (current?.lease_token !== record.lease_token || current.state !== "running")
@@ -23,7 +23,7 @@ export function retainScopeDrain(context, control, processes, runs, native, prob
23
23
  const registry = getResourceDatabase(resourceHome(context));
24
24
  const targets = new Map();
25
25
  const captures = [];
26
- registry.exec("BEGIN IMMEDIATE");
26
+ registry.beginWriteTransaction();
27
27
  try {
28
28
  for (const run of [...runs].sort((a, b) => String(a.runtime_home).localeCompare(String(b.runtime_home)))) {
29
29
  const home = String(run.runtime_home);
@@ -31,7 +31,7 @@ export function retainScopeDrain(context, control, processes, runs, native, prob
31
31
  if (!target) {
32
32
  target = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_RESOURCE_HOME: resourceHome(context), DD_FLOW_ENGINE_MODE: "1" });
33
33
  try {
34
- target.db.exec("BEGIN IMMEDIATE");
34
+ target.db.beginWriteTransaction();
35
35
  }
36
36
  catch (error) {
37
37
  target.db.close?.();
@@ -157,7 +157,7 @@ export async function controlRuntimeScopeLocalProbes(context, scopeId, generatio
157
157
  if (mode === "pause" && processTreeIsAlive(current))
158
158
  return { process_id: record.id, physical_settled: false, reason: "local_probe_running" };
159
159
  const operationId = `scope-probe:${crypto.createHash("sha256").update(JSON.stringify([scopeId, generation, record.id, record.lease_token, record.pid_started_at])).digest("hex")}`;
160
- registry.exec("BEGIN IMMEDIATE");
160
+ registry.beginWriteTransaction();
161
161
  try {
162
162
  current = validate();
163
163
  registry.run("INSERT OR IGNORE INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-operation',?,?,?,?,NULL,?,?,?)", [operationId, scopeId, record.lease_token, context.now(), JSON.stringify({ scope_id: scopeId, generation, process_id: record.id, operation: mode === "stop" ? "probe.stop" : "probe.observe", status: "dispatching" }), context.now(), context.now()]);
@@ -240,7 +240,7 @@ export async function controlRuntimeScopeNativeOwners(context, scopeId, generati
240
240
  const operationId = `scope-cancel:${crypto.createHash("sha256").update(JSON.stringify([scopeId, generation, record.id, state.daemon_id, id])).digest("hex")}`;
241
241
  const now = context.now();
242
242
  let inserted = false;
243
- registry.exec("BEGIN IMMEDIATE");
243
+ registry.beginWriteTransaction();
244
244
  try {
245
245
  assertOwner();
246
246
  inserted = registry.run("INSERT OR IGNORE INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-operation',?,?,?,?,NULL,?,?,?)", [operationId, scopeId, record.lease_token, now, JSON.stringify({ scope_id: scopeId, generation, process_id: record.id, daemon_id: state.daemon_id, session_id: id, status: "dispatching" }), now, now]).changes === 1;
@@ -331,7 +331,7 @@ export async function controlRuntimeScopeRuns(context, scopeId, requestId, mode,
331
331
  let input = { projectRoot: project.root, runId: record.run_id, requestId: runRequestId, action: mode };
332
332
  // Lock order is resource registry then RUN database. No provider or await
333
333
  // occurs here; a newer scope request cannot interleave RUN intent creation.
334
- registry.exec("BEGIN IMMEDIATE");
334
+ registry.beginWriteTransaction();
335
335
  try {
336
336
  const current = registry.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
337
337
  if (!current || JSON.parse(current.metadata_json).generation !== control.generation || registry.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]))
@@ -67,7 +67,7 @@ export async function prepareRuntimeScopeResume(context, scopeId, generation, re
67
67
  if (!project || manifest?.budget?.scope_id !== scopeId || manifest.resource_home !== resourceHome(context))
68
68
  throw new AppError("runtime_scope_owner_unknown", "Resume RUN no longer belongs to this retained scope", 1);
69
69
  const input = { projectRoot: project.root, runId: source.run_id, requestId: source.resume_request_id, controlId: source.control_id };
70
- db.exec("BEGIN IMMEDIATE");
70
+ db.beginWriteTransaction();
71
71
  try {
72
72
  assertCurrent();
73
73
  recordControllerResume(target, input);
@@ -111,10 +111,10 @@ export async function prepareRuntimeScopeResume(context, scopeId, generation, re
111
111
  try {
112
112
  for (const home of [...new Set(intent.runs.map((source) => source.runtime_home))].sort())
113
113
  lockedRuns.set(home, createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_RESOURCE_HOME: resourceHome(context), DD_FLOW_ENGINE_MODE: "1" }));
114
- db.exec("BEGIN IMMEDIATE");
114
+ db.beginWriteTransaction();
115
115
  registryHeld = true;
116
116
  for (const target of lockedRuns.values()) {
117
- target.db.exec("BEGIN IMMEDIATE");
117
+ target.db.beginWriteTransaction();
118
118
  heldRuns.push(target);
119
119
  }
120
120
  assertCurrent();
@@ -316,7 +316,7 @@ async function restartScopeNative(context, intent, source, assertCurrent) {
316
316
  };
317
317
  assertRetired();
318
318
  let dispatch = false;
319
- db.exec("BEGIN IMMEDIATE");
319
+ db.beginWriteTransaction();
320
320
  try {
321
321
  assertRetired();
322
322
  if (operation.status === "retired") {
@@ -370,7 +370,7 @@ async function restartScopeNative(context, intent, source, assertCurrent) {
370
370
  for (const session of source.sessions) {
371
371
  const operationId = `scope-load:${hash(JSON.stringify([intent.scope_id, intent.generation, source.process_id, session.session_id]))}`;
372
372
  let load, dispatchLoad = false;
373
- db.exec("BEGIN IMMEDIATE");
373
+ db.beginWriteTransaction();
374
374
  try {
375
375
  assertReplacement();
376
376
  const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-resume-native-load' AND resource_key = ?", [operationId]);
@@ -439,7 +439,7 @@ async function retireScopeNative(context, intent, source, assertCurrent) {
439
439
  throw new AppError("runtime_scope_native_launch_required", "Native recovery requires explicit configuration presence before retiring its owner", 1);
440
440
  let operation;
441
441
  let dispatch = false;
442
- db.exec("BEGIN IMMEDIATE");
442
+ db.beginWriteTransaction();
443
443
  try {
444
444
  assertOwner();
445
445
  const saved = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-resume-native' AND resource_key = ?", [key]);
@@ -486,7 +486,7 @@ export function recordRuntimeScopeResume(context, input) {
486
486
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !Number.isSafeInteger(input.generation) || input.generation < 1 || !/^[a-f0-9]{64}$/.test(input.captureKey))
487
487
  throw new AppError("runtime_scope_resume_invalid", "Scope resume requires its exact generation, capture and request identity", 2);
488
488
  const db = getResourceDatabase(resourceHome(context)), key = hash(JSON.stringify([input.scopeId, input.requestId]));
489
- db.exec("BEGIN IMMEDIATE");
489
+ db.beginWriteTransaction();
490
490
  try {
491
491
  const latest = runtimeBudgetStatus(context, input.scopeId);
492
492
  const control = latest.control;
@@ -40,7 +40,7 @@ export async function reconcileRuntimeScopeControl(context, scopeId, generation,
40
40
  export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest, reconcileId, resumeId) {
41
41
  const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
42
42
  let worker;
43
- db.exec("BEGIN IMMEDIATE");
43
+ db.beginWriteTransaction();
44
44
  try {
45
45
  const current = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
46
46
  const prior = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
@@ -108,7 +108,7 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
108
108
  throw new AppError("runtime_scope_owner_unknown", "Scope worker requires its launch token and generation", 1);
109
109
  const db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, generation), token = context.env.DD_FLOW_SCOPE_CONTROL_TOKEN;
110
110
  let worker;
111
- db.exec("BEGIN IMMEDIATE");
111
+ db.beginWriteTransaction();
112
112
  try {
113
113
  const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
114
114
  if (!row)
@@ -27,7 +27,7 @@ export function pauseStageForUser(context, input) {
27
27
  fs.mkdirSync(pauseRoot, { recursive: true });
28
28
  fs.writeFileSync(questionPath, question);
29
29
  const now = context.now();
30
- context.db.exec("BEGIN IMMEDIATE");
30
+ context.db.beginWriteTransaction();
31
31
  try {
32
32
  const paused = context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
33
33
  if (paused.changes !== 1)
@@ -78,7 +78,7 @@ export function resumeStageAfterUser(context, input) {
78
78
  if (!workSession)
79
79
  throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
80
80
  const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
81
- context.db.exec("BEGIN IMMEDIATE");
81
+ context.db.beginWriteTransaction();
82
82
  let identity;
83
83
  try {
84
84
  identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
@@ -14,12 +14,12 @@ import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
14
14
  import { addVnextCodeRepair, resumeAcceptedCodeStage } from "./vnext-code.js";
15
15
  import { finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
16
16
  import { addWorkBatch, bindStageCoordinatorWork, finishStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
17
- import { withStageSettlement } from "./runs.js";
17
+ import { withStageSettlement, stageInputGuard } from "./runs.js";
18
18
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
19
19
  import { writeStageReport } from "./stage-report-renderer.js";
20
20
  import { applyExternalStageContext } from "./stage-context.js";
21
21
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
22
- import { ensureVnextMergeRequest } from "./vnext-merge.js";
22
+ import { prepareVnextMergeRequest } from "./vnext-merge.js";
23
23
  import { validateVnextCodeHandoff } from "./vnext-plan.js";
24
24
  import { assertRunMutationAllowed } from "./run-recovery.js";
25
25
  const stage = "code-review";
@@ -175,6 +175,7 @@ export async function finishVnextCodeReview(context, input) {
175
175
  validateDecision(context, { decision: canonical.decision, reviewers: canonical.reviewers, repairs, workspaceRoot: run.workspace_root });
176
176
  }
177
177
  const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
178
+ const assertInputs = stageInputGuard(context, project.id, run.id, () => [workspaceFingerprint(run.workspace_root), sha256File(decisionFile), sha256File(path.join(home, "05-code", "stage-report.json"))]);
178
179
  const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
179
180
  const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
180
181
  if (input.retryCheckId) {
@@ -200,10 +201,14 @@ export async function finishVnextCodeReview(context, input) {
200
201
  // The request must exist before CODE-REVIEW becomes terminal. Otherwise a
201
202
  // merge-gate error creates an irrecoverable `done` report with no MRG-*.
202
203
  const acceptedPaths = changedPaths(run.workspace_root);
203
- const mergeRequest = nextAction === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths }) : null;
204
- writeReport(root, report);
204
+ let mergeRequest = null;
205
+ assertInputs();
206
+ const prepareMerge = nextAction === "start_merge" ? prepareVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths }) : null;
205
207
  withStageSettlement(context, () => {
206
208
  assertRunMutationAllowed(context, project.id, run.id, generation);
209
+ assertInputs();
210
+ mergeRequest = prepareMerge?.() ?? null;
211
+ writeReport(root, report);
207
212
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
208
213
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
209
214
  if (nextAction === "start_merge")
@@ -1,5 +1,7 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
+ import { stageInputGuard } from "./runs.js";
4
+ import { workspaceFingerprint } from "./code-checks.js";
3
5
  import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
4
6
  import fs from "node:fs";
5
7
  import path from "node:path";
@@ -16,7 +18,7 @@ import { readVnextSpecifyResult } from "./vnext-specify.js";
16
18
  import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishStageCoordinatorWork, refreshRunWorkProjection, validateWorkBatchFile, workStartCommand } from "./work-registry.js";
17
19
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
18
20
  import { writeStageReport } from "./stage-report-renderer.js";
19
- import { ensureVnextMergeRequest } from "./vnext-merge.js";
21
+ import { prepareVnextMergeRequest } from "./vnext-merge.js";
20
22
  import { assertStageStartHookEvent } from "./hooks.js";
21
23
  import { applyExternalStageContext } from "./stage-context.js";
22
24
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
@@ -143,6 +145,7 @@ export async function finishVnextCode(context, input) {
143
145
  throw new AppError("obligation_coverage_incomplete", "CODE graph does not cover every accepted obligation", 2, { missing });
144
146
  }
145
147
  // Reject a malformed semantic receipt before running the expensive gate.
148
+ const assertInputs = stageInputGuard(context, project.id, run.id, () => [workspaceFingerprint(run.workspace_root), crypto.createHash("sha256").update(fs.readFileSync(input.verificationFile)).digest("hex")]);
146
149
  const verification = verificationForFinish(context, { file: input.verificationFile, projectRoot: run.workspace_root, runId: run.id, runRoot: home });
147
150
  if (verification.verdict === "blocked") {
148
151
  return { ok: true, run_id: run.id, stage, outcome: "blocked", verification, instruction: "CODE remains running. Resolve the stated external or user-input blocker in this same coordinator session, update code-verification.json, then invoke this same stage finish command again." };
@@ -233,10 +236,14 @@ export async function finishVnextCode(context, input) {
233
236
  // Creating the MERGE request is part of accepting this terminal handoff.
234
237
  // Do it before materialising a terminal stage report or changing RUN state,
235
238
  // so an invalid merge policy leaves CODE safely running and retryable.
236
- const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
237
- writeReport(root, report);
239
+ let mergeRequest = null;
240
+ assertInputs();
241
+ const prepareMerge = next === "start_merge" ? prepareVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
238
242
  withStageSettlement(context, () => {
239
243
  assertRunMutationAllowed(context, project.id, run.id, generation);
244
+ assertInputs();
245
+ mergeRequest = prepareMerge?.() ?? null;
246
+ writeReport(root, report);
240
247
  completeFlowRunStage(context, {
241
248
  projectRoot,
242
249
  runId: run.id,
@@ -28,13 +28,17 @@ export function isVnextMergeRun(context, input) {
28
28
  }
29
29
  /** Terminal CODE/CODE-REVIEW calls this once; it freezes source and creates the child MERGE Work. */
30
30
  export function ensureVnextMergeRequest(context, input) {
31
+ return prepareVnextMergeRequest(context, input)();
32
+ }
33
+ /** Freeze filesystem evidence outside the stage writer lock; return only the SQL settlement. */
34
+ export function prepareVnextMergeRequest(context, input) {
31
35
  const projectRoot = resolveProjectRoot(input.projectRoot);
32
36
  const project = requireProjectByRoot(context, projectRoot);
33
37
  const run = requireRun(context, project.id, input.runId);
34
38
  assertRunMutationAllowed(context, project.id, run.id);
35
39
  const existing = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') ORDER BY created_at DESC LIMIT 1", [project.id, run.id]);
36
40
  if (existing)
37
- return requestView(context, existing);
41
+ return () => requestView(context, requireRequest(context, existing.merge_request_id));
38
42
  const settings = executionSettings(run);
39
43
  const route = settings.merge_mode ?? "same_session";
40
44
  const home = requireHome(run);
@@ -88,28 +92,19 @@ export function ensureVnextMergeRequest(context, input) {
88
92
  if (!sourceCommit)
89
93
  throw new AppError("merge_source_invalid", "MERGE could not freeze source commit", 1, { source_commit: sourceCommit, target_branch: targetBranch });
90
94
  const enqueueTarget = gitValue(targetWorkspace, ["rev-parse", targetBranch], true);
91
- const root = requireRootWork(context, project.id, run.id);
92
- let child;
93
- let requestId;
94
- const now = context.now();
95
- context.db.exec("BEGIN IMMEDIATE");
96
- try {
95
+ return () => context.db.writeTransaction(() => {
96
+ assertRunMutationAllowed(context, project.id, run.id);
97
+ const root = requireRootWork(context, project.id, run.id);
98
+ const now = context.now();
97
99
  const raced = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') ORDER BY created_at DESC LIMIT 1", [project.id, run.id]);
98
- if (raced) {
99
- context.db.exec("COMMIT");
100
+ if (raced)
100
101
  return requestView(context, raced);
101
- }
102
- child = createChildWork(context, { parentWorkId: root.work_id, slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
103
- requestId = nextMergeRequestId(context);
102
+ const child = createChildWork(context, { parentWorkId: root.work_id, slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
103
+ const requestId = nextMergeRequestId(context);
104
104
  context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, replacement_of_merge_request_id, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, replacementOf(run), now, now]);
105
- context.db.exec("COMMIT");
106
- }
107
- catch (error) {
108
- context.db.exec("ROLLBACK");
109
- throw error;
110
- }
111
- appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_request_queued", merge_request_id: requestId, work_id: child.work_id, route, source_commit: sourceCommit, target_branch: targetBranch });
112
- return requestView(context, requireRequest(context, requestId));
105
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_request_queued", merge_request_id: requestId, work_id: child.work_id, route, source_commit: sourceCommit, target_branch: targetBranch });
106
+ return requestView(context, requireRequest(context, requestId));
107
+ });
113
108
  }
114
109
  export async function startVnextMerge(context, input) {
115
110
  const projectRoot = resolveProjectRoot(input.projectRoot);
@@ -346,7 +341,7 @@ function recoverCompletedMergeRun(context, run, projectRoot, request) {
346
341
  }
347
342
  export function routeVnextMergeRequest(context, input) {
348
343
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
349
- context.db.exec("BEGIN IMMEDIATE");
344
+ context.db.beginWriteTransaction();
350
345
  let request;
351
346
  try {
352
347
  request = requireRequest(context, input.requestId);
@@ -1,6 +1,6 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
- import { withStageSettlement } from "./runs.js";
3
+ import { withStageSettlement, stageInputGuard } from "./runs.js";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { AppError } from "../shared/errors.js";
@@ -184,6 +184,7 @@ export function finishVnextPlanReview(context, input) {
184
184
  if (!parent || parent.status !== "running")
185
185
  throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
186
186
  const decisionPath = path.resolve(input.decisionFile ?? path.join(root, "decision.json"));
187
+ const assertInputs = stageInputGuard(context, project.id, run.id, () => [planSetChecksum(home, run.workspace_root), checksum(batch), checksum(decisionPath), checksum(path.join(root, "work-context.json"))]);
187
188
  const decision = readDecision(context, projectRoot, run.id, home, decisionPath);
188
189
  const children = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ?", [project.id, run.id, parent.work_id]);
189
190
  const contextFile = readJson(path.join(root, "work-context.json"));
@@ -198,7 +199,7 @@ export function finishVnextPlanReview(context, input) {
198
199
  }
199
200
  if (["failed", "cancelled"].includes(decision.outcome)) {
200
201
  preserveDecisionReceipt(root, decisionPath);
201
- return withStageSettlement(context, () => finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }));
202
+ return withStageSettlement(context, () => { assertInputs(); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
202
203
  }
203
204
  if (children.some((child) => child.status === "created" || child.status === "running"))
204
205
  throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
@@ -246,6 +247,7 @@ export function finishVnextPlanReview(context, input) {
246
247
  const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
247
248
  const finalBatchChecksum = checksum(batch);
248
249
  return withStageSettlement(context, () => {
250
+ assertInputs();
249
251
  const registered = registerCode(context, project.id, run.id, batch);
250
252
  const now = context.now();
251
253
  settleReviewParent(context, { projectRoot, projectId: project.id, runId: run.id, parentWorkId: parent.work_id, status: "completed", result: decision.summary, reason: "plan_review_completed", resultPath: path.join(root, "stage-report.json") });
@@ -52,7 +52,7 @@ export function startVnextPlan(context, input) {
52
52
  if (!context.db.get("SELECT 1 FROM work_sessions WHERE work_id = ? LIMIT 1", [rootWork.work_id])) {
53
53
  bindRunningWorkSession(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, promptPath: path.join(root, "fixture-root.prompt.md") });
54
54
  }
55
- context.db.exec("BEGIN IMMEDIATE");
55
+ context.db.beginWriteTransaction();
56
56
  let planWorkId;
57
57
  try {
58
58
  planWorkId = nextWorkId(context, project.id, "plan");