@deksden-com/dd-flow-cli 0.9.0-beta.49 → 0.9.0-beta.52

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.
@@ -15,7 +15,7 @@ import { validateSchema } from "./schema-validation.js";
15
15
  import { writeStageReport } from "./stage-report-renderer.js";
16
16
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
17
17
  import { addVnextCodeRepair } from "./vnext-code.js";
18
- import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
18
+ import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
19
19
  import { applyExternalStageContext } from "./stage-context.js";
20
20
  import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
21
21
  import { assertRunMutationAllowed } from "./run-recovery.js";
@@ -50,6 +50,7 @@ export function ensureVnextMergeRequest(context, input) {
50
50
  throw new AppError("merge_source_invalid", "MERGE could not determine the target branch", 1, { target_branch: targetBranch });
51
51
  const semantic = planChecks(run.workspace_root, protocols);
52
52
  validateMergeAcceptance(run.workspace_root, protocols, semantic);
53
+ const acceptanceRefs = mergeAcceptanceRefs(run.workspace_root, protocols);
53
54
  const effective = effectiveCheckDeclarations(targetWorkspace, semantic, ["work", "code", "readiness", "merge"]);
54
55
  if (run.workspace_root !== targetWorkspace && effective.length === 0)
55
56
  throw new AppError("merge_gate_missing", "A real source/target integration requires at least one semantic or policy merge check", 2, { run_id: run.id, protocol_ids: protocols });
@@ -59,7 +60,7 @@ export function ensureVnextMergeRequest(context, input) {
59
60
  const freezeFile = path.join(freezeRoot, "source-freeze.json");
60
61
  const gateFile = path.join(freezeRoot, "merge-gate.json");
61
62
  fs.mkdirSync(freezeRoot, { recursive: true });
62
- freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
63
+ freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, acceptanceRefs, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
63
64
  let sourceCommit;
64
65
  let sourcePaths;
65
66
  if (fs.existsSync(freezeFile)) {
@@ -179,10 +180,14 @@ function startPacket(context, run, request, binding, promptPath, resultPath, pro
179
180
  export function applyVnextMerge(context, input) {
180
181
  const existing = requireRequest(context, input.requestId);
181
182
  const receiptFile = applyReceiptPath(context, existing);
182
- if (existing.executor_work_id === input.workId && ["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(existing.checkpoint) && fs.existsSync(receiptFile))
183
- return JSON.parse(fs.readFileSync(receiptFile, "utf8"));
184
183
  const request = requireOwnedActiveRequest(context, input);
185
184
  assertRunMutationAllowed(context, request.project_id, request.run_id);
185
+ if (["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(request.checkpoint) && fs.existsSync(receiptFile)) {
186
+ const receipt = JSON.parse(fs.readFileSync(receiptFile, "utf8"));
187
+ if (receipt.merge_request_id !== request.merge_request_id || receipt.work_id !== request.executor_work_id || receipt.source_commit !== request.source_commit)
188
+ throw new AppError("merge_apply_receipt_invalid", "Existing merge apply receipt does not match the active request", 1, { receipt_path: receiptFile });
189
+ return receipt;
190
+ }
186
191
  heartbeatLaneLock(context, { projectRoot: input.projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
187
192
  assertExecutionBaseline(request);
188
193
  input.progress?.(`applying ${request.source_commit} to ${request.target_branch}`);
@@ -214,6 +219,7 @@ export async function finishVnextMerge(context, input) {
214
219
  throw new AppError("merge_request_mismatch", "MRG/Work/RUN/project identity does not match", 2);
215
220
  if (existing.status === "completed") {
216
221
  recoverCompletedMergeRun(context, run, projectRoot, existing);
222
+ releaseMergeLane(context, projectRoot, existing, `MERGE ${existing.merge_request_id} completion recovered`);
217
223
  return { ok: true, resumed: true, run_id: run.id, stage, outcome: "completed", merge_request_id: existing.merge_request_id, work_id: existing.executor_work_id, integration_commit: existing.integration_commit, report_path: path.join(requireHome(run), stageDir, "stage-report.json"), next_action: "merge_completed" };
218
224
  }
219
225
  let request = requireOwnedActiveRequest(context, input);
@@ -236,29 +242,35 @@ export async function finishVnextMerge(context, input) {
236
242
  context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
237
243
  request = requireRequest(context, request.merge_request_id);
238
244
  }
239
- const checks = effectiveMergeChecks(run, request);
245
+ const gate = readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id), checks = gate.checks;
240
246
  if (input.retryCheckId) {
241
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
247
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, reason: input.retryReason });
242
248
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
243
249
  }
244
- const unchanged = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: request.target_workspace, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
245
- if (unchanged.length)
246
- throw new AppError("merge_check_repair_required", "MERGE checks already failed for the unchanged integrated tree; choose source repair or restore the environment and retry that receipt", 2, { failures: unchanged, retry_command: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${unchanged[0].id} --reason "<environment recovery evidence>"` });
247
- const receipts = request.checkpoint === "checks_passed" || request.checkpoint === "delivered" || request.checkpoint === "finalized" ? checkReceipts(context, { projectId: project.id, runId: run.id, workId: request.executor_work_id }).filter((receipt) => receipt.status === "passed" && receipt.before_fingerprint === workspaceFingerprint(request.target_workspace)) : await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: requireHome(run), workspaceRoot: request.target_workspace, workId: request.executor_work_id, artifactDir: path.join(stageDir, "works", request.executor_work_id), scope: "aggregate", checks, ...(input.progress ? { progress: input.progress } : {}) });
250
+ const acceptancePath = path.join(requireHome(run), stageDir, "merge-gate-acceptance.json");
251
+ const recovered = readMergeGateAcceptance(context, acceptancePath, request, gate);
252
+ const resumingGate = ["checks_passed", "integration_committed", "delivered"].includes(request.checkpoint);
253
+ if (resumingGate && !recovered.valid)
254
+ return recovery(context, request, "merge_evidence_missing", { checkpoint: request.checkpoint });
255
+ if (request.checkpoint === "checks_passed" && request.accepted_tree && gitValue(request.target_workspace, ["write-tree"]) !== request.accepted_tree)
256
+ return recovery(context, request, "merge_workspace_drift_after_checks", { accepted_tree: request.accepted_tree });
257
+ const receipts = resumingGate ? recovered.receipts : await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: requireHome(run), workspaceRoot: request.target_workspace, workId: request.executor_work_id, artifactDir: path.join(stageDir, "works", request.executor_work_id), scope: "aggregate", checks, expectedProfileHash: gate.profileHash, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}), ...(input.progress ? { progress: input.progress } : {}) });
248
258
  assertRunMutationAllowed(context, project.id, run.id, generation);
249
259
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
250
260
  if (failed.length) {
251
261
  context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
252
262
  throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"` });
253
263
  }
254
- const requiredRefs = mergeAcceptanceRefs(run.workspace_root, JSON.parse(request.protocol_ids_json));
255
264
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
256
- const missingRefs = requiredRefs.filter((ref) => !passedRefs.has(ref));
265
+ const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
257
266
  if (missingRefs.length)
258
- throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every merge acceptance reference", 2, { missing_check_refs: missingRefs });
259
- const acceptedTree = gitValue(request.target_workspace, ["write-tree"]);
260
- context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', accepted_tree = ?, status = 'active', updated_at = ? WHERE merge_request_id = ?", [acceptedTree, context.now(), request.merge_request_id]);
261
- request = requireRequest(context, request.merge_request_id);
267
+ throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every frozen merge-gate reference", 2, { missing_check_refs: missingRefs });
268
+ if (!request.integration_commit) {
269
+ const acceptedTree = gitValue(request.target_workspace, ["write-tree"]);
270
+ writeMergeGateAcceptance(context, acceptancePath, request, gate, acceptedTree, receipts);
271
+ context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', accepted_tree = ?, status = 'active', updated_at = ? WHERE merge_request_id = ?", [acceptedTree, context.now(), request.merge_request_id]);
272
+ request = requireRequest(context, request.merge_request_id);
273
+ }
262
274
  if (!request.integration_commit) {
263
275
  commitIntegration(request.target_workspace, run.id, ignoredGitPaths(context, project.id));
264
276
  const commit = gitValue(request.target_workspace, ["rev-parse", "HEAD"]);
@@ -269,11 +281,20 @@ export async function finishVnextMerge(context, input) {
269
281
  context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [commit, context.now(), request.merge_request_id]);
270
282
  request = requireRequest(context, request.merge_request_id);
271
283
  }
284
+ if (request.integration_commit && request.accepted_tree && gitValue(request.target_workspace, ["rev-parse", `${request.integration_commit}^{tree}`]) !== request.accepted_tree)
285
+ return recovery(context, request, "merge_committed_tree_drift", { accepted_tree: request.accepted_tree, integration_commit: request.integration_commit });
272
286
  verifyLocalDelivery(request);
273
287
  context.db.run("UPDATE merge_requests SET checkpoint = 'delivered', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
274
288
  request = requireRequest(context, request.merge_request_id);
275
289
  performConfiguredCleanup(run, request);
276
- await finishWork(context, request.executor_work_id, fs.readFileSync(resultPath, "utf8"));
290
+ const acceptedResult = fs.readFileSync(resultPath, "utf8");
291
+ const executor = context.db.get("SELECT status, result FROM works WHERE work_id = ?", [request.executor_work_id]);
292
+ if (!executor)
293
+ return recovery(context, request, "merge_executor_missing", { work_id: request.executor_work_id });
294
+ if (executor.status === "running")
295
+ await finishWork(context, request.executor_work_id, acceptedResult);
296
+ else if (executor.status !== "completed" || executor.result !== acceptedResult)
297
+ return recovery(context, request, "merge_executor_settlement_mismatch", { work_id: request.executor_work_id, status: executor.status });
277
298
  assertRunMutationAllowed(context, project.id, run.id, generation);
278
299
  const now = context.now();
279
300
  context.db.run("UPDATE merge_requests SET status = 'completed', checkpoint = 'finalized', result_json = ?, completed_at = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify(semantic), now, now, request.merge_request_id]);
@@ -299,14 +320,24 @@ function ensureMergeStageAttached(context, run, projectRoot) {
299
320
  function recoverCompletedMergeRun(context, run, projectRoot, request) {
300
321
  const root = path.join(requireHome(run), stageDir);
301
322
  const reportPath = path.join(root, "stage-report.json");
302
- if (!fs.existsSync(reportPath))
303
- throw new AppError("merge_report_missing", "Completed MERGE request has no stage report", 1, { merge_request_id: request.merge_request_id, report_path: reportPath });
323
+ if (!fs.existsSync(reportPath)) {
324
+ const semantic = request.result_json ? JSON.parse(request.result_json) : null;
325
+ const gate = readFrozenMergeGate(path.join(root, "merge-gate.json"), request.merge_request_id);
326
+ const accepted = readMergeGateAcceptance(context, path.join(root, "merge-gate-acceptance.json"), request, gate);
327
+ if (!semantic || typeof semantic.summary !== "string" || typeof semantic.verification_summary !== "string" || !Array.isArray(semantic.residual_risks) || !semantic.residual_risks.every(item => typeof item === "string") || !accepted.valid)
328
+ throw new AppError("merge_report_missing", "Completed MERGE has insufficient authoritative evidence to rebuild its report", 1, { merge_request_id: request.merge_request_id, report_path: reportPath });
329
+ verifyLocalDelivery(request);
330
+ performConfiguredCleanup(run, request);
331
+ writeStageReport(root, mergeReport(context, run, request, semantic, accepted.receipts));
332
+ }
304
333
  ensureMergeStageAttached(context, run, projectRoot);
305
334
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@2", report: "stage-report.md", stageReport: "stage-report.html" });
306
335
  const rootWork = findRootWork(context, run.project_id, run.id);
307
336
  const rootStatus = rootWork.status;
308
337
  if (rootStatus === "running")
309
338
  finishFanInWork(context, rootWork.work_id, fs.readFileSync(reportPath, "utf8"));
339
+ else if (rootStatus !== "completed")
340
+ throw new AppError("merge_root_settlement_mismatch", "Completed MERGE cannot recover a non-completed root Work", 1, { merge_request_id: request.merge_request_id, work_id: rootWork.work_id, status: rootStatus });
310
341
  const runStatus = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id])?.status;
311
342
  if (runStatus !== "done")
312
343
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "merge_completed", nextAction: undefined });
@@ -374,21 +405,39 @@ function planChecks(workspace, protocols) { return protocols.flatMap((protocol)
374
405
  return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.checks ?? []).filter((check) => check.availability === "available").map((check) => ({ ...check, canonical_ref: `${protocol}/${check.id}` })); }); }
375
406
  function validateMergeAcceptance(workspace, protocols, checks) { const known = new Set(checks.map((check) => check.canonical_ref)); const missing = mergeAcceptanceRefs(workspace, protocols).filter((ref) => !known.has(ref)); if (missing.length)
376
407
  throw new AppError("merge_acceptance_invalid", "A merge acceptance criterion references a missing check", 2, { check_refs: missing }); }
377
- function freezeMergeGate(file, input) { const canonical = JSON.stringify(input.checks); const hash = cryptoHash(canonical); if (fs.existsSync(file)) {
408
+ function freezeMergeGate(file, input) { const canonical = JSON.stringify({ checks: input.checks, acceptance_refs: [...input.acceptanceRefs].sort() }); const hash = cryptoHash(canonical); if (fs.existsSync(file)) {
378
409
  const existing = JSON.parse(fs.readFileSync(file, "utf8"));
379
- if (existing.run_id !== input.runId || existing.checks_hash !== hash)
410
+ if (existing.run_id !== input.runId || existing.gate_hash !== hash)
380
411
  throw new AppError("merge_gate_freeze_conflict", "MERGE gate is already frozen with different checks", 2, { file });
381
412
  return;
382
- } fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/merge-gate@1", run_id: input.runId, protocols: input.protocols, checks: input.checks, checks_hash: hash, profile_hash: input.profileHash, frozen_at: input.now }, null, 2)}\n`); }
413
+ } fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/merge-gate@2", run_id: input.runId, protocols: input.protocols, checks: input.checks, acceptance_refs: [...input.acceptanceRefs].sort(), gate_hash: hash, profile_hash: input.profileHash, frozen_at: input.now }, null, 2)}\n`); }
383
414
  function readFrozenMergeGate(file, requestId) { try {
384
415
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
385
- if (value.schema_id !== "dd-flow/merge-gate@1" || !Array.isArray(value.checks) || value.checks_hash !== cryptoHash(JSON.stringify(value.checks)))
416
+ if (value.schema_id !== "dd-flow/merge-gate@2" || !Array.isArray(value.checks) || !Array.isArray(value.acceptance_refs) || !value.acceptance_refs.every(ref => typeof ref === "string") || typeof value.gate_hash !== "string" || value.gate_hash !== cryptoHash(JSON.stringify({ checks: value.checks, acceptance_refs: [...value.acceptance_refs].sort() })))
386
417
  throw new Error("invalid gate");
387
- return { checks: value.checks };
418
+ return { checks: value.checks, acceptanceRefs: [...value.acceptance_refs].sort(), profileHash: value.profile_hash ?? null, gateHash: value.gate_hash };
388
419
  }
389
420
  catch (cause) {
390
421
  throw new AppError("merge_gate_missing", "MERGE gate is missing or invalid", 1, { merge_request_id: requestId, file, cause: String(cause) });
391
422
  } }
423
+ function frozenGateRefs(gate) { return [...new Set([...gate.checks.map(check => check.canonical_ref ?? check.id), ...gate.acceptanceRefs])].sort(); }
424
+ function writeMergeGateAcceptance(context, file, request, gate, acceptedTree, receipts) { const persisted = new Map(checkReceipts(context, { projectId: request.project_id, runId: request.run_id }).map(receipt => [receipt.id, receipt])); const value = { schema_id: "dd-flow/merge-gate-acceptance@2", merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, gate_hash: gate.gateHash, profile_hash: gate.profileHash, accepted_tree: acceptedTree, receipts: receipts.map(receipt => ({ id: receipt.id, execution_refs: [...(persisted.get(receipt.id)?.check_refs ?? receipt.check_refs)].sort(), binding_refs: [...receipt.check_refs].sort(), input_hash: receipt.input_hash })) }; const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
425
+ function readMergeGateAcceptance(context, file, request, gate) { try {
426
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
427
+ if (value.schema_id !== "dd-flow/merge-gate-acceptance@2" || value.merge_request_id !== request.merge_request_id || value.run_id !== request.run_id || value.work_id !== request.executor_work_id || value.gate_hash !== gate.gateHash || value.profile_hash !== gate.profileHash || value.accepted_tree !== request.accepted_tree || !Array.isArray(value.receipts) || !value.receipts.every(receipt => receipt && typeof receipt.id === "string" && typeof receipt.input_hash === "string" && Array.isArray(receipt.execution_refs) && receipt.execution_refs.every(ref => typeof ref === "string") && Array.isArray(receipt.binding_refs) && receipt.binding_refs.every(ref => typeof ref === "string")))
428
+ return { valid: false, receipts: [] };
429
+ const all = checkReceipts(context, { projectId: request.project_id, runId: request.run_id });
430
+ const receipts = value.receipts.map(saved => all.find(receipt => receipt.id === saved.id && receipt.input_hash === saved.input_hash && JSON.stringify([...receipt.check_refs].sort()) === JSON.stringify(saved.execution_refs))).filter((receipt) => Boolean(receipt));
431
+ const bindingRefs = value.receipts.flatMap(receipt => receipt.binding_refs);
432
+ const bindings = new Set(bindingRefs);
433
+ const expected = frozenGateRefs(gate);
434
+ if (receipts.length !== value.receipts.length || bindingRefs.length !== bindings.size || !receipts.every(checkReceiptEvidenceValid) || JSON.stringify([...bindings].sort()) !== JSON.stringify(expected) || gitValue(request.target_workspace, ["write-tree"]) !== value.accepted_tree)
435
+ return { valid: false, receipts: [] };
436
+ return { valid: true, receipts: receipts.map((receipt, index) => ({ ...receipt, check_refs: value.receipts[index].binding_refs })) };
437
+ }
438
+ catch {
439
+ return { valid: false, receipts: [] };
440
+ } }
392
441
  function cryptoHash(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
393
442
  function mergeAcceptanceRefs(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
394
443
  return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.acceptance ?? []).filter((entry) => entry.gate === "merge").flatMap((entry) => (entry.check_refs ?? []).map((ref) => `${protocol}/${ref}`)); }); }
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { withStageSettlement } from "./runs.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { AppError } from "../shared/errors.js";
@@ -70,13 +71,15 @@ export function startVnextPlanReview(context, input) {
70
71
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
71
72
  bindStageCoordinatorWork(context, { workId: rootWork, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
72
73
  attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/plan-review-decision@3" });
73
- const registered = registerCode(context, project.id, run.id, batch);
74
- const report = reportFor({ run, mode: effective, requested, outcome: "off", groups: [], batchChecksum, planChecksum, code: registered, now, projectRoot, flow: flowCommand(context) });
75
- writeReport(root, report);
76
- 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" });
77
- advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_off", nextAction: "start_code" });
78
- appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_opened", mode: effective, outcome: "off" });
79
- return { ok: true, run_id: run.id, stage, outcome: "review_off", ...(externalContext ? { external_context: externalContext } : {}), plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), reason: "No applicable independent review group." }, next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
74
+ return withStageSettlement(context, () => {
75
+ const registered = registerCode(context, project.id, run.id, batch);
76
+ const report = reportFor({ run, mode: effective, requested, outcome: "off", groups: [], batchChecksum, planChecksum, code: registered, now, projectRoot, flow: flowCommand(context) });
77
+ writeReport(root, report);
78
+ 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" });
79
+ advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_off", nextAction: "start_code" });
80
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_opened", mode: effective, outcome: "off" });
81
+ return { ok: true, run_id: run.id, stage, outcome: "review_off", ...(externalContext ? { external_context: externalContext } : {}), plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), reason: "No applicable independent review group." }, next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
82
+ });
80
83
  }
81
84
  const rootWork = rootWorkId(context, project.id, run.id);
82
85
  const workId = nextWorkId(context, project.id, "plan-review");
@@ -194,7 +197,7 @@ export function finishVnextPlanReview(context, input) {
194
197
  }
195
198
  if (["failed", "cancelled"].includes(decision.outcome)) {
196
199
  preserveDecisionReceipt(root, decisionPath);
197
- return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision });
200
+ return withStageSettlement(context, () => finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }));
198
201
  }
199
202
  if (children.some((child) => child.status === "created" || child.status === "running"))
200
203
  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 })) });
