@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
@@ -91,15 +91,17 @@ export function assertWorkLifecycleOwner(context, input) {
91
91
  throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
92
92
  }
93
93
  export function createChildWork(context, input) {
94
- const parent = requireWork(context, input.parentWorkId);
95
- assertRunMutationAllowed(context, parent.project_id, parent.run_id);
96
- if (parent.status !== "running")
97
- throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
98
- const id = nextWorkId(context, parent.project_id, input.slug);
99
- const now = context.now();
100
- context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
101
- refreshRunWorkProjection(context, parent.project_id, parent.run_id);
102
- return requireWork(context, id);
94
+ return context.db.writeTransaction(() => {
95
+ const parent = requireWork(context, input.parentWorkId);
96
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
97
+ if (parent.status !== "running")
98
+ throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
99
+ const id = nextWorkId(context, parent.project_id, input.slug);
100
+ const now = context.now();
101
+ context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
102
+ refreshRunWorkProjection(context, parent.project_id, parent.run_id);
103
+ return requireWork(context, id);
104
+ });
103
105
  }
104
106
  /** Validate a proposed batch before PLAN accepts it, without registering Work. */
105
107
  export function validateWorkBatchFile(file) {
@@ -122,10 +124,6 @@ export function validateWorkBatchFile(file) {
122
124
  }
123
125
  export function addWorkBatch(context, input) {
124
126
  ensureWorkRegistry(context);
125
- const parent = requireWork(context, input.parentWorkId);
126
- assertRunMutationAllowed(context, parent.project_id, parent.run_id);
127
- if (parent.status !== "running")
128
- throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
129
127
  const parsed = readJson(input.file);
130
128
  if (!Array.isArray(parsed.works) || parsed.works.length === 0)
131
129
  throw new AppError("validation", "work batch requires non-empty works", 2);
@@ -133,18 +131,21 @@ export function addWorkBatch(context, input) {
133
131
  const keys = new Set(items.map((item) => item.key));
134
132
  if (keys.size !== items.length)
135
133
  throw new AppError("validation", "work batch keys must be unique", 2);
136
- const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
137
- const existingIds = new Set(existing.map((row) => row.work_id));
138
- for (const item of items)
139
- for (const dependency of item.depends_on ?? [])
140
- if (!keys.has(dependency) && !existingIds.has(dependency))
141
- throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
142
- for (const item of items)
143
- if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
144
- throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
145
- const now = context.now();
146
- context.db.exec("SAVEPOINT work_batch");
147
- try {
134
+ return context.db.writeTransaction(() => {
135
+ const parent = requireWork(context, input.parentWorkId);
136
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
137
+ if (parent.status !== "running")
138
+ throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
139
+ const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
140
+ const existingIds = new Set(existing.map((row) => row.work_id));
141
+ for (const item of items)
142
+ for (const dependency of item.depends_on ?? [])
143
+ if (!keys.has(dependency) && !existingIds.has(dependency))
144
+ throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
145
+ for (const item of items)
146
+ if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
147
+ throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
148
+ const now = context.now();
148
149
  const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
149
150
  const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
150
151
  const resolve = (value) => ids.get(value) ?? value;
@@ -157,14 +158,8 @@ export function addWorkBatch(context, input) {
157
158
  for (const item of proposed)
158
159
  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" });
159
160
  refreshRunWorkProjection(context, parent.project_id, parent.run_id);
160
- context.db.exec("RELEASE work_batch");
161
161
  return { ok: true, work_ids: Object.fromEntries(ids) };
162
- }
163
- catch (error) {
164
- context.db.exec("ROLLBACK TO work_batch");
165
- context.db.exec("RELEASE work_batch");
166
- throw error;
167
- }
162
+ });
168
163
  }
169
164
  export function listWorks(context, input) {
170
165
  ensureWorkRegistry(context);
@@ -216,6 +211,7 @@ function receiptIntentPath(resultPath, sessionId) {
216
211
  function receiptFileHash(file) {
217
212
  return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
218
213
  }
214
+ function sha256(contents) { return crypto.createHash("sha256").update(contents).digest("hex"); }
219
215
  function publishReceiptFile(file, contents) {
220
216
  const temporary = `${file}.tmp-${crypto.randomUUID()}`;
221
217
  try {
@@ -240,9 +236,44 @@ function publishReceiptFile(file, contents) {
240
236
  fs.rmSync(temporary, { force: true });
241
237
  }
242
238
  }
239
+ /** Rebuild prompt/context projections from a committed Work-start receipt. */
240
+ export function materializePendingWorkStarts(context, projectId, runId) {
241
+ const runHome = requireRunHome(requireRun(context, projectId, runId));
242
+ context.db.writeTransaction(() => {
243
+ const rows = context.db.all("SELECT ws.id, ws.work_id, ws.prompt_path, ws.start_receipt_json FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' AND ws.start_receipt_json IS NOT NULL", [projectId, runId]);
244
+ for (const row of rows) {
245
+ // The same coordinator Session can move on to a different stage packet.
246
+ // A start receipt is not authority to overwrite that later packet.
247
+ let receipt;
248
+ try {
249
+ receipt = JSON.parse(row.start_receipt_json);
250
+ }
251
+ catch {
252
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt is not valid JSON", 1, { work_id: row.work_id });
253
+ }
254
+ const originalPromptPath = path.join(runHome, "works", row.work_id, ...(receipt.generation && receipt.generation > 0 ? ["starts", row.id] : []), "prompt.md");
255
+ if (path.resolve(row.prompt_path) !== path.resolve(originalPromptPath))
256
+ continue;
257
+ if (receipt.version !== 1 || typeof receipt.prompt !== "string" || typeof receipt.context !== "string" || receipt.prompt_sha256 !== sha256(receipt.prompt) || receipt.context_sha256 !== sha256(receipt.context))
258
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt does not match its retained bytes", 1, { work_id: row.work_id });
259
+ publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
260
+ publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
261
+ }
262
+ });
263
+ }
264
+ function publishStartReceiptFile(file, contents, expectedHash) {
265
+ const actual = receiptFileHash(file);
266
+ if (actual === expectedHash)
267
+ return;
268
+ if (actual !== null) {
269
+ throw new AppError("work_start_projection_conflict", "Work start packet differs from its committed receipt; preserve it for recovery", 1, { path: file, expected_sha256: expectedHash, actual_sha256: actual });
270
+ }
271
+ fs.mkdirSync(path.dirname(file), { recursive: true });
272
+ publishReceiptFile(file, contents);
273
+ }
243
274
  /** Replay only a prepared publication whose exact receipt was committed in SQLite. */
244
275
  export function materializePendingWorkResults(context, projectId, runId) {
245
- context.db.exec("BEGIN IMMEDIATE");
276
+ context.db.beginWriteTransaction();
246
277
  try {
247
278
  const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id AND ws.status = w.status WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NOT NULL AND w.result IS NOT NULL AND w.status IN ('completed', 'failed', 'cancelled') AND ws.result_path IS NOT NULL`, [projectId, runId]);
248
279
  for (const row of rows) {
@@ -280,33 +311,37 @@ export function materializePendingWorkResults(context, projectId, runId) {
280
311
  }
281
312
  }
282
313
  export function mutateWorkDeps(context, input) {
283
- const work = requireWork(context, input.workId);
284
- if (input.action === "list")
314
+ if (input.action === "list") {
315
+ const work = requireWork(context, input.workId);
285
316
  return { work_id: work.work_id, depends_on: parseDependencies(work) };
286
- assertRunMutationAllowed(context, work.project_id, work.run_id);
287
- if (work.status !== "created")
288
- throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
289
- const dependencies = new Set(parseDependencies(work));
290
- if (input.action === "clear")
291
- dependencies.clear();
292
- for (const id of input.on ?? []) {
293
- const dependency = requireWork(context, id);
294
- if (dependency.run_id !== work.run_id)
295
- throw new AppError("validation", "Dependencies must stay in one RUN", 2);
296
- if (input.action === "add")
297
- dependencies.add(dependency.work_id);
298
- else
299
- dependencies.delete(dependency.work_id);
300
- }
301
- if (dependencies.has(work.work_id))
302
- throw new AppError("validation", "Work cannot depend on itself", 2);
303
- assertNoCycles(context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [work.project_id, work.run_id]).map((row) => ({ id: row.work_id, dependencies: row.work_id === work.work_id ? [...dependencies] : parseDependencies(row) })));
304
- context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify([...dependencies]), context.now(), work.work_id]);
305
- refreshRunWorkProjection(context, work.project_id, work.run_id);
306
- return { work_id: work.work_id, depends_on: [...dependencies] };
317
+ }
318
+ return context.db.writeTransaction(() => {
319
+ const work = requireWork(context, input.workId);
320
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
321
+ if (work.status !== "created")
322
+ throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
323
+ const dependencies = new Set(parseDependencies(work));
324
+ if (input.action === "clear")
325
+ dependencies.clear();
326
+ for (const id of input.on ?? []) {
327
+ const dependency = requireWork(context, id);
328
+ if (dependency.run_id !== work.run_id)
329
+ throw new AppError("validation", "Dependencies must stay in one RUN", 2);
330
+ if (input.action === "add")
331
+ dependencies.add(dependency.work_id);
332
+ else
333
+ dependencies.delete(dependency.work_id);
334
+ }
335
+ if (dependencies.has(work.work_id))
336
+ throw new AppError("validation", "Work cannot depend on itself", 2);
337
+ assertNoCycles(context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [work.project_id, work.run_id]).map((row) => ({ id: row.work_id, dependencies: row.work_id === work.work_id ? [...dependencies] : parseDependencies(row) })));
338
+ context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify([...dependencies]), context.now(), work.work_id]);
339
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
340
+ return { work_id: work.work_id, depends_on: [...dependencies] };
341
+ });
307
342
  }
308
- export function deleteWork(context, id) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
309
- throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
343
+ export function deleteWork(context, id) { return context.db.writeTransaction(() => { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
344
+ throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }); }
310
345
  export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
311
346
  /**
312
347
  * Return the engine-bound command, never a PATH-dependent `dd-flow` token.
@@ -329,6 +364,15 @@ export function assertWorkLaunchReady(context, id) {
329
364
  return work;
330
365
  }
331
366
  export function startWork(context, id, input) {
367
+ if (input.hookEventId && !context.env.DD_FLOW_CURRENT_INVOCATION) {
368
+ const retainedWork = requireWork(context, id);
369
+ const retainedRun = requireRun(context, retainedWork.project_id, retainedWork.run_id);
370
+ if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(retainedRun.project_root))
371
+ throw new AppError("project_mismatch", "Work start belongs to another project", 1);
372
+ const reply = recoverCommittedWorkStart(context, null, input.hookEventId, recoveryGuard(context, retainedWork.project_id, retainedWork.run_id)?.generation ?? 0, retainedWork.work_id);
373
+ if (reply)
374
+ return reply;
375
+ }
332
376
  const work = assertWorkLaunchReady(context, id);
333
377
  const run = requireRun(context, work.project_id, work.run_id);
334
378
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
@@ -345,16 +389,9 @@ export function startWork(context, id, input) {
345
389
  hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
346
390
  }
347
391
  }
348
- const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) });
349
- let started;
350
- try {
351
- started = startBoundWork(context, work, run, identity, hookEventId);
352
- }
353
- catch (error) {
354
- context.db.run("UPDATE hook_events SET status = 'observed', claimed_at = NULL WHERE project_id = ? AND event_key = ? AND status = 'claimed'", [work.project_id, hookEventId]);
355
- throw error;
356
- }
357
- appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
392
+ const eventKey = hookEventId;
393
+ const started = startBoundWork(context, work, run, () => claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) }), eventKey);
394
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, work_session_id: started.work_session_id });
358
395
  return started;
359
396
  }
360
397
  /** Binds the already-running coordinator of a stage to its trusted hook Session. */
@@ -378,7 +415,7 @@ export function bindRunningWorkSession(context, input) {
378
415
  throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
379
416
  const now = context.now();
380
417
  const id = `WS-${crypto.randomUUID()}`;
381
- context.db.exec("BEGIN IMMEDIATE");
418
+ context.db.beginWriteTransaction();
382
419
  try {
383
420
  context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
384
421
  const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
@@ -400,7 +437,7 @@ export function bindRunningWorkSession(context, input) {
400
437
  }
401
438
  const now = context.now();
402
439
  const id = `WS-${crypto.randomUUID()}`;
403
- context.db.exec("BEGIN IMMEDIATE");
440
+ context.db.beginWriteTransaction();
404
441
  try {
405
442
  bindSession(context, work, run, identity, now);
406
443
  context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
@@ -445,65 +482,105 @@ export function startStageCoordinatorWork(context, input) {
445
482
  const run = requireRun(context, work.project_id, work.run_id);
446
483
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
447
484
  throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
448
- const identity = claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
449
- const binding = startBoundWork(context, work, run, identity, input.hookEventId);
450
- const sessionId = String(binding.session_binding.session_id ?? "");
451
- context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
485
+ const binding = startBoundWork(context, work, run, () => claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) }), input.hookEventId, input.stage);
452
486
  refreshRunSessionProjection(context, work.project_id, work.run_id);
453
487
  return binding;
454
488
  }
455
- /** Stage entry points call this only after their own trusted hook claim. */
456
- export function startBoundWork(context, work, run, identity, hookEventId) {
457
- assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
489
+ /** Claim, command authority and binding commit together; only packet projection follows. */
490
+ export function startBoundWork(context, work, run, claimIdentity, hookEventId, stage) {
458
491
  const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
459
- if (work.status !== "created")
460
- throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
461
- if (fs.existsSync(path.join(requireRunHome(run), "works", work.work_id, ".retry.json")))
462
- throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
463
- const now = context.now();
464
- const linkId = `WS-${crypto.randomUUID()}`;
465
492
  const directory = path.join(requireRunHome(run), "works", work.work_id);
466
- fs.mkdirSync(directory, { recursive: true });
467
- const promptPath = path.join(directory, "prompt.md");
468
- const resultPath = path.join(directory, "result.json");
469
- const contextPath = path.join(directory, "context.json");
470
- const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
471
- const recovery = recoveryPromptForWork(context, work.work_id);
472
- let prompt = renderWorkerPrompt(context, work, run, dependencyResults);
473
- if (recovery)
474
- prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
475
- const payload = parsePayload(work);
476
- const readOnly = payload?.read_only === true;
477
- const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
478
- const token = crypto.randomUUID();
479
- const promptCandidate = `${promptPath}.${token}.tmp`;
480
- const contextCandidate = `${contextPath}.${token}.tmp`;
481
- fs.writeFileSync(promptCandidate, prompt);
482
- fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
483
- context.db.exec("BEGIN IMMEDIATE");
493
+ const linkId = `WS-${crypto.randomUUID()}`;
494
+ // Recovery can reopen the same Work without retiring its historical packet.
495
+ // Give each new-generation start its own immutable publication destination.
496
+ const packetDirectory = generation > 0 ? path.join(directory, "starts", linkId) : directory;
497
+ const promptPath = path.join(packetDirectory, "prompt.md"), resultPath = path.join(directory, "result.json");
498
+ // The expensive filesystem observation is not performed under the writer lock.
499
+ const readOnly = parsePayload(work)?.read_only === true;
500
+ const fingerprint = readOnly ? workspaceFingerprint(run.workspace_root) : undefined;
501
+ let binding;
484
502
  try {
485
- assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
486
- bindSession(context, work, run, identity, now);
487
- if (work.parent_work_id && work.launch_policy === "reuse_allowed")
488
- context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, work.work_id]);
489
- const claimed = context.db.run("UPDATE works SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, work.work_id]);
490
- if (claimed.changes !== 1)
491
- throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
492
- context.db.run(`INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)`, [linkId, work.work_id, identity.sessionId, hookEventId, promptPath, resultPath, now, now]);
493
- fs.renameSync(promptCandidate, promptPath);
494
- fs.renameSync(contextCandidate, contextPath);
495
- if (recovery)
496
- activateRecoveryWork(context, work.work_id);
497
- context.db.exec("COMMIT");
503
+ binding = context.db.writeTransaction(() => {
504
+ const current = requireWork(context, work.work_id);
505
+ assertRunMutationAllowed(context, current.project_id, current.run_id, generation);
506
+ if (current.status !== "created")
507
+ throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id, status: current.status });
508
+ if (Object.keys(current).some(key => current[key] !== work[key]))
509
+ throw new AppError("work_not_ready", "Work inputs changed while preparing its start packet", 2, { work_id: current.work_id });
510
+ if (!isReady(context, current))
511
+ throw new AppError("work_not_ready", "Work dependencies are not ready", 2, { work_id: current.work_id, blockers: readinessBlockers(context, current) });
512
+ if (fs.existsSync(path.join(directory, ".retry.json")))
513
+ throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
514
+ const identity = claimIdentity();
515
+ assertRecoveryHookCurrent(context, current.project_id, current.run_id, identity);
516
+ const dependencyResults = parseDependencies(current).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
517
+ const recovery = recoveryPromptForWork(context, current.work_id);
518
+ let prompt = renderWorkerPrompt(context, current, run, dependencyResults);
519
+ if (recovery)
520
+ prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
521
+ const workContext = { schema_id: "dd-flow/work-context@1", run_id: current.run_id, work_id: current.work_id, parent_work_id: current.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(current), launch_policy: current.launch_policy, result_schema: current.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: fingerprint } : {}) };
522
+ const contextBytes = `${JSON.stringify(workContext, null, 2)}\n`;
523
+ const response = { ok: true, work_id: current.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 } } };
524
+ const receipt = JSON.stringify({ version: 1, prompt, context: contextBytes, prompt_sha256: sha256(prompt), context_sha256: sha256(contextBytes), response, response_sha256: sha256(JSON.stringify(response)), generation, invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null });
525
+ const now = context.now();
526
+ bindSession(context, current, run, identity, now);
527
+ if (current.parent_work_id && current.launch_policy === "reuse_allowed")
528
+ context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, current.work_id]);
529
+ if (context.db.run("UPDATE works SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, current.work_id]).changes !== 1)
530
+ throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id });
531
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, start_receipt_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, NULL)", [linkId, current.work_id, identity.sessionId, hookEventId, promptPath, resultPath, receipt, now, now]);
532
+ if (stage)
533
+ context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [stage, now, current.project_id, identity.sessionId]);
534
+ if (recovery)
535
+ activateRecoveryWork(context, current.work_id);
536
+ return response;
537
+ });
498
538
  }
499
539
  catch (error) {
500
- context.db.exec("ROLLBACK");
501
- fs.rmSync(promptCandidate, { force: true });
502
- fs.rmSync(contextCandidate, { force: true });
540
+ // This transaction publishes only SQL authority, never files or provider calls.
541
+ if (error instanceof AppError && error.code === "storage_write_failed" && error.details.transaction_rolled_back === true)
542
+ error.details.effect = "no_effect";
543
+ if (error instanceof AppError && error.details.effect === "no_effect" && !context.env.DD_FLOW_CURRENT_INVOCATION) {
544
+ let retry = false;
545
+ try {
546
+ retry = context.db.writeTransaction(() => {
547
+ if (context.db.get("SELECT 1 FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.work_id') = ? AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.storage_retry') = 1", [work.project_id, work.work_id, generation]))
548
+ return false;
549
+ return context.db.run("UPDATE hook_events SET outcome_json = ? WHERE project_id = ? AND event_key = ? AND status = 'observed' AND outcome_json IS NULL", [JSON.stringify({ work_id: work.work_id, run_id: work.run_id, generation, effect: "no_effect", disposition: "retrying", storage_retry: 1, deadline: Date.now() + 60000 }), work.project_id, hookEventId]).changes === 1;
550
+ });
551
+ }
552
+ catch (persistence) {
553
+ error.details.retry_persistence_error = String(persistence);
554
+ }
555
+ if (retry)
556
+ return startBoundWork(context, work, run, claimIdentity, hookEventId, stage);
557
+ }
503
558
  throw error;
504
559
  }
505
- refreshRunWorkProjection(context, work.project_id, work.run_id);
506
- 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 } } };
560
+ try {
561
+ materializePendingWorkStarts(context, work.project_id, work.run_id);
562
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
563
+ }
564
+ catch (error) {
565
+ if (error instanceof AppError) {
566
+ error.details.effect = "committed";
567
+ throw error;
568
+ }
569
+ throw new AppError("work_start_publication_failed", error instanceof Error ? error.message : String(error), 1, { effect: "committed", work_id: work.work_id, hook_event_id: hookEventId, cause: { code: error?.code ?? null } });
570
+ }
571
+ return binding;
572
+ }
573
+ /** Read the exact committed reply, never bind another Session or regenerate a packet. */
574
+ export function recoverCommittedWorkStart(context, invocationId, eventKey, generation, workId) {
575
+ const row = context.db.get("SELECT ws.start_receipt_json, w.project_id, w.run_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE ws.start_receipt_json IS NOT NULL AND ws.hook_event_id = ? AND json_extract(ws.start_receipt_json, '$.invocation_id') IS ? AND (? IS NULL OR w.work_id = ?)", [eventKey, invocationId, workId ?? null, workId ?? null]);
576
+ if (!row)
577
+ return undefined;
578
+ const receipt = JSON.parse(row.start_receipt_json);
579
+ if (receipt.generation !== generation || !receipt.response || receipt.response_sha256 !== sha256(JSON.stringify(receipt.response)))
580
+ throw new AppError("work_start_receipt_invalid", "Committed reply identity or checksum differs", 1, { invocation_id: invocationId });
581
+ assertRunMutationAllowed(context, row.project_id, row.run_id, generation);
582
+ materializePendingWorkStarts(context, row.project_id, row.run_id);
583
+ return receipt.response;
507
584
  }
508
585
  /** Root coordinators have no worker checks: settle them inside the stage transaction. */
509
586
  export function finishStageCoordinatorWork(context, id, result) {
@@ -532,7 +609,7 @@ export function retryWork(context, id, reason) {
532
609
  const directory = path.join(requireRunHome(run), "works", work.work_id);
533
610
  const journal = path.join(directory, ".retry.json");
534
611
  let archive = "";
535
- context.db.exec("BEGIN IMMEDIATE");
612
+ context.db.beginWriteTransaction();
536
613
  try {
537
614
  assertRunMutationAllowed(context, work.project_id, work.run_id);
538
615
  const current = requireWork(context, work.work_id);
@@ -637,7 +714,7 @@ async function settle(context, id, status, result, progress, retry) {
637
714
  }
638
715
  }
639
716
  const now = context.now();
640
- context.db.exec("BEGIN IMMEDIATE");
717
+ context.db.beginWriteTransaction();
641
718
  try {
642
719
  assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
643
720
  if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
@@ -147,6 +147,7 @@ export function ensureVnextWorkStorage(db) {
147
147
  status TEXT NOT NULL,
148
148
  prompt_path TEXT NOT NULL,
149
149
  result_path TEXT,
150
+ start_receipt_json TEXT,
150
151
  created_at TEXT NOT NULL,
151
152
  updated_at TEXT NOT NULL,
152
153
  completed_at TEXT,
@@ -196,6 +197,13 @@ export function ensureVnextWorkStorage(db) {
196
197
  );
197
198
  CREATE INDEX IF NOT EXISTS idx_check_receipts_run ON check_receipts(project_id, run_id, started_at);
198
199
  `);
200
+ // A committed Work start is authoritative even if publishing its prompt
201
+ // files is interrupted after commit. Keep the exact bytes for repair.
202
+ db.writeTransaction(() => {
203
+ const columns = db.all("PRAGMA table_info(work_sessions)");
204
+ if (!columns.some((column) => column.name === "start_receipt_json"))
205
+ db.execSchema("ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
206
+ });
199
207
  }
200
208
  /** Durable intent for a provider-root external Work. It is deliberately
201
209
  * separate from `work_sessions`: only a trusted hook may bind that Session. */
@@ -265,16 +273,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
265
273
  if (mode === "read_existing")
266
274
  db.exec("PRAGMA query_only = ON");
267
275
  db.exec("PRAGMA foreign_keys = ON");
268
- return {
269
- path: dbPath,
270
- writable: mode !== "read_existing",
271
- close: () => db.close?.(),
272
- exec: (sql) => db.exec(sql),
273
- execSchema: (sql) => executeStoreSchema(db, sql),
274
- run: (sql, params = []) => db.prepare(sql).run(...params),
275
- get: (sql, params = []) => db.prepare(sql).get(...params),
276
- all: (sql, params = []) => db.prepare(sql).all(...params)
277
- };
276
+ return databaseFacade(dbPath, mode !== "read_existing", db);
278
277
  }
279
278
  /**
280
279
  * Opens the small host-wide resource registry. It is intentionally separate
@@ -351,16 +350,10 @@ export function getResourceDatabase(resourceHome) {
351
350
  db.close();
352
351
  throw error;
353
352
  }
354
- const database = {
355
- path: dbPath,
356
- writable: true,
357
- close: () => { resourceDatabases.delete(dbPath); db.close?.(); },
358
- exec: (sql) => db.exec(sql),
359
- execSchema: (sql) => executeStoreSchema(db, sql),
360
- run: (sql, params = []) => db.prepare(sql).run(...params),
361
- get: (sql, params = []) => db.prepare(sql).get(...params),
362
- all: (sql, params = []) => db.prepare(sql).all(...params)
363
- };
353
+ const database = databaseFacade(dbPath, true, db, () => {
354
+ resourceDatabases.delete(dbPath);
355
+ db.close?.();
356
+ });
364
357
  resourceDatabases.set(dbPath, database);
365
358
  return database;
366
359
  }
@@ -370,11 +363,108 @@ function emptyReadOnlyDatabase(dbPath) {
370
363
  writable: false,
371
364
  exec: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
372
365
  execSchema: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
366
+ beginWriteTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
367
+ writeTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
373
368
  run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
374
369
  get: () => undefined,
375
370
  all: () => []
376
371
  };
377
372
  }
373
+ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
374
+ let reserved = false;
375
+ let poisoned = false;
376
+ let savepoint = 0;
377
+ const assertUsable = () => { if (poisoned)
378
+ throw new AppError("write_transaction_unresolved", "Transaction cleanup failed; reopen the connection before continuing", 1); };
379
+ const beginWriteTransaction = () => {
380
+ assertUsable();
381
+ if (!writable)
382
+ throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
383
+ db.exec("BEGIN IMMEDIATE");
384
+ reserved = true;
385
+ };
386
+ const rollback = (sql, original) => {
387
+ try {
388
+ db.exec(sql);
389
+ }
390
+ catch (cleanup) {
391
+ poisoned = true;
392
+ if (original instanceof AppError)
393
+ throw new AppError(original.code, original.message, original.exitCode, { ...original.details, transaction_cleanup_error: errorDetails(cleanup) });
394
+ throw new AppError("write_transaction_failed", "Write transaction failed and SQLite cleanup also failed", 1, { cause: errorDetails(original), transaction_cleanup_error: errorDetails(cleanup) });
395
+ }
396
+ throw original;
397
+ };
398
+ const writeTransaction = (operation) => {
399
+ assertUsable();
400
+ if (!writable)
401
+ throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
402
+ if (db.isTransaction && !reserved)
403
+ throw new AppError("write_transaction_unowned", "Cannot write inside an unowned transaction or read snapshot", 1);
404
+ const nested = db.isTransaction;
405
+ const marker = `dd_flow_write_${++savepoint}`;
406
+ try {
407
+ if (nested)
408
+ db.exec(`SAVEPOINT ${marker}`);
409
+ else
410
+ beginWriteTransaction();
411
+ }
412
+ catch (error) {
413
+ throw new AppError("storage_write_failed", "Cannot reserve SQLite writer", 1, { cause: errorDetails(error), transaction_phase: "begin", transaction_rolled_back: true });
414
+ }
415
+ let phase = "body";
416
+ try {
417
+ const value = operation();
418
+ if (value && typeof value.then === "function") {
419
+ void Promise.resolve(value).catch(() => { });
420
+ throw new AppError("write_transaction_async", "Write transaction callback must be synchronous", 1);
421
+ }
422
+ phase = "commit";
423
+ if (nested)
424
+ db.exec(`RELEASE ${marker}`);
425
+ else
426
+ db.exec("COMMIT");
427
+ return value;
428
+ }
429
+ catch (error) {
430
+ try {
431
+ return rollback(nested ? `ROLLBACK TO ${marker}; RELEASE ${marker}` : "ROLLBACK", error);
432
+ }
433
+ catch (failure) {
434
+ if (failure === error && !(error instanceof AppError) && typeof error === "object" && error !== null && "code" in error && String(error.code).startsWith("ERR_SQLITE"))
435
+ throw new AppError("storage_write_failed", "SQLite write failed", 1, { cause: errorDetails(error), transaction_phase: phase, transaction_rolled_back: true });
436
+ throw failure;
437
+ }
438
+ }
439
+ finally {
440
+ if (!db.isTransaction)
441
+ reserved = false;
442
+ }
443
+ };
444
+ return {
445
+ path: dbPath,
446
+ writable,
447
+ close,
448
+ exec: (sql) => { assertUsable(); try {
449
+ db.exec(sql);
450
+ }
451
+ finally {
452
+ if (!db.isTransaction)
453
+ reserved = false;
454
+ } },
455
+ execSchema: (sql) => { assertUsable(); if (db.isTransaction && !reserved)
456
+ throw new AppError("write_transaction_unowned", "Cannot change schema inside an unowned transaction", 1); executeStoreSchema(db, sql); },
457
+ beginWriteTransaction,
458
+ writeTransaction,
459
+ run: (sql, params = []) => { assertUsable(); return db.prepare(sql).run(...params); },
460
+ get: (sql, params = []) => db.prepare(sql).get(...params),
461
+ all: (sql, params = []) => db.prepare(sql).all(...params)
462
+ };
463
+ }
464
+ function errorDetails(error) {
465
+ const value = error;
466
+ return { message: String(value?.message ?? error), ...(typeof value?.code === "string" ? { code: value.code } : {}), ...(typeof value?.errcode === "number" ? { sqlite_extended_code: value.errcode } : {}) };
467
+ }
378
468
  function configureDatabase(db, mode) {
379
469
  if (mode !== "read_existing") {
380
470
  // Concurrent first openers can collide while changing journal mode, even
@@ -1026,6 +1116,7 @@ function migrate(db) {
1026
1116
  ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
1027
1117
  ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
1028
1118
  ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
1119
+ ensureColumn(db, "work_sessions", "start_receipt_json", "ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
1029
1120
  ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
1030
1121
  ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
1031
1122
  ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
@@ -1055,6 +1146,7 @@ function migrate(db) {
1055
1146
  ensureColumn(db, "hook_events", "parent_session_id", "ALTER TABLE hook_events ADD COLUMN parent_session_id TEXT");
1056
1147
  ensureColumn(db, "hook_events", "daemon_id", "ALTER TABLE hook_events ADD COLUMN daemon_id TEXT");
1057
1148
  ensureColumn(db, "hook_events", "cwd", "ALTER TABLE hook_events ADD COLUMN cwd TEXT");
1149
+ ensureColumn(db, "hook_events", "outcome_json", "ALTER TABLE hook_events ADD COLUMN outcome_json TEXT");
1058
1150
  ensureColumn(db, "harness_usage_snapshots", "tool_calls", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_calls INTEGER");
1059
1151
  ensureColumn(db, "harness_usage_snapshots", "tool_failures", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_failures INTEGER");
1060
1152
  ensureColumn(db, "harness_usage_snapshots", "tool_by_name_json", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_by_name_json TEXT");