@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.
- package/CHANGELOG.md +43 -0
- package/README.md +22 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/help.js +2 -0
- package/dist/cli/run-cli.js +13 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +17 -3
- package/dist/harness-runtime/lib/dd-zcode.mjs +57 -18
- package/dist/services/harness-adapter.js +2 -1
- package/dist/services/hooks.js +15 -7
- package/dist/services/lifecycle-invocations.js +84 -14
- package/dist/services/native-session-control.js +6 -4
- package/dist/services/recovery-observation-budget.js +53 -0
- package/dist/services/run-control-worker.js +55 -8
- package/dist/services/run-control.js +33 -9
- package/dist/services/run-controller-capture.js +20 -3
- package/dist/services/run-controller-process.js +12 -1
- package/dist/services/run-controller-state.js +1 -1
- package/dist/services/run-controller.js +27 -6
- package/dist/services/run-recovery.js +15 -4
- package/dist/services/runs.js +46 -18
- package/dist/services/runtime-scope-control.js +20 -8
- package/dist/services/runtime-scope-worker.js +50 -5
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
269
|
-
|
|
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(
|
|
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
|
|
463
|
-
|
|
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
|
-
|
|
471
|
-
context.db.run("UPDATE run_control_operations SET status = 'outcome_unknown', error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(
|
|
472
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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) {
|
package/dist/services/runs.js
CHANGED
|
@@ -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:
|
|
860
|
-
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status:
|
|
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
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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";
|