@deksden-com/dd-flow-cli 0.9.0-beta.55 → 0.9.0-beta.62

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.
@@ -0,0 +1,53 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { AppError } from "../shared/errors.js";
3
+ export const recoveryObservationBudgetMs = 120_000;
4
+ const activeObservation = new AsyncLocalStorage();
5
+ export const withRecoveryObservation = (budget, action) => activeObservation.run(budget, action);
6
+ export const recoveryRpcTimeout = (maximum) => activeObservation.getStore()?.timeout(maximum) ?? maximum;
7
+ /** Call only while holding the existing owner-claim transaction. A replay of
8
+ * an explicit request never replenishes time, even after another request. */
9
+ export function renewRecoveryObservation(snapshot, requestId) {
10
+ if (!requestId.trim() || requestId.length > 200)
11
+ throw new AppError("usage", "Reconciliation requires a nonempty request ID of at most 200 characters", 2);
12
+ const previous = snapshot.recovery_observation;
13
+ const ids = previous?.reconciliation_ids ?? [];
14
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== "string"))
15
+ throw new AppError("recovery_observation_invalid", "Retained reconciliation identities are malformed", 1);
16
+ if (ids.includes(requestId))
17
+ return snapshot;
18
+ return { ...snapshot, recovery_observation: { budget_ms: recoveryObservationBudgetMs, remaining_ms: recoveryObservationBudgetMs, reconciliation_ids: [...ids, requestId], poll_index: 0 } };
19
+ }
20
+ /**
21
+ * Measures only time this owner can actually observe. Large scheduler gaps
22
+ * reset the sample instead of turning host sleep into evidence about a remote
23
+ * process. Persisting the remaining budget makes a recovered owner continue
24
+ * reconciliation rather than silently receiving a new unlimited wait.
25
+ */
26
+ export function recoveryObservationBudget(saved, monotonic = () => performance.now()) {
27
+ const prior = saved && typeof saved === "object" ? saved.recovery_observation : null;
28
+ let remaining = typeof prior?.remaining_ms === "number" && Number.isFinite(prior.remaining_ms)
29
+ ? Math.max(0, Math.min(recoveryObservationBudgetMs, prior.remaining_ms))
30
+ : prior == null ? recoveryObservationBudgetMs : 0;
31
+ let last = monotonic();
32
+ let pollIndex = Number.isSafeInteger(prior?.poll_index) && prior.poll_index >= 0 ? prior.poll_index : 0;
33
+ const observation = {
34
+ exhausted: () => {
35
+ const current = monotonic();
36
+ const elapsed = current - last;
37
+ last = current;
38
+ // A gap means this process was not observing; it never proves progress
39
+ // or death and therefore does not consume the reconciliation budget.
40
+ if (elapsed >= 0 && elapsed <= 60_000)
41
+ remaining = Math.max(0, remaining - elapsed);
42
+ return remaining === 0;
43
+ },
44
+ state: () => ({ budget_ms: recoveryObservationBudgetMs, remaining_ms: Math.ceil(remaining), reconciliation_ids: prior?.reconciliation_ids ?? [], poll_index: pollIndex }),
45
+ timeout: (maximum = 30_000) => {
46
+ if (observation.exhausted())
47
+ throw new AppError("recovery_observation_budget_exhausted", "Recovery observation budget exhausted; explicit reconciliation is required", 1);
48
+ return Math.max(1, Math.min(maximum, Math.ceil(remaining)));
49
+ },
50
+ nextDelay: () => Math.min([1000, 2000, 5000, 10000][Math.min(pollIndex++, 3)], Math.ceil(remaining)),
51
+ };
52
+ return observation;
53
+ }
@@ -13,6 +13,17 @@ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, m
13
13
  import { interruptControlledSessions, observeControlledSessions, recordRunControlIntent, runControlStatus } from "./run-control.js";
14
14
  import { recoveryGuard, recordRecoveryCapture, sealRunRecovery } from "./run-recovery.js";
15
15
  import { createEvalRunSnapshot, verifyRecoverySnapshot } from "./eval-snapshots.js";
16
+ import { recoveryObservationBudget, renewRecoveryObservation, withRecoveryObservation } from "./recovery-observation-budget.js";
17
+ export async function reconcileRunControl(context, input) {
18
+ const status = runControlStatus(context, input);
19
+ const control = status.control;
20
+ if (!control?.current || !["requested", "settled"].includes(control.status))
21
+ throw new AppError("run_control_state_changed", "Reconciliation requires the current draining/sealed control", 1);
22
+ renewRecoveryObservation({}, input.requestId); // Validate before claiming anything.
23
+ if (control.status === "requested")
24
+ await launchControlWorker(context, { ...input, reconcileId: input.requestId });
25
+ return runControlStatus(context, input);
26
+ }
16
27
  /** The caller publishes intent and launches a control owner; it never owns drain. */
17
28
  export async function requestRunControl(context, input) {
18
29
  if (input.waitMs !== undefined && (!Number.isSafeInteger(input.waitMs) || input.waitMs < 0 || input.waitMs > 60_000))
@@ -24,7 +35,7 @@ export async function requestRunControl(context, input) {
24
35
  };
25
36
  if (!receipt.control_id || receipt.status !== "requested")
26
37
  return projectReceipt();
27
- const scope = { projectRoot: input.projectRoot, runId: input.runId, controlId: String(receipt.control_id) };
38
+ const scope = { projectRoot: input.projectRoot, runId: input.runId, controlId: String(receipt.control_id), ...(input.reconcileId ? { reconcileId: input.reconcileId } : {}) };
28
39
  await launchControlWorker(context, scope);
29
40
  const deadline = performance.now() + (input.waitMs ?? 0);
30
41
  while (performance.now() < deadline) {
@@ -49,8 +60,11 @@ async function launchControlWorker(context, input) {
49
60
  context.db.exec("BEGIN IMMEDIATE");
50
61
  try {
51
62
  claimed = context.db.run("INSERT OR IGNORE INTO run_control_workers (control_id, owner_token, status, resource_home, state_dir, created_at, updated_at) VALUES (?, ?, 'starting', ?, ?, ?, ?)", [input.controlId, token, home, directory, now, now]).changes === 1;
52
- if (claimed)
63
+ if (claimed) {
53
64
  recordControlLauncher(context, input.controlId, token);
65
+ if (input.reconcileId)
66
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ? WHERE control_id = ?", [JSON.stringify(renewRecoveryObservation({}, input.reconcileId)), input.controlId]);
67
+ }
54
68
  context.db.exec("COMMIT");
55
69
  }
56
70
  catch (error) {
@@ -93,6 +107,10 @@ function reclaimControlWorker(context, input, projectId, token, home) {
93
107
  const previous = context.db.get("SELECT * FROM run_control_workers WHERE control_id = ?", [input.controlId]);
94
108
  if (!previous || !["starting", "running", "failed"].includes(previous.status) || previous.resource_home !== home)
95
109
  return false;
110
+ const saved = JSON.parse(previous.snapshot_json ?? "{}");
111
+ const requested = input.reconcileId ? renewRecoveryObservation(saved, input.reconcileId) : saved;
112
+ if (recoveryObservationBudget(requested).state().remaining_ms === 0)
113
+ return false;
96
114
  const registry = fs.existsSync(path.join(home, "runtime.sqlite")) ? getResourceDatabase(home) : null;
97
115
  let record = previous.process_id ? registry?.get("SELECT * FROM managed_processes WHERE id = ?", [previous.process_id]) : null;
98
116
  const operationId = launchOperationId(input.controlId, previous.owner_token);
@@ -157,6 +175,10 @@ function reclaimControlWorker(context, input, projectId, token, home) {
157
175
  context.db.exec("ROLLBACK");
158
176
  return false;
159
177
  }
178
+ if (input.reconcileId) {
179
+ const snapshot = renewRecoveryObservation(JSON.parse(previous.snapshot_json ?? "{}"), input.reconcileId);
180
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = NULL WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), input.controlId, token]);
181
+ }
160
182
  const now = context.now();
161
183
  context.db.run("INSERT INTO run_control_operations (operation_id, control_id, target_json, status, receipt_json, created_at, updated_at) VALUES (?, ?, ?, 'completed', ?, ?, ?)", [`${launchOperationId(input.controlId, previous.owner_token)}:reconciled`, input.controlId, JSON.stringify({ kind: "control_worker_reconciliation", process_id: record?.id ?? null, resource_home: home }), JSON.stringify({ process_id: record?.id ?? null, pid: record?.pid ?? null, pid_started_at: record?.pid_started_at ?? null, prior_status: previous.status, snapshot: JSON.parse(previous.snapshot_json ?? "null"), error: JSON.parse(previous.error_json ?? "null") }), now, now]);
162
184
  recordControlLauncher(context, input.controlId, token);
@@ -216,6 +238,14 @@ export async function serveRunControl(context, input) {
216
238
  if (!worker.process_lease_token)
217
239
  throw new AppError("run_control_owner_mismatch", "Control owner has no resource lease", 1);
218
240
  confirmManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
241
+ let priorObservation = null;
242
+ try {
243
+ priorObservation = worker.snapshot_json ? JSON.parse(worker.snapshot_json) : null;
244
+ }
245
+ catch { /* A malformed prior snapshot cannot grant a new budget. */
246
+ priorObservation = { recovery_observation: { remaining_ms: 0 } };
247
+ }
248
+ const observation = recoveryObservationBudget(priorObservation);
219
249
  for (;;) {
220
250
  if (leaseLost)
221
251
  throw new AppError("run_control_lease_lost", "Control worker lost its physical ownership lease", 1);
@@ -226,6 +256,18 @@ export async function serveRunControl(context, input) {
226
256
  context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
227
257
  return { ok: true, control_id: input.controlId, status };
228
258
  }
259
+ if (observation.exhausted()) {
260
+ const latest = context.db.get("SELECT snapshot_json FROM run_control_workers WHERE control_id = ?", [input.controlId]);
261
+ let retained = {};
262
+ try {
263
+ retained = JSON.parse(latest?.snapshot_json ?? "{}") ?? {};
264
+ }
265
+ catch { /* Retain a blocked status even when the old projection is malformed. */ }
266
+ const snapshot = { ...retained, status: "recovery_blocked", settled: false, pending_reasons: ["recovery_observation_budget_exhausted"],
267
+ error: { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget" }, recovery_observation: observation.state() };
268
+ context.db.run("UPDATE run_control_workers SET status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(snapshot.error), context.now(), input.controlId, worker.owner_token]);
269
+ return { ok: true, control_id: input.controlId, ...snapshot };
270
+ }
229
271
  if (guard.status === "sealed") {
230
272
  // Capture retries are local evidence work. Never dispatch another
231
273
  // native interrupt/inspect merely because publication previously failed.
@@ -250,14 +292,15 @@ export async function serveRunControl(context, input) {
250
292
  AND EXISTS (SELECT 1 FROM run_recovery_guards g WHERE g.recovery_id = run_controls.recovery_id AND g.status = 'sealed' AND g.capture_path = ? AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [context.now(), input.controlId, guard.recovery_id, worker.owner_token, capture]);
251
293
  if (applied.changes !== 1)
252
294
  throw new AppError("run_control_state_changed", "Control changed before its capture could be acknowledged", 1);
253
- const snapshot = { ...settlement, status: "settled", capture_path: capture };
295
+ const snapshot = { ...settlement, status: "settled", capture_path: capture, recovery_observation: observation.state() };
254
296
  context.db.run("UPDATE run_control_workers SET status = 'completed', snapshot_json = ?, error_json = NULL, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
255
297
  return { ok: true, control_id: input.controlId, ...snapshot };
256
298
  }
257
299
  catch (error) {
258
- const snapshot = { ...settlement, status: "requested", capture: { status: "pending", error: failure(error) }, pending_reasons: ["recovery_capture_required"] };
300
+ observation.exhausted();
301
+ const snapshot = { ...settlement, status: "requested", capture: { status: "pending", error: failure(error) }, pending_reasons: ["recovery_capture_required"], recovery_observation: observation.state() };
259
302
  context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
260
- await delay(5_000);
303
+ await delay(observation.nextDelay());
261
304
  continue;
262
305
  }
263
306
  }
@@ -265,12 +308,16 @@ export async function serveRunControl(context, input) {
265
308
  const interrupt = current.action === "stop" || request.escalate === "interrupt" && Date.now() >= Date.parse(current.created_at) + (request.grace_ms ?? 30_000);
266
309
  let snapshot;
267
310
  try {
268
- const cancellation = interrupt ? await interruptControlledSessions(context, input) : null;
269
- snapshot = { ...await observeControlledSessions(context, input), ...(cancellation ? { cancellation } : {}) };
311
+ snapshot = await withRecoveryObservation(observation, async () => {
312
+ const cancellation = interrupt ? await interruptControlledSessions(context, input) : null;
313
+ return { ...await observeControlledSessions(context, input), ...(cancellation ? { cancellation } : {}) };
314
+ });
270
315
  }
271
316
  catch (error) {
272
317
  snapshot = { ok: true, control_id: input.controlId, status: "requested", settled: false, pending_reasons: ["control_observation_failed"], error: failure(error) };
273
318
  }
319
+ observation.exhausted();
320
+ snapshot = { ...snapshot, recovery_observation: observation.state() };
274
321
  context.db.run("UPDATE run_control_workers SET snapshot_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
275
322
  if (snapshot.settled === true) {
276
323
  try {
@@ -281,7 +328,7 @@ export async function serveRunControl(context, input) {
281
328
  context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify({ ...snapshot, pending_reasons: ["recovery_seal_required"], error: failure(error) }), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
282
329
  }
283
330
  }
284
- await delay(1_000);
331
+ await delay(observation.nextDelay());
285
332
  }
286
333
  }
287
334
  catch (error) {
@@ -1,3 +1,4 @@
1
+ import { setFlowRunRecoveryPaused } from "./runs.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import { ensureRunControlStorage } from "../storage/database.js";
@@ -456,20 +457,32 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
456
457
  assertCurrent();
457
458
  const inserted = context.db.run("INSERT OR IGNORE INTO run_control_operations (operation_id, control_id, target_json, status, created_at, updated_at) VALUES (?, ?, ?, 'dispatching', ?, ?)", [operationId, observationId, JSON.stringify({ session_id: session.id, state_dir: session.state_dir, executable }), now, now]);
458
459
  const prior = context.db.get("SELECT status, receipt_json FROM run_control_operations WHERE operation_id = ?", [operationId]);
460
+ let cancellation;
461
+ let cancellationError;
459
462
  try {
460
463
  if (!inserted.changes && prior.status === "completed") {
461
464
  const saved = JSON.parse(prior.receipt_json);
462
- assertNativeCancellationReceipt(saved, session.id, harness === "zcode-acp");
463
- return { operation_id: operationId, session_id: session.id, status: prior.status, receipt: saved, reused: true };
465
+ assertNativeCancellationReceipt(saved, session.id);
466
+ cancellation = saved;
467
+ }
468
+ else {
469
+ const { status, receipt } = await cancelControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, env, operationId, observe: !inserted.changes, assertCurrent });
470
+ context.db.run("UPDATE run_control_operations SET status = ?, receipt_json = ?, error_json = NULL, updated_at = ? WHERE operation_id = ?", [status, JSON.stringify(receipt), context.now(), operationId]);
471
+ cancellation = receipt;
464
472
  }
465
- const { status, receipt } = await cancelControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, env, operationId, observe: !inserted.changes, assertCurrent, requireSettled: harness === "zcode-acp" });
466
- context.db.run("UPDATE run_control_operations SET status = ?, receipt_json = ?, error_json = NULL, updated_at = ? WHERE operation_id = ?", [status, JSON.stringify(receipt), context.now(), operationId]);
467
- return { operation_id: operationId, session_id: session.id, status, receipt };
468
473
  }
469
474
  catch (error) {
470
- const failure = { code: error instanceof AppError ? error.code : "control_observation_failed", message: String(error) };
471
- context.db.run("UPDATE run_control_operations SET status = 'outcome_unknown', error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(failure), context.now(), operationId]);
472
- return { operation_id: operationId, session_id: session.id, status: "outcome_unknown", error: failure };
475
+ cancellationError = { code: error instanceof AppError ? error.code : "control_observation_failed", message: String(error) };
476
+ context.db.run("UPDATE run_control_operations SET status = 'outcome_unknown', error_json = ?, updated_at = ? WHERE operation_id = ? AND status != 'completed'", [JSON.stringify(cancellationError), context.now(), operationId]);
477
+ if (cancellationError.code === "controller_operation_mismatch")
478
+ return { operation_id: operationId, session_id: session.id, status: "outcome_unknown", error: cancellationError };
479
+ }
480
+ try {
481
+ const observed = await inspectControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, harness, env, operationId: `${observationId}:inspect:${crypto.randomUUID()}`, assertCurrent });
482
+ return { operation_id: operationId, session_id: session.id, status: observed.settled === true ? "completed" : "pending", receipt: observed, cancellation, reused: !inserted.changes, ...(cancellationError ? { cancellation_error: cancellationError } : {}) };
483
+ }
484
+ catch (error) {
485
+ return { operation_id: operationId, session_id: session.id, status: "pending", cancellation, cancellation_error: cancellationError, error: { code: error instanceof AppError ? error.code : "control_observation_failed", message: String(error) } };
473
486
  }
474
487
  }));
475
488
  const processResults = Promise.all(processTargets.map(async ({ home, record }) => {
@@ -532,7 +545,17 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
532
545
  const executable = directoryAdapters.get(directory) ?? (leader && JSON.parse(leader.metadata_json).adapter_executable);
533
546
  if (!daemonId || !leader || !executable)
534
547
  return { state_dir: directory, status: "pending", reason: "daemon_close_owner_unproven" };
535
- const operationId = `${observationId}:close-daemon:${crypto.createHash("sha256").update(JSON.stringify([directory, daemonId])).digest("hex")}`;
548
+ let operationId = `${observationId}:close-daemon:${crypto.createHash("sha256").update(JSON.stringify([directory, daemonId])).digest("hex")}`;
549
+ const nativeOperations = retainedDaemonOperations(directory);
550
+ const targetList = [...targets.values()];
551
+ const sessionsSettled = ids.every(id => nodes.some((node, index) => targetList[index]?.session.state_dir === directory && targetList[index]?.session.id === id && node.status === "completed"));
552
+ // A confirmed failed tree guard admits a new shutdown attempt only after
553
+ // fresh tree settlement. Unknown outcomes retain their original ID.
554
+ if (sessionsSettled) {
555
+ while (nativeOperations.some(operation => operation.operation_id === operationId && operation.daemon_id === daemonId && operation.operation === "daemon.stop"
556
+ && operation.state === "failed" && operation.error?.code === "tree_not_settled"))
557
+ operationId += ":finalize";
558
+ }
536
559
  assertCurrent();
537
560
  const inserted = context.db.run("INSERT OR IGNORE INTO run_control_operations (operation_id, control_id, target_json, status, created_at, updated_at) VALUES (?, ?, ?, 'dispatching', ?, ?)", [operationId, observationId, JSON.stringify({ state_dir: directory, daemon_id: daemonId, executable, cancel_tree: true }), context.now(), context.now()]);
538
561
  const prior = context.db.get("SELECT status, receipt_json FROM run_control_operations WHERE operation_id = ?", [operationId]);
@@ -746,6 +769,7 @@ export function recordRunControlIntent(context, input) {
746
769
  FROM runs r, json_each(r.index_json, '$.stage_runs') stage
747
770
  WHERE r.project_id = ? AND r.id = ? AND json_extract(stage.value, '$.status') = 'running'
748
771
  AND EXISTS (SELECT 1 FROM run_control_admissions a JOIN sessions s ON s.session_id = a.session_id AND s.project_id = r.project_id WHERE a.control_id = ? AND s.current_stage = json_extract(stage.value, '$.stage'))`, [controlId, controlId, project.id, input.runId, controlId]);
772
+ setFlowRunRecoveryPaused(context, project.id, input.runId, true);
749
773
  context.db.exec("COMMIT");
750
774
  return { ok: true, control_id: controlId, recovery_id: recoveryId, generation, action: input.action, status: "requested", settled: false };
751
775
  }
@@ -7,6 +7,7 @@ import { observeControllerBoundary } from "./run-control.js";
7
7
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
8
8
  import { appendEvent, digest, errorRecord, requireController, requireRun } from "./run-controller-state.js";
9
9
  import { getFlowRunStatus } from "./runs.js";
10
+ import { recoveryObservationBudget, withRecoveryObservation } from "./recovery-observation-budget.js";
10
11
  /** Capture is performed by the productive owner, before it releases the next
11
12
  * Stage. An observer never races the controller to copy a completed boundary. */
12
13
  export async function captureControllerBoundary(context, row, manifest, stage, nextStage, assertPhysicalOwner) {
@@ -63,6 +64,14 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
63
64
  if (nextStage ? continuation.kind !== "start_stage" || continuation.stage !== nextStage : continuation.kind !== "terminal")
64
65
  throw new AppError("controller_boundary_changed", "Stage successor changed after its capture fence", 1);
65
66
  };
67
+ let priorObservation = null;
68
+ try {
69
+ priorObservation = prior?.receipt_json ? JSON.parse(prior.receipt_json) : null;
70
+ }
71
+ catch {
72
+ priorObservation = { recovery_observation: { remaining_ms: 0 } };
73
+ }
74
+ const observationBudget = recoveryObservationBudget(priorObservation);
66
75
  try {
67
76
  let receipt;
68
77
  if (fs.existsSync(output)) {
@@ -72,9 +81,13 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
72
81
  else {
73
82
  let lastPending = "";
74
83
  for (;;) {
84
+ if (observationBudget.exhausted()) {
85
+ context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(COALESCE(receipt_json, '{}'), '$.recovery_observation', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify(observationBudget.state()), context.now(), operationId]);
86
+ throw new AppError("recovery_observation_budget_exhausted", "Boundary capture has no stable settlement within the active observation budget", 1, { recovery_observation: observationBudget.state() });
87
+ }
75
88
  assertCurrent();
76
89
  let verify = () => { throw new AppError("controller_boundary_unsettled", "Boundary has no stable writer observation", 1); };
77
- const observation = await observeControllerBoundary(context, { ...scope, controllerId: row.controller_id, operationId, assertCurrent, retainVerification: value => { verify = value; } });
90
+ const observation = await withRecoveryObservation(observationBudget, () => observeControllerBoundary(context, { ...scope, controllerId: row.controller_id, operationId, assertCurrent, retainVerification: value => { verify = value; } }));
78
91
  if (observation.settled === true) {
79
92
  try {
80
93
  createEvalRunSnapshot(context, { ...scope, output, ...(nextStage ? { stageEntry: nextStage } : { candidate: true }), boundary: { controllerId: row.controller_id, operationId, boundaryKey, observation, verify } });
@@ -88,13 +101,15 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
88
101
  observation.pending_reasons = [error.code];
89
102
  }
90
103
  }
104
+ observationBudget.exhausted();
105
+ const published = { ...observation, recovery_observation: observationBudget.state() };
91
106
  const pending = digest(observation.pending_reasons);
107
+ context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(published), context.now(), operationId]);
92
108
  if (pending !== lastPending) {
93
- context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(observation), context.now(), operationId]);
94
109
  appendEvent(context, row.controller_id, "boundary_capture_pending", { operation_id: operationId, pending_reasons: observation.pending_reasons });
95
110
  lastPending = pending;
96
111
  }
97
- await delay(1000);
112
+ await delay(observationBudget.nextDelay());
98
113
  }
99
114
  }
100
115
  context.db.exec("BEGIN IMMEDIATE");
@@ -111,6 +126,8 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
111
126
  }
112
127
  }
113
128
  catch (error) {
129
+ observationBudget.exhausted();
130
+ context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(COALESCE(receipt_json, '{}'), '$.recovery_observation', json(?)) WHERE operation_id = ?", [JSON.stringify(observationBudget.state()), operationId]);
114
131
  context.db.run("UPDATE run_controller_operations SET status = 'failed', error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(errorRecord(error)), context.now(), operationId]);
115
132
  throw error;
116
133
  }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { AppError } from "../shared/errors.js";
7
7
  import { getResourceDatabase } from "../storage/database.js";
8
8
  import { finishManagedProcess, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
9
+ import { setFlowRunRecoveryPaused } from "./runs.js";
9
10
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
10
11
  import { requireRun, requireController, controllerReceipt, appendEvent, digest, errorRecord } from "./run-controller-state.js";
11
12
  const launchId = (row) => `${row.controller_id}:owner-launch:${digest(row.owner_token)}`;
@@ -172,7 +173,17 @@ export async function spawnController(context, row) {
172
173
  return controllerReceipt(requireController(context, controllerId));
173
174
  }
174
175
  catch (error) {
175
- context.db.run("UPDATE run_controllers SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(errorRecord(error)), context.now(), controllerId, token]);
176
+ context.db.exec("SAVEPOINT controller_spawn_status");
177
+ try {
178
+ const changed = context.db.run("UPDATE run_controllers SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(errorRecord(error)), context.now(), controllerId, token]);
179
+ if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation)
180
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
181
+ context.db.exec("RELEASE controller_spawn_status");
182
+ }
183
+ catch (failure) {
184
+ context.db.exec("ROLLBACK TO controller_spawn_status; RELEASE controller_spawn_status");
185
+ throw failure;
186
+ }
176
187
  throw error;
177
188
  }
178
189
  }
@@ -16,7 +16,7 @@ export function requireController(context, id) {
16
16
  export function controllerReceipt(row) {
17
17
  const state = JSON.parse(row.state_json);
18
18
  const manifest = JSON.parse(row.manifest_json);
19
- return { ok: true, schema_id: "dd-flow/run-controller-receipt@1", controller: { controller_id: row.controller_id, request_id: row.request_id, project_id: row.project_id, run_id: row.run_id, generation: row.generation, runtime_budget: manifest.budget ?? null, status: row.status, process_id: row.process_id, owner_pid: row.owner_pid, state_dir: row.state_dir, stage: state.stage, sessions: state.sessions.map(session => ({ session_id: session.id, harness: runtimeHarness(session.profile), profile_id: session.profile.id, state_dir: session.state_dir, stopped: session.stopped === true })), error: row.last_error_json ? JSON.parse(row.last_error_json) : null } };
19
+ return { ok: true, schema_id: "dd-flow/run-controller-receipt@1", controller: { controller_id: row.controller_id, request_id: row.request_id, project_id: row.project_id, run_id: row.run_id, generation: row.generation, runtime_budget: manifest.budget ?? null, status: row.status, process_id: row.process_id, owner_pid: row.owner_pid, state_dir: row.state_dir, stage: state.stage, sessions: state.sessions.map(session => ({ session_id: session.id, harness: runtimeHarness(session.profile), profile_id: session.profile.id, state_dir: session.state_dir, journal: session.journal, stopped: session.stopped === true })), error: row.last_error_json ? JSON.parse(row.last_error_json) : null } };
20
20
  }
21
21
  export function appendEvent(context, id, type, data) { context.db.run("INSERT INTO run_controller_events (controller_id, event_type, data_json, created_at) VALUES (?, ?, ?, ?)", [id, type, JSON.stringify(data), context.now()]); }
22
22
  export function saveState(context, row, state) { context.db.run("UPDATE run_controllers SET state_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(state), context.now(), row.controller_id, row.owner_token]); }
@@ -10,7 +10,7 @@ import { AppError } from "../shared/errors.js";
10
10
  import { parseRuntimeBudget } from "./runtime-budget.js";
11
11
  import { requireProjectByRoot } from "./projects.js";
12
12
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
13
- import { getFlowRunStatus } from "./runs.js";
13
+ import { getFlowRunStatus, setFlowRunRecoveryPaused } from "./runs.js";
14
14
  import { loadExternalStageContext } from "./stage-context.js";
15
15
  import { flowCommand } from "./stage-pause.js";
16
16
  import { resolveStageTransition, sameSessionProfile, runtimeHarness } from "./execution-policy.js";
@@ -202,7 +202,17 @@ export async function serveRunController(context, input) {
202
202
  };
203
203
  const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
204
204
  try {
205
- markControllerDispatchReady(context, row);
205
+ context.db.exec("SAVEPOINT controller_ready_status");
206
+ try {
207
+ markControllerDispatchReady(context, row);
208
+ if (!state.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
209
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
210
+ context.db.exec("RELEASE controller_ready_status");
211
+ }
212
+ catch (error) {
213
+ context.db.exec("ROLLBACK TO controller_ready_status; RELEASE controller_ready_status");
214
+ throw error;
215
+ }
206
216
  appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
207
217
  if (state.recovery)
208
218
  await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
@@ -221,10 +231,21 @@ export async function serveRunController(context, input) {
221
231
  return controllerReceipt(requireController(context, row.controller_id));
222
232
  }
223
233
  }
224
- // A failed capture must not reopen admission when this process exits.
225
- context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
226
- context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
227
- appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
234
+ context.db.exec("SAVEPOINT controller_failure_status");
235
+ try {
236
+ // A failed capture must not reopen admission when this process exits.
237
+ const changed = context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status NOT IN ('completed','cancelled','superseded')", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
238
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
239
+ if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
240
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
241
+ }
242
+ appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
243
+ context.db.exec("RELEASE controller_failure_status");
244
+ }
245
+ catch (failure) {
246
+ context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
247
+ throw failure;
248
+ }
228
249
  throw error;
229
250
  }
230
251
  finally {
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { requireProjectByRoot } from "./projects.js";
8
- import { appendFlowRunTimelineEvent } from "./runs.js";
8
+ import { appendFlowRunTimelineEvent, setFlowRunRecoveryPaused } from "./runs.js";
9
9
  import { verifyRecoverySnapshot } from "./eval-snapshots.js";
10
10
  import { findRecentMatchingHookEvent, hookSessionIdentity, recoveryAcceptMatchKey } from "./hooks.js";
11
11
  import { flowCommand } from "./stage-pause.js";
@@ -123,6 +123,7 @@ export function beginRunRecovery(context, input) {
123
123
  context.db.run("UPDATE run_recovery_bindings SET status = 'abandoned' WHERE recovery_id = ? AND status = 'prepared'", [prior.recovery_id]);
124
124
  }
125
125
  context.db.run("INSERT INTO run_recovery_guards (recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at) VALUES (?, ?, ?, ?, 'draining', ?, NULL, NULL, ?, ?, NULL, NULL)", [recoveryId, project.id, run.id, generation, JSON.stringify(interruption), now, now]);
126
+ setFlowRunRecoveryPaused(context, project.id, run.id, true);
126
127
  context.db.exec("COMMIT");
127
128
  }
128
129
  catch (error) {
@@ -147,9 +148,18 @@ export function sealRunRecovery(context, input) {
147
148
  throw new AppError("recovery_invalid_state", "Recovery is not draining", 1, { recovery_id: guard.recovery_id, status: guard.status });
148
149
  materializePendingWorkResults(context, project.id, run.id);
149
150
  const now = context.now();
150
- const updated = context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining' AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [JSON.stringify(settlement), now, now, guard.recovery_id, project.id, run.id]);
151
- if (updated.changes !== 1)
152
- throw new AppError("recovery_state_changed", "Recovery state changed before it could be sealed", 1, { recovery_id: guard.recovery_id });
151
+ context.db.exec("SAVEPOINT seal_run_status");
152
+ try {
153
+ const updated = context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining' AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [JSON.stringify(settlement), now, now, guard.recovery_id, project.id, run.id]);
154
+ if (updated.changes !== 1)
155
+ throw new AppError("recovery_state_changed", "Recovery state changed before it could be sealed", 1, { recovery_id: guard.recovery_id });
156
+ setFlowRunRecoveryPaused(context, project.id, run.id, true);
157
+ context.db.exec("RELEASE seal_run_status");
158
+ }
159
+ catch (error) {
160
+ context.db.exec("ROLLBACK TO seal_run_status; RELEASE seal_run_status");
161
+ throw error;
162
+ }
153
163
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
154
164
  return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
155
165
  }
@@ -286,6 +296,7 @@ export function acceptRunRecovery(context, input) {
286
296
  }
287
297
  context.db.run("UPDATE run_recovery_bindings SET status = 'accepted', hook_event_key = ?, accepted_at = ? WHERE recovery_id = ?", [eventKey, now, binding.recovery_id]);
288
298
  context.db.run("UPDATE run_recovery_guards SET status = 'recovered', resumed_at = ?, updated_at = ? WHERE recovery_id = ?", [now, now, guard.recovery_id]);
299
+ setFlowRunRecoveryPaused(context, project.id, run.id, false);
289
300
  context.db.exec("COMMIT");
290
301
  }
291
302
  catch (error) {
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
6
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
- import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
8
+ import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
11
  import { buildRunFlowGuidance } from "./flow-guidance.js";
@@ -788,8 +788,24 @@ function operationalRunStatus(context, projectId, runId) {
788
788
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
789
789
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
790
790
  }
791
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
792
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
793
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
794
+ const run = resolveRun(context, projectId, runId);
795
+ if (!["running", "paused"].includes(run.status))
796
+ return;
797
+ const index = authoritativeIndex(run);
798
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
799
+ ? "paused" : operationalRunStatus(context, projectId, runId);
800
+ if (run.status === status && index.status === status)
801
+ return;
802
+ index.status = status;
803
+ index.updated_at = context.now();
804
+ const project = requireProjectByRoot(context, run.project_root);
805
+ persistRunState(context, project, run, index);
806
+ }
791
807
  export function completeFlowRun(context, input) {
792
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
808
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
793
809
  const run = resolveRun(context, project.id, input.runId);
794
810
  assertRunMutationAllowed(context, project.id, run.id);
795
811
  const index = authoritativeIndex(run);
@@ -856,8 +872,8 @@ export function advanceFlowRun(context, input) {
856
872
  index.next_action = control ? null : input.nextAction ?? null;
857
873
  index.updated_at = now;
858
874
  persistRunState(context, project, run, index);
859
- appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: input.status, verdict: input.verdict, next_action: index.next_action } });
860
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
875
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
876
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
861
877
  const updatedRun = requireRunById(context, project.id, run.id);
862
878
  return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
863
879
  }
@@ -1189,6 +1205,10 @@ function vnextSpecifyGuidance(run, index) {
1189
1205
  };
1190
1206
  }
1191
1207
  function persistRunState(context, project, run, index, options = {}) {
1208
+ // Fenced productive admission is a logical pause, even while physical work
1209
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1210
+ if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1211
+ index.status = "paused";
1192
1212
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1193
1213
  const previousRuntime = readRuntimeSnapshot(runtimePath);
1194
1214
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
@@ -1199,22 +1219,30 @@ function persistRunState(context, project, run, index, options = {}) {
1199
1219
  }
1200
1220
  const persistedIndex = persistedVnextIndex(index);
1201
1221
  ensureDir(path.dirname(runtimePath));
1202
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1203
- context.db.run(`UPDATE runs
1222
+ context.db.exec("SAVEPOINT persist_run_state");
1223
+ try {
1224
+ context.db.run(`UPDATE runs
1204
1225
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1205
1226
  updated_at = ?, completed_at = ?
1206
1227
  WHERE project_id = ? AND id = ?`, [
1207
- index.status,
1208
- index.verdict,
1209
- index.next_action,
1210
- JSON.stringify(persistedIndex),
1211
- runtimePath,
1212
- runtimePath,
1213
- index.updated_at,
1214
- index.completed_at ?? null,
1215
- project.id,
1216
- run.id
1217
- ]);
1228
+ index.status,
1229
+ index.verdict,
1230
+ index.next_action,
1231
+ JSON.stringify(persistedIndex),
1232
+ runtimePath,
1233
+ runtimePath,
1234
+ index.updated_at,
1235
+ index.completed_at ?? null,
1236
+ project.id,
1237
+ run.id
1238
+ ]);
1239
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1240
+ context.db.exec("RELEASE persist_run_state");
1241
+ }
1242
+ catch (error) {
1243
+ context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
1244
+ throw error;
1245
+ }
1218
1246
  }
1219
1247
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1220
1248
  function persistedVnextIndex(index) {
@@ -1576,5 +1604,5 @@ export function gitFacts(workspaceRoot) {
1576
1604
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1577
1605
  };
1578
1606
  }
1579
- import { assertRunMutationAllowed, assertStageSettlementAllowed } from "./run-recovery.js";
1607
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
1580
1608
  import { runControlDirective } from "./run-control.js";