@deksden-com/dd-flow-cli 0.9.0-beta.18 → 0.9.0-beta.21

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.
@@ -2,20 +2,87 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
5
+ import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
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
9
  import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
10
10
  import { nextWorkId, nextWorkIds } from "./ids.js";
11
11
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
+ import { appendAudit } from "./audit.js";
12
13
  import { flowCommand } from "./stage-pause.js";
13
14
  import { assertPortableArtifactRef } from "./portable-refs.js";
14
15
  import { publicSessionIdentity } from "./session-identity.js";
16
+ import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
15
17
  const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
16
18
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
19
+ function ownsPhysicalWorkSession(context, projectId, owner, identity) {
20
+ if (identity.sessionId !== owner.session_id)
21
+ return false;
22
+ const binding = owner.hook_event_id ? context.db.get("SELECT daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, owner.hook_event_id]) : undefined;
23
+ // Legacy non-daemon sessions remain usable. Once a physical daemon was
24
+ // captured, another process cannot inherit authority merely by resuming
25
+ // the same provider Session ID.
26
+ return !binding?.daemon_id || binding.daemon_id === identity.daemonId;
27
+ }
28
+ /**
29
+ * A Stage terminal command is allowed only from the Session that owns its
30
+ * running coordinator Work. A hook receipt proves the caller; a WorkSession
31
+ * proves the owner. Either fact alone is insufficient.
32
+ */
33
+ export function assertStageLifecycleOwner(context, input) {
34
+ ensureWorkRegistry(context);
35
+ const projectRoot = resolveProjectRoot(input.projectRoot);
36
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
37
+ if (!project)
38
+ throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
39
+ requireRun(context, project.id, input.runId);
40
+ const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
41
+ FROM work_sessions ws
42
+ JOIN works w ON w.work_id = ws.work_id
43
+ JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
44
+ WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
45
+ AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
46
+ ${input.workId ? "AND w.work_id = ?" : ""}
47
+ ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
48
+ if (!owner)
49
+ 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 } : {}) });
50
+ const identity = claimStageLifecycleHookEvent(context, {
51
+ projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
52
+ runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
53
+ });
54
+ if (ownsPhysicalWorkSession(context, project.id, owner, identity))
55
+ return identity;
56
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
57
+ const payload = { run_id: input.runId, stage: input.stage, work_id: owner.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
58
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
59
+ appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
60
+ throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
61
+ }
62
+ /** A Work terminal command belongs to the Session that started that Work.
63
+ * Controllers can still use their explicit non-agent operations; they cannot
64
+ * borrow a native worker's authority by naming its Work id. */
65
+ export function assertWorkLifecycleOwner(context, input) {
66
+ ensureWorkRegistry(context);
67
+ const work = requireWork(context, input.workId);
68
+ const run = requireRun(context, work.project_id, work.run_id);
69
+ if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
70
+ throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
71
+ const owner = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
72
+ if (!owner)
73
+ throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
74
+ const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
75
+ if (ownsPhysicalWorkSession(context, work.project_id, owner, identity))
76
+ return identity;
77
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
78
+ const payload = { run_id: work.run_id, work_id: work.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
79
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
80
+ appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
81
+ throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
82
+ }
17
83
  export function createChildWork(context, input) {
18
84
  const parent = requireWork(context, input.parentWorkId);
85
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
19
86
  if (parent.status !== "running")
20
87
  throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
21
88
  const id = nextWorkId(context, parent.project_id, input.slug);
@@ -46,6 +113,7 @@ export function validateWorkBatchFile(file) {
46
113
  export function addWorkBatch(context, input) {
47
114
  ensureWorkRegistry(context);
48
115
  const parent = requireWork(context, input.parentWorkId);
116
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
49
117
  if (parent.status !== "running")
50
118
  throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
51
119
  const parsed = readJson(input.file);
@@ -118,6 +186,7 @@ export function mutateWorkDeps(context, input) {
118
186
  const work = requireWork(context, input.workId);
119
187
  if (input.action === "list")
120
188
  return { work_id: work.work_id, depends_on: parseDependencies(work) };
189
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
121
190
  if (work.status !== "created")
122
191
  throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
123
192
  const dependencies = new Set(parseDependencies(work));
@@ -139,7 +208,7 @@ export function mutateWorkDeps(context, input) {
139
208
  refreshRunWorkProjection(context, work.project_id, work.run_id);
140
209
  return { work_id: work.work_id, depends_on: [...dependencies] };
141
210
  }
142
- export function deleteWork(context, id) { const work = requireWork(context, 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}%`]))
211
+ 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}%`]))
143
212
  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 }; }
144
213
  export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
145
214
  /**
@@ -148,10 +217,11 @@ export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.rep
148
217
  * PATH is not inherited; using the shared lifecycle command keeps them on the
149
218
  * same captured runtime as their parent stage.
150
219
  */
151
- export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
220
+ export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`; }
152
221
  export function startWork(context, id, input) {
153
222
  ensureWorkRegistry(context);
154
223
  const work = requireWork(context, id);
224
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
155
225
  if (work.status !== "created")
156
226
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
157
227
  if (!isReady(context, work)) {
@@ -162,16 +232,19 @@ export function startWork(context, id, input) {
162
232
  const run = requireRun(context, work.project_id, work.run_id);
163
233
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
164
234
  throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
235
+ const recoveryId = recoveryGuard(context, work.project_id, work.run_id)?.recovery_id;
236
+ if (input.recoveryId !== recoveryId)
237
+ throw new AppError("run_recovery_generation_stale", "Work launch must use the exact current recovery packet", 1, { work_id: id, recovery_id: recoveryId ?? null });
165
238
  let hookEventId = input.hookEventId;
166
239
  if (!hookEventId) {
167
240
  try {
168
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
241
+ hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
169
242
  }
170
243
  catch {
171
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
244
+ 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;
172
245
  }
173
246
  }
174
- const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root });
247
+ const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) });
175
248
  const started = startBoundWork(context, work, run, identity, hookEventId);
176
249
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
177
250
  return started;
@@ -179,12 +252,16 @@ export function startWork(context, id, input) {
179
252
  /** Binds the already-running coordinator of a stage to its trusted hook Session. */
180
253
  export function bindRunningWorkSession(context, input) {
181
254
  const work = requireWork(context, input.workId);
255
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
182
256
  if (work.status !== "running")
183
257
  throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
184
258
  const run = requireRun(context, work.project_id, work.run_id);
185
259
  const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
260
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
186
261
  const active = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
187
262
  if (active) {
263
+ if (active.session_id === identity.sessionId && !ownsPhysicalWorkSession(context, work.project_id, active, identity))
264
+ throw new AppError("handoff_session_mismatch", "The active Work belongs to another physical daemon", 1, { work_id: work.work_id });
188
265
  if (active.session_id !== identity.sessionId) {
189
266
  // A frozen `new_session` policy may transfer the same coordinator Work
190
267
  // only to its next stage. Repeating the same stage from another Session
@@ -231,6 +308,7 @@ export function bindRunningWorkSession(context, input) {
231
308
  /** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
232
309
  export function bindStageCoordinatorWork(context, input) {
233
310
  const work = requireWork(context, input.workId);
311
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
234
312
  const run = requireRun(context, work.project_id, work.run_id);
235
313
  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 } : {}) });
236
314
  const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
@@ -242,6 +320,7 @@ export function bindStageCoordinatorWork(context, input) {
242
320
  /** Starts a created coordinator Work from its trusted `stage start` event. */
243
321
  export function startStageCoordinatorWork(context, input) {
244
322
  const work = requireWork(context, input.workId);
323
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
245
324
  if (work.status !== "created")
246
325
  throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
247
326
  const run = requireRun(context, work.project_id, work.run_id);
@@ -256,6 +335,8 @@ export function startStageCoordinatorWork(context, input) {
256
335
  }
257
336
  /** Stage entry points call this only after their own trusted hook claim. */
258
337
  export function startBoundWork(context, work, run, identity, hookEventId) {
338
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
339
+ const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
259
340
  if (work.status !== "created")
260
341
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
261
342
  const now = context.now();
@@ -266,10 +347,13 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
266
347
  const resultPath = path.join(directory, "result.json");
267
348
  const contextPath = path.join(directory, "context.json");
268
349
  const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
269
- const prompt = renderWorkerPrompt(context, work, run, dependencyResults);
350
+ const recovery = recoveryPromptForWork(context, work.work_id);
351
+ let prompt = renderWorkerPrompt(context, work, run, dependencyResults);
352
+ if (recovery)
353
+ prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
270
354
  const payload = parsePayload(work);
271
355
  const readOnly = payload?.read_only === true;
272
- 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, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
356
+ 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) } : {}) };
273
357
  const token = crypto.randomUUID();
274
358
  const promptCandidate = `${promptPath}.${token}.tmp`;
275
359
  const contextCandidate = `${contextPath}.${token}.tmp`;
@@ -277,15 +361,18 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
277
361
  fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
278
362
  context.db.exec("BEGIN IMMEDIATE");
279
363
  try {
364
+ assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
280
365
  bindSession(context, work, run, identity, now);
281
366
  if (work.parent_work_id && work.launch_policy === "reuse_allowed")
282
367
  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]);
283
- const claimed = context.db.run("UPDATE works SET status = 'running', started_at = ?, updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, work.work_id]);
368
+ 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]);
284
369
  if (claimed.changes !== 1)
285
370
  throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
286
371
  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]);
287
372
  fs.renameSync(promptCandidate, promptPath);
288
373
  fs.renameSync(contextCandidate, contextPath);
374
+ if (recovery)
375
+ activateRecoveryWork(context, work.work_id);
289
376
  context.db.exec("COMMIT");
290
377
  }
291
378
  catch (error) {
@@ -295,16 +382,16 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
295
382
  throw error;
296
383
  }
297
384
  refreshRunWorkProjection(context, work.project_id, work.run_id);
298
- return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
385
+ 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 } } };
299
386
  }
300
387
  export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
301
388
  /** Structured fan-in closes a parent after its Session was handed to a child. */
302
- export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
389
+ export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
303
390
  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]))
304
391
  throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
305
392
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
306
393
  export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
307
- export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
394
+ export function retryWork(context, id, reason) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); id = work.work_id; if (work.status !== "failed")
308
395
  throw new AppError("invalid_work_state", "Only failed Work may be retried", 2); const run = requireRun(context, work.project_id, work.run_id); const directory = path.join(requireRunHome(run), "works", id); const attempts = path.join(directory, "attempts"); const number = fs.existsSync(attempts) ? fs.readdirSync(attempts).filter((entry) => /^ATT-\d{3}$/.test(entry)).length + 1 : 1; const archive = path.join(attempts, `ATT-${String(number).padStart(3, "0")}`); fs.mkdirSync(archive, { recursive: true }); for (const file of ["prompt.md", "result.json", "context.json"]) {
309
396
  const source = path.join(directory, file);
310
397
  if (fs.existsSync(source))
@@ -312,6 +399,7 @@ export function retryWork(context, id, reason) { const work = requireWork(contex
312
399
  } context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
313
400
  async function settle(context, id, status, result, progress) {
314
401
  const work = requireWork(context, id);
402
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
315
403
  id = work.work_id;
316
404
  if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
317
405
  throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
@@ -352,6 +440,9 @@ async function settle(context, id, status, result, progress) {
352
440
  const now = context.now();
353
441
  context.db.exec("BEGIN IMMEDIATE");
354
442
  try {
443
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
444
+ if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
445
+ throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
355
446
  context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
356
447
  if (link) {
357
448
  if (link.result_path && work.parent_work_id !== null)
@@ -367,6 +458,7 @@ async function settle(context, id, status, result, progress) {
367
458
  context.db.exec("ROLLBACK");
368
459
  throw error;
369
460
  }
461
+ settleRecoveryWork(context, work.work_id, status);
370
462
  refreshRunWorkProjection(context, work.project_id, work.run_id);
371
463
  const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
372
464
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
@@ -374,6 +466,7 @@ async function settle(context, id, status, result, progress) {
374
466
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
375
467
  return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
376
468
  }
469
+ function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
377
470
  function plannedAreaDrift(packet, result) {
378
471
  const changed = JSON.parse(result).changed_paths;
379
472
  if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
@@ -7,6 +7,29 @@ import { AppError } from "../shared/errors.js";
7
7
  const require = createRequire(import.meta.url);
8
8
  const { DatabaseSync } = require("node:sqlite");
9
9
  const resourceDatabases = new Map();
10
+ /** Recovery guards apply to every RUN kind, including custom eval fixtures. */
11
+ export function ensureRecoveryStorage(db) {
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
14
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
15
+ );
16
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
17
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
18
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
19
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
22
+ CREATE TABLE IF NOT EXISTS run_recovery_bindings (
23
+ recovery_id TEXT PRIMARY KEY REFERENCES run_recovery_guards(recovery_id),
24
+ work_id TEXT NOT NULL REFERENCES works(work_id), prior_work_session_id TEXT NOT NULL,
25
+ session_id TEXT NOT NULL, harness TEXT NOT NULL, provider_session_id TEXT NOT NULL,
26
+ daemon_id TEXT NOT NULL, status TEXT NOT NULL, hook_event_key TEXT,
27
+ created_at TEXT NOT NULL, accepted_at TEXT
28
+ );
29
+ `);
30
+ ensureColumn(db, "run_recovery_bindings", "packet_json", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_json TEXT");
31
+ ensureColumn(db, "run_recovery_bindings", "packet_sha256", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_sha256 TEXT");
32
+ }
10
33
  /** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
11
34
  export function ensureVnextWorkStorage(db) {
12
35
  const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
@@ -53,6 +76,16 @@ export function ensureVnextWorkStorage(db) {
53
76
  );
54
77
  CREATE INDEX IF NOT EXISTS idx_work_sessions_work ON work_sessions(work_id, created_at DESC);
55
78
  CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open ON work_sessions(work_id) WHERE status = 'running';
79
+
80
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
81
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
82
+ );
83
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
84
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
85
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
86
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
87
+ );
88
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
56
89
  CREATE TABLE IF NOT EXISTS check_receipts (
57
90
  id TEXT PRIMARY KEY,
58
91
  project_id TEXT NOT NULL,
@@ -95,8 +128,10 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
95
128
  configureDatabase(db, mode);
96
129
  // Internal additive schema changes must be available to every write command.
97
130
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
98
- if (mode !== "read_existing")
131
+ if (mode !== "read_existing") {
99
132
  migrate(db, dbPath);
133
+ ensureRecoveryStorage(db);
134
+ }
100
135
  if (mode === "read_existing")
101
136
  db.exec("PRAGMA query_only = ON");
102
137
  db.exec("PRAGMA foreign_keys = ON");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.18",
3
+ "version": "0.9.0-beta.21",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,16 @@
16
16
  "node": ">=26.0.0",
17
17
  "pnpm": ">=10.0.0"
18
18
  },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "eslint . --max-warnings=0",
23
+ "test": "vitest run",
24
+ "changeset": "changeset",
25
+ "version-packages": "changeset version",
26
+ "release": "pnpm run build && changeset publish",
27
+ "prepublishOnly": "node scripts/verify-release-build.mjs"
28
+ },
19
29
  "devDependencies": {
20
30
  "@changesets/cli": "^2.31.0",
21
31
  "@eslint/js": "^9.39.1",
@@ -34,14 +44,5 @@
34
44
  "publishConfig": {
35
45
  "access": "public"
36
46
  },
37
- "license": "MIT",
38
- "scripts": {
39
- "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
40
- "typecheck": "tsc --noEmit",
41
- "lint": "eslint . --max-warnings=0",
42
- "test": "vitest run",
43
- "changeset": "changeset",
44
- "version-packages": "changeset version",
45
- "release": "pnpm run build && changeset publish"
46
- }
47
- }
47
+ "license": "MIT"
48
+ }