@@ -241,17 +244,19 @@ export function finishVnextPlanReview(context, input) {
241
244
  preserveDecisionReceipt(root, decisionPath);
242
245
  const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
243
246
  const finalBatchChecksum = checksum(batch);
244
- const registered = registerCode(context, project.id, run.id, batch);
245
- const now = context.now();
246
- 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") });
247
- const report = reportFor({ run, mode: contextFile.system?.effective_mode ?? "standard", requested: contextFile.system?.requested_mode ?? "auto", outcome: "accepted", groups, batchChecksum: contextFile.system?.batch_checksum ?? finalBatchChecksum, planChecksum: contextFile.system?.plan_checksum ?? finalPlanChecksum, finalBatchChecksum, finalPlanChecksum, initialPlanRevision: reviewedPlanRevision, finalPlanRevision: planRevision, code: registered, now, projectRoot, decision, children: latestChildren, flow: flowCommand(context) });
248
- applyReviewResults(home, latestChildren, decision);
249
- writeReport(root, report);
250
- refreshRunWorkProjection(context, project.id, run.id);
251
- 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" });
252
- advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_accepted", nextAction: "start_code" });
253
- appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_accepted", work_id: parent.work_id, code: registered });
254
- return { ok: true, run_id: run.id, stage, outcome: "accepted", code: registered, next_action: "start_code", next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
247
+ return withStageSettlement(context, () => {
248
+ const registered = registerCode(context, project.id, run.id, batch);
249
+ const now = context.now();
250
+ 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") });
251
+ const report = reportFor({ run, mode: contextFile.system?.effective_mode ?? "standard", requested: contextFile.system?.requested_mode ?? "auto", outcome: "accepted", groups, batchChecksum: contextFile.system?.batch_checksum ?? finalBatchChecksum, planChecksum: contextFile.system?.plan_checksum ?? finalPlanChecksum, finalBatchChecksum, finalPlanChecksum, initialPlanRevision: reviewedPlanRevision, finalPlanRevision: planRevision, code: registered, now, projectRoot, decision, children: latestChildren, flow: flowCommand(context) });
252
+ applyReviewResults(home, latestChildren, decision);
253
+ writeReport(root, report);
254
+ refreshRunWorkProjection(context, project.id, run.id);
255
+ 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" });
256
+ advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_accepted", nextAction: "start_code" });
257
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_accepted", work_id: parent.work_id, code: registered });
258
+ return { ok: true, run_id: run.id, stage, outcome: "accepted", code: registered, next_action: "start_code", next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
259
+ });
255
260
  }
256
261
  function preparedExisting(context, input) {
257
262
  const promptPath = path.join(input.root, "stage-prompt.md");
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { withStageSettlement } from "./runs.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { AppError } from "../shared/errors.js";
@@ -106,6 +107,9 @@ export function startVnextPlan(context, input) {
106
107
  return { ok: true, run_id: run.id, work_id: planWorkId, id: workSessionId, artifact_materialization: artifactMaterialization, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), prompt_path: promptPath, next_command: finishCommand, ...(externalContext ? { external_context: externalContext } : {}) };
107
108
  }
108
109
  export function finishVnextPlan(context, input) {
110
+ return withStageSettlement(context, () => finishVnextPlanOwned(context, input));
111
+ }
112
+ function finishVnextPlanOwned(context, input) {
109
113
  const projectRoot = resolveProjectRoot(input.projectRoot);
110
114
  const project = requireProjectByRoot(context, projectRoot);
111
115
  const run = requireRun(context, projectRoot, input.runId);
@@ -6,7 +6,7 @@ import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageSt
6
6
  import { validateSchema } from "./schema-validation.js";
7
7
  import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
8
8
  import { refreshRunSessionProjection } from "./run-projection.js";
9
- import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
9
+ import { readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
10
10
  import { nextWorkId, nextWorkIds } from "./ids.js";
11
11
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
12
  import { resolveStageTransition } from "./execution-policy.js";
@@ -39,7 +39,7 @@ export function assertStageLifecycleOwner(context, input) {
39
39
  if (!project)
40
40
  throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
41
41
  requireRun(context, project.id, input.runId);
42
- const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
42
+ let owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
43
43
  FROM work_sessions ws
44
44
  JOIN works w ON w.work_id = ws.work_id
45
45
  JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
@@ -47,6 +47,13 @@ export function assertStageLifecycleOwner(context, input) {
47
47
  AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
48
48
  ${input.workId ? "AND w.work_id = ?" : ""}
49
49
  ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
50
+ if (!owner && input.operation === "stage_finish" && ["code", "code-review"].includes(input.stage)) {
51
+ const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
52
+ const index = row ? JSON.parse(row.index_json) : null;
53
+ const completed = index?.stage_runs.find(entry => entry.stage === input.stage && entry.status === "done");
54
+ if (row && completed && (index?.current_stage ?? index?.stage_runs.at(-1)?.stage) === input.stage)
55
+ owner = context.db.get("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NULL AND w.status = 'completed' AND ws.status = 'completed' AND ws.result_path = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, input.runId, path.join(row.run_root, completed.dir, "stage-report.json")]);
56
+ }
50
57
  if (!owner)
51
58
  throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
52
59
  const identity = claimStageLifecycleHookEvent(context, {
@@ -135,7 +142,7 @@ export function addWorkBatch(context, input) {
135
142
  if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
136
143
  throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
137
144
  const now = context.now();
138
- context.db.exec("BEGIN IMMEDIATE");
145
+ context.db.exec("SAVEPOINT work_batch");
139
146
  try {
140
147
  const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
141
148
  const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
@@ -146,14 +153,15 @@ export function addWorkBatch(context, input) {
146
153
  for (const item of proposed)
147
154
  context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, started_at, created_at, updated_at, completed_at)
148
155
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'created', NULL, NULL, ?, ?, NULL)`, [item.id, parent.project_id, parent.run_id, item.parentId, item.task, item.launch_policy ?? "reuse_allowed", item.result_schema ?? null, item.payload ? JSON.stringify(item.payload) : null, JSON.stringify(item.dependencies), now, now]);
149
- context.db.exec("COMMIT");
150
156
  for (const item of proposed)
151
157
  appendFlowRunTimelineEvent(context, parent.project_id, parent.run_id, { type: "work_materialized", work_id: item.id, parent_work_id: item.parentId, depends_on: item.dependencies, launch_policy: item.launch_policy ?? "reuse_allowed" });
152
158
  refreshRunWorkProjection(context, parent.project_id, parent.run_id);
159
+ context.db.exec("RELEASE work_batch");
153
160
  return { ok: true, work_ids: Object.fromEntries(ids) };
154
161
  }
155
162
  catch (error) {
156
- context.db.exec("ROLLBACK");
163
+ context.db.exec("ROLLBACK TO work_batch");
164
+ context.db.exec("RELEASE work_batch");
157
165
  throw error;
158
166
  }
159
167
  }
@@ -496,7 +504,21 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
496
504
  refreshRunWorkProjection(context, work.project_id, work.run_id);
497
505
  return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
498
506
  }
499
- export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
507
+ /** Root coordinators have no worker checks: settle them inside the stage transaction. */
508
+ export function finishStageCoordinatorWork(context, id, result) {
509
+ const work = requireWork(context, id);
510
+ if (work.parent_work_id !== null || codePacket(work) || parsePayload(work)?.read_only)
511
+ throw new AppError("invalid_work_state", "Stage settlement requires a root coordinator", 2);
512
+ const run = requireRun(context, work.project_id, work.run_id);
513
+ const link = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
514
+ if (!link)
515
+ throw new AppError("runtime_missing", "Coordinator has no open Work/Session link", 1);
516
+ validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link.result_path);
517
+ finishFanInWork(context, id, result);
518
+ context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ? AND NOT EXISTS (SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running')", [context.now(), work.project_id, link.session_id, link.session_id]);
519
+ settleRecoveryWork(context, id, "completed");
520
+ }
521
+ export function finishWork(context, id, result, progress, retry) { return settle(context, id, "completed", result, progress, retry); }
500
522
  /** Structured fan-in closes a parent after its Session was handed to a child. */
501
523
  export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
502
524
  throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
@@ -571,7 +593,7 @@ export function retryWork(context, id, reason) {
571
593
  refreshRunWorkProjection(context, work.project_id, work.run_id);
572
594
  return { ok: true, work_id: work.work_id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason };
573
595
  }
574
- async function settle(context, id, status, result, progress) {
596
+ async function settle(context, id, status, result, progress, retry) {
575
597
  const work = requireWork(context, id);
576
598
  assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
577
599
  id = work.work_id;
@@ -605,7 +627,9 @@ async function settle(context, id, status, result, progress) {
605
627
  const requiredChecks = packet.repair?.verification_check_refs?.length
606
628
  ? uniqueChecks([...packet.checks.filter((check) => check.run_at === "work"), ...packet.repair.verification_check_refs])
607
629
  : packet.checks.filter((check) => check.run_at === "work");
608
- receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: requiredChecks, ...(progress ? { progress } : {}) });
630
+ if (retry)
631
+ validateFailedCheckRetry(context, { projectId: work.project_id, runId: work.run_id, receiptId: retry.receiptId, declarations: requiredChecks, reason: retry.reason });
632
+ receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: requiredChecks, ...(retry ? { retryReceiptId: retry.receiptId, retryReason: retry.reason } : {}), ...(progress ? { progress } : {}) });
609
633
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
610
634
  if (failed.length)
611
635
  throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
@@ -3,33 +3,49 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
5
  import { assertRunMutationAllowed } from "./run-recovery.js";
6
- import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
6
+ import { codeExecutionEnvironment, reconcileWorkspaceOwnership, runCheck, workspaceOperationId } from "./code-checks.js";
7
7
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
8
8
  /** CODE and MERGE prepare their own checkout through the same managed executor.
9
9
  * Bootstrap is a project operation, not an alias classified by the engine as a test. */
10
10
  export async function runWorkspaceBootstrap(context, input) {
11
- const operationId = `bootstrap:${crypto.createHash("sha256").update(JSON.stringify([input.runId, input.workspaceRoot, input.artifactDir, input.command])).digest("hex")}`;
11
+ reconcileWorkspaceOwnership(context, input.workspaceRoot);
12
+ const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
13
+ const operationId = workspaceOperationId(input.workspaceRoot);
14
+ const identity = crypto.createHash("sha256").update(JSON.stringify([input.projectId, input.runId, generation, path.resolve(input.workspaceRoot), input.command])).digest("hex");
12
15
  const pending = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
13
16
  if (pending) {
17
+ if (pending.kind !== "workspace-bootstrap")
18
+ throw new AppError("bootstrap_in_progress", "A check currently owns this workspace; wait for it before bootstrapping", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
14
19
  if (processTreeIsAlive(pending) || (pending.state === "starting" && managedOwnerIsAlive(pending) !== false)) {
15
20
  throw new AppError("bootstrap_in_progress", "Workspace preparation is still running; inspect its logs and wait for the existing process, do not launch another copy", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
16
21
  }
17
- const completionPath = path.join(path.dirname(pending.stdout_path), "completion.json");
18
- let completion = null;
22
+ let pendingIdentity = null;
19
23
  try {
20
- completion = JSON.parse(fs.readFileSync(completionPath, "utf8"));
24
+ const metadata = JSON.parse(pending.metadata_json);
25
+ pendingIdentity = typeof metadata.bootstrap_identity === "string" ? metadata.bootstrap_identity : null;
26
+ }
27
+ catch { /* Legacy records have no usable identity. */ }
28
+ if (pendingIdentity !== identity) {
29
+ finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: "failed", reason: "bootstrap_identity_superseded" });
30
+ }
31
+ else {
32
+ const completionPath = path.join(path.dirname(pending.stdout_path), "completion.json");
33
+ let completion = null;
34
+ try {
35
+ completion = JSON.parse(fs.readFileSync(completionPath, "utf8"));
36
+ }
37
+ catch { /* Interrupted shell has no terminal marker. */ }
38
+ const exitCode = typeof completion?.exit_code === "number" ? completion.exit_code : null;
39
+ const error = exitCode === null ? "bootstrap ended without a completion marker; inspect retained logs before retrying" : null;
40
+ finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
41
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
42
+ return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
21
43
  }
22
- catch { /* Interrupted shell has no terminal marker. */ }
23
- const exitCode = typeof completion?.exit_code === "number" ? completion.exit_code : null;
24
- const error = exitCode === null ? "bootstrap ended without a completion marker; inspect retained logs before retrying" : null;
25
- finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
26
- return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
27
44
  }
28
- const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
29
45
  const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
30
46
  fs.mkdirSync(directory, { recursive: true });
31
47
  const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
32
- const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: operationId, ownerPid: process.pid, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
48
+ const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: `${operationId}:${identity}`, ownerPid: process.pid, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot, bootstrap_identity: identity } });
33
49
  const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
34
50
  try {
35
51
  try {
@@ -44,6 +60,7 @@ export async function runWorkspaceBootstrap(context, input) {
44
60
  const current = managedProcessStatus(context).find(item => item.id === managed.id);
45
61
  if (processTreeIsAlive(current))
46
62
  throw new AppError("bootstrap_tree_not_settled", "Bootstrap shell exited but still owns live processes; resources were retained for inspection", 2, { process_id: managed.id, stdout_path: stdoutPath, stderr_path: stderrPath });
63
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
47
64
  finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: completed.exitCode === 0 && !completed.error ? "stopped" : "failed", reason: completed.error });
48
65
  return result(managed.id, stdoutPath, stderrPath, completionPath, completed.exitCode, completed.error);
49
66
  }
@@ -176,6 +176,9 @@ export function ensureVnextWorkStorage(db) {
176
176
  command TEXT NOT NULL,
177
177
  input_hash TEXT NOT NULL DEFAULT '',
178
178
  verification_epoch TEXT NOT NULL DEFAULT '',
179
+ reservation_sequence INTEGER NOT NULL DEFAULT 0,
180
+ retry_of_receipt_id TEXT,
181
+ retry_reason TEXT,
179
182
  status TEXT NOT NULL,
180
183
  exit_code INTEGER,
181
184
  stdout_path TEXT NOT NULL,
@@ -1030,6 +1033,9 @@ function migrate(db) {
1030
1033
  ensureColumn(db, "check_receipts", "gate", "ALTER TABLE check_receipts ADD COLUMN gate TEXT NOT NULL DEFAULT 'work'");
1031
1034
  ensureColumn(db, "check_receipts", "input_hash", "ALTER TABLE check_receipts ADD COLUMN input_hash TEXT NOT NULL DEFAULT ''");
1032
1035
  ensureColumn(db, "check_receipts", "verification_epoch", "ALTER TABLE check_receipts ADD COLUMN verification_epoch TEXT NOT NULL DEFAULT ''");
1036
+ ensureColumn(db, "check_receipts", "reservation_sequence", "ALTER TABLE check_receipts ADD COLUMN reservation_sequence INTEGER NOT NULL DEFAULT 0");
1037
+ ensureColumn(db, "check_receipts", "retry_of_receipt_id", "ALTER TABLE check_receipts ADD COLUMN retry_of_receipt_id TEXT");
1038
+ ensureColumn(db, "check_receipts", "retry_reason", "ALTER TABLE check_receipts ADD COLUMN retry_reason TEXT");
1033
1039
  ensureColumn(db, "check_receipts", "before_fingerprint", "ALTER TABLE check_receipts ADD COLUMN before_fingerprint TEXT NOT NULL DEFAULT ''");
1034
1040
  ensureColumn(db, "check_receipts", "after_fingerprint", "ALTER TABLE check_receipts ADD COLUMN after_fingerprint TEXT NOT NULL DEFAULT ''");
1035
1041
  ensureColumn(db, "check_receipts", "profile_hash", "ALTER TABLE check_receipts ADD COLUMN profile_hash TEXT");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.49",
3
+ "version": "0.9.0-beta.52",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {