@deksden-com/dd-flow-cli 0.9.0-beta.63 → 0.9.0-beta.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/run-cli.js +45 -4
- package/dist/harness-runtime/lib/native-hook-command.mjs +1 -0
- package/dist/harness-runtime/lib/process-json.mjs +15 -6
- package/dist/services/cleanup.js +1 -1
- package/dist/services/code-checks.js +1 -1
- package/dist/services/eval-snapshots.js +2 -2
- package/dist/services/harness-adapter.js +61 -9
- package/dist/services/hooks.js +26 -0
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-invocations.js +194 -22
- package/dist/services/managed-processes.js +2 -2
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +1 -1
- package/dist/services/migrations.js +1 -1
- package/dist/services/projects.js +1 -1
- package/dist/services/protocols.js +2 -2
- package/dist/services/run-control-worker.js +3 -3
- package/dist/services/run-control.js +1 -1
- package/dist/services/run-controller-adapter.js +8 -3
- package/dist/services/run-controller-capture.js +2 -2
- package/dist/services/run-controller-process.js +2 -2
- package/dist/services/run-controller-recovery.js +3 -3
- package/dist/services/run-controller.js +297 -180
- package/dist/services/run-recovery-runtime.js +1 -1
- package/dist/services/run-recovery.js +3 -3
- package/dist/services/runs.js +13 -14
- package/dist/services/runtime-budget.js +4 -4
- package/dist/services/runtime-scope-control.js +5 -5
- package/dist/services/runtime-scope-resume.js +7 -7
- package/dist/services/runtime-scope-worker.js +2 -2
- package/dist/services/stage-pause.js +2 -2
- package/dist/services/vnext-code-review.js +9 -4
- package/dist/services/vnext-code.js +10 -3
- package/dist/services/vnext-merge.js +16 -21
- package/dist/services/vnext-plan-review.js +4 -2
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/work-registry.js +198 -121
- package/dist/storage/database.js +112 -20
- package/dist/storage/writer-contract.js +2 -1
- package/dist/storage/writer-migration.js +2 -2
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
1
|
+
import { assertLifecycleOutcomes, managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { ensureRunControllerStorage } from "../storage/database.js";
|
|
7
|
+
import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
|
|
8
8
|
import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { AppError } from "../shared/errors.js";
|
|
10
10
|
import { parseRuntimeBudget } from "./runtime-budget.js";
|
|
@@ -27,6 +27,7 @@ import { spawnController, recordControllerLauncher, claimControllerProcess, mark
|
|
|
27
27
|
import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
|
|
28
28
|
import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
|
|
29
29
|
import { captureControllerBoundary } from "./run-controller-capture.js";
|
|
30
|
+
import { requestRunControl } from "./run-control-worker.js";
|
|
30
31
|
export { resumeRunController } from "./run-controller-recovery.js";
|
|
31
32
|
/** Persist intent before spawning. A repeated client observes the same owner. */
|
|
32
33
|
export async function launchRunController(context, input) {
|
|
@@ -86,7 +87,7 @@ export async function launchRunController(context, input) {
|
|
|
86
87
|
const token = crypto.randomUUID();
|
|
87
88
|
const now = context.now();
|
|
88
89
|
const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
|
|
89
|
-
context.db.
|
|
90
|
+
context.db.beginWriteTransaction();
|
|
90
91
|
try {
|
|
91
92
|
const duplicate = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]);
|
|
92
93
|
if (duplicate) {
|
|
@@ -201,6 +202,10 @@ export async function serveRunController(context, input) {
|
|
|
201
202
|
throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
|
|
202
203
|
};
|
|
203
204
|
const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
|
|
205
|
+
let primaryFailure;
|
|
206
|
+
const retainSecondary = (phase, error) => {
|
|
207
|
+
process.stderr.write(`${JSON.stringify({ controller_id: row.controller_id, phase, primary: errorRecord(primaryFailure), secondary: errorRecord(error) })}\n`);
|
|
208
|
+
};
|
|
204
209
|
try {
|
|
205
210
|
context.db.exec("SAVEPOINT controller_ready_status");
|
|
206
211
|
try {
|
|
@@ -220,37 +225,65 @@ export async function serveRunController(context, input) {
|
|
|
220
225
|
return controllerReceipt(requireController(context, row.controller_id));
|
|
221
226
|
}
|
|
222
227
|
catch (error) {
|
|
223
|
-
|
|
224
|
-
if ((error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code) || (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) !== row.generation)
|
|
225
|
-
&& context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
|
|
226
|
-
const guard = recoveryGuard(context, row.project_id, row.run_id);
|
|
227
|
-
const control = guard && ["draining", "sealed"].includes(guard.status) && context.db.get("SELECT control_id, action FROM run_controls WHERE project_id = ? AND run_id = ? AND recovery_id = ? AND status IN ('requested','settled')", [row.project_id, row.run_id, guard.recovery_id]);
|
|
228
|
-
if (control) {
|
|
229
|
-
context.db.run("UPDATE run_controllers SET status = 'control_requested', last_error_json = NULL, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
|
|
230
|
-
appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
|
|
231
|
-
return controllerReceipt(requireController(context, row.controller_id));
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
context.db.exec("SAVEPOINT controller_failure_status");
|
|
228
|
+
primaryFailure = error;
|
|
235
229
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
230
|
+
try {
|
|
231
|
+
failControllerRecovery(context, row, state, error);
|
|
232
|
+
}
|
|
233
|
+
catch (secondary) {
|
|
234
|
+
retainSecondary("fail_controller_recovery", secondary);
|
|
235
|
+
}
|
|
236
|
+
if ((error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code) || (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) !== row.generation)
|
|
237
|
+
&& context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
|
|
238
|
+
const guard = recoveryGuard(context, row.project_id, row.run_id);
|
|
239
|
+
const control = guard && ["draining", "sealed"].includes(guard.status) && context.db.get("SELECT control_id, action FROM run_controls WHERE project_id = ? AND run_id = ? AND recovery_id = ? AND status IN ('requested','settled')", [row.project_id, row.run_id, guard.recovery_id]);
|
|
240
|
+
if (control) {
|
|
241
|
+
// Control is a later state transition, not evidence that the causal
|
|
242
|
+
// failure which fenced admission disappeared.
|
|
243
|
+
context.db.run("UPDATE run_controllers SET status = CASE WHEN last_error_json IS NULL THEN 'control_requested' ELSE status END, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
|
|
244
|
+
appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
|
|
245
|
+
return controllerReceipt(requireController(context, row.controller_id));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
context.db.exec("SAVEPOINT controller_failure_status");
|
|
249
|
+
try {
|
|
250
|
+
// A failed capture must not reopen admission when this process exits.
|
|
251
|
+
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]);
|
|
252
|
+
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]);
|
|
253
|
+
if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
|
|
254
|
+
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
|
|
255
|
+
}
|
|
256
|
+
appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
|
|
257
|
+
context.db.exec("RELEASE controller_failure_status");
|
|
258
|
+
}
|
|
259
|
+
catch (failure) {
|
|
260
|
+
retainSecondary("persist_controller_failure", failure);
|
|
261
|
+
try {
|
|
262
|
+
context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
|
|
263
|
+
}
|
|
264
|
+
catch (rollback) {
|
|
265
|
+
retainSecondary("rollback_controller_failure", rollback);
|
|
266
|
+
}
|
|
241
267
|
}
|
|
242
|
-
appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
|
|
243
|
-
context.db.exec("RELEASE controller_failure_status");
|
|
244
268
|
}
|
|
245
|
-
catch (
|
|
246
|
-
|
|
247
|
-
throw failure;
|
|
269
|
+
catch (secondary) {
|
|
270
|
+
retainSecondary("controller_failure_boundary", secondary);
|
|
248
271
|
}
|
|
249
272
|
throw error;
|
|
250
273
|
}
|
|
251
274
|
finally {
|
|
252
275
|
clearInterval(heartbeat);
|
|
253
|
-
|
|
276
|
+
finishOwner();
|
|
277
|
+
}
|
|
278
|
+
function finishOwner() {
|
|
279
|
+
try {
|
|
280
|
+
finishManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, state: "stopped", reason: "controller_owner_exited" });
|
|
281
|
+
}
|
|
282
|
+
catch (cleanup) {
|
|
283
|
+
if (!primaryFailure)
|
|
284
|
+
throw cleanup;
|
|
285
|
+
retainSecondary("finish_controller_process", cleanup);
|
|
286
|
+
}
|
|
254
287
|
}
|
|
255
288
|
}
|
|
256
289
|
/** Public HITL input: persist raw bytes, never deliver a turn from this client. */
|
|
@@ -267,7 +300,7 @@ export function answerRunController(context, input) {
|
|
|
267
300
|
const operationId = `${row.controller_id}:answer:${input.requestId}`;
|
|
268
301
|
const answerHash = digestBytes(bytes);
|
|
269
302
|
const hash = digest({ pause_id: input.pauseId, answer_sha256: answerHash });
|
|
270
|
-
context.db.
|
|
303
|
+
context.db.beginWriteTransaction();
|
|
271
304
|
try {
|
|
272
305
|
const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
|
|
273
306
|
if (prior) {
|
|
@@ -319,7 +352,7 @@ export function supplyRunControllerContext(context, input) {
|
|
|
319
352
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
320
353
|
const operationId = `${input.controllerId}:context:${input.requestId}`;
|
|
321
354
|
const hash = digest({ stage: input.stage, sha256: input.contextSha256 });
|
|
322
|
-
context.db.
|
|
355
|
+
context.db.beginWriteTransaction();
|
|
323
356
|
try {
|
|
324
357
|
const row = requireController(context, input.controllerId);
|
|
325
358
|
if (row.project_id !== project.id || row.run_id !== input.runId)
|
|
@@ -364,7 +397,12 @@ export function supplyRunControllerContext(context, input) {
|
|
|
364
397
|
throw error;
|
|
365
398
|
}
|
|
366
399
|
}
|
|
367
|
-
async function executeController(context, row, manifest, state,
|
|
400
|
+
async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner) {
|
|
401
|
+
assertOwnership();
|
|
402
|
+
const observer = { ...context, db: getDatabase(context.ddFlowHome, "read_existing") };
|
|
403
|
+
const checkOutcomes = () => assertLifecycleOutcomes(observer, { projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
|
|
404
|
+
const assertOwner = () => { if (fatal)
|
|
405
|
+
throw fatal; checkOutcomes(); assertOwnership(); };
|
|
368
406
|
const policy = manifest.execution_profile.settings.execution;
|
|
369
407
|
const profiles = manifest.execution_profile.agent_profiles;
|
|
370
408
|
let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined } };
|
|
@@ -373,12 +411,40 @@ async function executeController(context, row, manifest, state, assertOwner, ass
|
|
|
373
411
|
projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
|
|
374
412
|
};
|
|
375
413
|
let lastCapture = null;
|
|
414
|
+
let fatal;
|
|
415
|
+
let stopping;
|
|
416
|
+
const cancellation = new AbortController();
|
|
417
|
+
const stopAfterFailure = (error) => {
|
|
418
|
+
if (stopping)
|
|
419
|
+
return stopping;
|
|
420
|
+
fatal = error;
|
|
421
|
+
// Persist the cause before fencing/cancellation; later control is not an
|
|
422
|
+
// operator pause and cannot replace this execution failure.
|
|
423
|
+
stopping = (async () => {
|
|
424
|
+
try {
|
|
425
|
+
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]);
|
|
426
|
+
}
|
|
427
|
+
catch (persistence) {
|
|
428
|
+
process.stderr.write(`${JSON.stringify({ primary: errorRecord(error), persistence: errorRecord(persistence) })}\n`);
|
|
429
|
+
}
|
|
430
|
+
await requestRunControl(executionContext, { projectRoot: manifest.request.project_root, runId: row.run_id, requestId: `fatal:${row.controller_id}:${row.generation}`, action: "stop", waitMs: 0 });
|
|
431
|
+
})().catch(cleanup => {
|
|
432
|
+
// The caller still receives the primary failure if control cannot start.
|
|
433
|
+
try {
|
|
434
|
+
appendEvent(context, row.controller_id, "failure_control_error", { primary: errorRecord(error), cleanup: errorRecord(cleanup) });
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
process.stderr.write(`${JSON.stringify({ primary: errorRecord(error), cleanup: errorRecord(cleanup) })}\n`);
|
|
438
|
+
}
|
|
439
|
+
}).finally(() => cancellation.abort());
|
|
440
|
+
return stopping;
|
|
441
|
+
};
|
|
376
442
|
const setStatus = (status) => {
|
|
377
443
|
const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
|
|
378
444
|
if (changed.changes)
|
|
379
445
|
appendEvent(context, row.controller_id, "status_changed", { status, stage: state.stage });
|
|
380
446
|
};
|
|
381
|
-
const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined);
|
|
447
|
+
const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined, undefined, productive ? cancellation.signal : undefined);
|
|
382
448
|
const confirmStopped = async (stateDir, receipt) => {
|
|
383
449
|
const fail = () => new AppError("controller_native_shutdown_unproven", "The managed daemon is not proven clean and closed", 1, { state_dir: stateDir, receipt });
|
|
384
450
|
if (receipt.stopped !== true || receipt.clean !== true)
|
|
@@ -413,178 +479,229 @@ async function executeController(context, row, manifest, state, assertOwner, ass
|
|
|
413
479
|
fs.writeFileSync(file, `${text}\n`, { flag: "wx", mode: 0o600 });
|
|
414
480
|
state.turns += 1;
|
|
415
481
|
saveState(context, row, state);
|
|
416
|
-
|
|
482
|
+
let receipt;
|
|
483
|
+
try {
|
|
484
|
+
const adapter = controllerAdapter(executionContext, row, manifest, session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], assertOwner, operationId, cancellation.signal);
|
|
485
|
+
try {
|
|
486
|
+
receipt = await adapter;
|
|
487
|
+
checkOutcomes();
|
|
488
|
+
if (fatal)
|
|
489
|
+
throw fatal;
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (!fatal && error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale", "controller_owner_mismatch"].includes(error.code))
|
|
493
|
+
throw error;
|
|
494
|
+
await stopAfterFailure(error);
|
|
495
|
+
cancellation.abort();
|
|
496
|
+
await adapter.catch(() => { });
|
|
497
|
+
throw fatal ?? error;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
finally { /* Adapter settlement is joined before returning. */ }
|
|
417
501
|
state.last_receipt = receipt;
|
|
418
502
|
saveState(context, row, state);
|
|
419
503
|
return receipt;
|
|
420
504
|
};
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (state.current_session !== null && !state.sessions[state.current_session].stopped)
|
|
424
|
-
bindInvocationScope(state.sessions[state.current_session]);
|
|
425
|
-
const run = requireRun(context, row.project_id, row.run_id);
|
|
426
|
-
const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
|
|
427
|
-
const next = view.continuation;
|
|
428
|
-
const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
|
|
429
|
-
if (completed && manifest.request.capture_root && ["start_stage", "terminal"].includes(next.kind)) {
|
|
430
|
-
const key = JSON.stringify([completed.stage, completed.attempt, completed.started_at, completed.completed_at]);
|
|
431
|
-
if (key !== lastCapture) {
|
|
432
|
-
await captureControllerBoundary(executionContext, row, manifest, completed, next.kind === "start_stage" ? next.stage : null, assertPhysicalOwner);
|
|
433
|
-
lastCapture = key;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
if (completed && manifest.request.stop_after === state.stage) {
|
|
437
|
-
await settle();
|
|
438
|
-
setStatus("stop_target_reached");
|
|
439
|
-
appendEvent(context, row.controller_id, "stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
|
|
505
|
+
const monitor = setInterval(() => {
|
|
506
|
+
if (fatal)
|
|
440
507
|
return;
|
|
508
|
+
try {
|
|
509
|
+
checkOutcomes();
|
|
441
510
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
setStatus(run.status === "cancelled" ? "cancelled" : "completed");
|
|
445
|
-
appendEvent(context, row.controller_id, "terminal", { reason: next.reason });
|
|
446
|
-
return;
|
|
511
|
+
catch (error) {
|
|
512
|
+
void stopAfterFailure(error);
|
|
447
513
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
throw new AppError("controller_hitl_missing", "Paused RUN has no managed Session or HITL receipt", 1);
|
|
453
|
-
const answer = context.db.get("SELECT operation_id, input_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
|
|
454
|
-
if (!answer) {
|
|
455
|
-
await delay(1000);
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
const input = JSON.parse(answer.input_json);
|
|
459
|
-
if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
|
|
460
|
-
throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
|
|
514
|
+
}, 1000);
|
|
515
|
+
monitor.unref();
|
|
516
|
+
try {
|
|
517
|
+
for (;;) {
|
|
461
518
|
assertOwner();
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
state.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (next.kind === "start_stage") {
|
|
475
|
-
// Only context slices can be added; profiles and launch inputs stay frozen.
|
|
476
|
-
manifest.contexts = JSON.parse(requireController(context, row.controller_id).manifest_json).contexts;
|
|
477
|
-
const stageContext = manifest.contexts[next.stage];
|
|
478
|
-
if (!stageContext) {
|
|
479
|
-
if (requireController(context, row.controller_id).status !== "waiting_for_context")
|
|
480
|
-
appendEvent(context, row.controller_id, "context_required", { stage: next.stage, attempt: next.attempt ?? null });
|
|
481
|
-
setStatus("waiting_for_context");
|
|
482
|
-
await delay(1000);
|
|
483
|
-
continue;
|
|
519
|
+
if (state.current_session !== null && !state.sessions[state.current_session].stopped)
|
|
520
|
+
bindInvocationScope(state.sessions[state.current_session]);
|
|
521
|
+
const run = requireRun(context, row.project_id, row.run_id);
|
|
522
|
+
const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
|
|
523
|
+
const next = view.continuation;
|
|
524
|
+
const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
|
|
525
|
+
if (completed && manifest.request.capture_root && ["start_stage", "terminal"].includes(next.kind)) {
|
|
526
|
+
const key = JSON.stringify([completed.stage, completed.attempt, completed.started_at, completed.completed_at]);
|
|
527
|
+
if (key !== lastCapture) {
|
|
528
|
+
await captureControllerBoundary(executionContext, row, manifest, completed, next.kind === "start_stage" ? next.stage : null, assertPhysicalOwner);
|
|
529
|
+
lastCapture = key;
|
|
530
|
+
}
|
|
484
531
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
532
|
+
if (completed && manifest.request.stop_after === state.stage) {
|
|
533
|
+
await settle();
|
|
534
|
+
setStatus("stop_target_reached");
|
|
535
|
+
appendEvent(context, row.controller_id, "stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (next.kind === "terminal") {
|
|
539
|
+
await settle();
|
|
540
|
+
setStatus(run.status === "cancelled" ? "cancelled" : "completed");
|
|
541
|
+
appendEvent(context, row.controller_id, "terminal", { reason: next.reason });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (next.kind === "paused") {
|
|
545
|
+
setStatus("waiting_for_user");
|
|
546
|
+
const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
|
|
547
|
+
if (!pause || state.current_session === null)
|
|
548
|
+
throw new AppError("controller_hitl_missing", "Paused RUN has no managed Session or HITL receipt", 1);
|
|
549
|
+
const answer = context.db.get("SELECT operation_id, input_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
|
|
550
|
+
if (!answer) {
|
|
494
551
|
await delay(1000);
|
|
495
552
|
continue;
|
|
496
553
|
}
|
|
497
|
-
|
|
554
|
+
const input = JSON.parse(answer.input_json);
|
|
555
|
+
if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
|
|
556
|
+
throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
|
|
557
|
+
assertOwner();
|
|
558
|
+
// The accepted answer is logical intent; only its generation-bound
|
|
559
|
+
// adapter operation represents a physical dispatch that stop must drain.
|
|
560
|
+
const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`);
|
|
561
|
+
const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
|
|
562
|
+
context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
|
|
563
|
+
state.last_continuation = null;
|
|
498
564
|
saveState(context, row, state);
|
|
499
|
-
|
|
565
|
+
setStatus("running");
|
|
566
|
+
continue;
|
|
500
567
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
568
|
+
if (next.kind === "blocked" || next.kind === "wait" || !next.stage)
|
|
569
|
+
throw new AppError("controller_continuation_blocked", "RUN has no legal managed continuation", 1, { continuation: next });
|
|
570
|
+
if (next.kind === "start_stage") {
|
|
571
|
+
// Only context slices can be added; profiles and launch inputs stay frozen.
|
|
572
|
+
manifest.contexts = JSON.parse(requireController(context, row.controller_id).manifest_json).contexts;
|
|
573
|
+
const stageContext = manifest.contexts[next.stage];
|
|
574
|
+
if (!stageContext) {
|
|
575
|
+
if (requireController(context, row.controller_id).status !== "waiting_for_context")
|
|
576
|
+
appendEvent(context, row.controller_id, "context_required", { stage: next.stage, attempt: next.attempt ?? null });
|
|
577
|
+
setStatus("waiting_for_context");
|
|
578
|
+
await delay(1000);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
|
|
582
|
+
const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
|
|
583
|
+
if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
|
|
584
|
+
const request = context.db.get("SELECT merge_request_id, status FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC, merge_request_id DESC LIMIT 1", [row.project_id, row.run_id]);
|
|
585
|
+
if (!request || request.status !== "queued")
|
|
586
|
+
throw new AppError("controller_attach_required", "Managed MERGE needs an unclaimed queued request; reconcile an existing owner", 1, { request });
|
|
587
|
+
const claim = claimQueuedMergeRequest(context, { requestId: request.merge_request_id, ownerId: row.controller_id, expectedGeneration: row.generation });
|
|
588
|
+
if (!claim) {
|
|
589
|
+
setStatus("waiting_for_merge");
|
|
590
|
+
await delay(1000);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
state.merge_claim = { request_id: claim.request.merge_request_id, token: claim.token, target_workspace: claim.request.target_workspace, work_id: claim.request.executor_work_id };
|
|
594
|
+
saveState(context, row, state);
|
|
595
|
+
appendEvent(context, row.controller_id, "merge_claimed", { request_id: claim.request.merge_request_id });
|
|
512
596
|
}
|
|
513
|
-
session =
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
|
|
597
|
+
let session = state.current_session === null ? null : state.sessions[state.current_session];
|
|
598
|
+
if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator)) {
|
|
599
|
+
if (session)
|
|
600
|
+
await stopSession(session);
|
|
601
|
+
assertOwner();
|
|
602
|
+
const directory = path.join(row.state_dir, `session-${state.sessions.length + 1}`);
|
|
603
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
604
|
+
const owner = { schema_id: "dd-flow/runtime-owner@1", owner_id: row.controller_id, role: "subject", project_id: row.project_id, run_id: row.run_id, operation_id: `${row.controller_id}:session:${state.sessions.length + 1}`, state_dir: directory, dd_flow_home: context.ddFlowHome, dd_flow_bin: manifest.flow_executable, resource_home: manifest.resource_home, budget: manifest.budget };
|
|
605
|
+
if (next.stage === "merge" && state.merge_claim) {
|
|
606
|
+
owner.role = "merge";
|
|
607
|
+
owner.work_id = state.merge_claim.work_id;
|
|
608
|
+
}
|
|
609
|
+
session = { id: "", profile: transition.coordinator, state_dir: directory, cwd: next.stage === "merge" && state.merge_claim ? state.merge_claim.target_workspace : run.workspace_root, journal: path.join(directory, "adapter.events.jsonl"), owner };
|
|
610
|
+
if (runtimeHarness(session.profile) === "codex-desktop") {
|
|
611
|
+
session.codex_home = path.join(directory, "codex-home");
|
|
612
|
+
initCodexHome(context, { projectRoot: run.project_root, profile: `controller-${digest(directory).slice(0, 16)}`, targetHome: session.codex_home, ...(context.env.CODEX_HOME ? { sourceHome: context.env.CODEX_HOME } : {}) });
|
|
613
|
+
}
|
|
614
|
+
state.sessions.push(session);
|
|
615
|
+
state.current_session = state.sessions.length - 1;
|
|
616
|
+
saveState(context, row, state);
|
|
617
|
+
await call(session, ["daemon", "start", ...sessionArguments(context, manifest, session)]);
|
|
618
|
+
const created = await call(session, ["session", "create", ...sessionArguments(context, manifest, session)]);
|
|
619
|
+
const sessionId = adapterSessionId(created);
|
|
620
|
+
if (!sessionId)
|
|
621
|
+
throw new AppError("harness_adapter_invalid", "Managed adapter did not return its Session id", 1);
|
|
622
|
+
session.id = sessionId;
|
|
623
|
+
saveState(context, row, state);
|
|
624
|
+
bindInvocationScope(session);
|
|
625
|
+
if (next.stage === "merge" && state.merge_claim)
|
|
626
|
+
context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify({ controller_id: row.controller_id, profile: session.profile, session_id: session.id, state_dir: session.state_dir, create: created }), context.now(), state.merge_claim.request_id, row.controller_id, state.merge_claim.token]);
|
|
627
|
+
appendEvent(context, row.controller_id, "session_created", { stage: next.stage, session_id: sessionId, harness: runtimeHarness(session.profile), profile: session.profile });
|
|
517
628
|
}
|
|
518
|
-
state.
|
|
519
|
-
state.
|
|
629
|
+
state.native_baseline = controllerNativeChildren(state.last_receipt ?? {}, session.id).map(child => child.session_id);
|
|
630
|
+
state.stage = next.stage;
|
|
631
|
+
state.last_continuation = null;
|
|
520
632
|
saveState(context, row, state);
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
633
|
+
setStatus("running");
|
|
634
|
+
const responseFile = path.join(row.state_dir, `${next.stage}-${next.attempt ?? 1}.stage-start-response.json`);
|
|
635
|
+
const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage start ${run.id} --stage ${next.stage} --project-root ${JSON.stringify(run.project_root)} --context-file ${JSON.stringify(stageContext.file)} --context-sha256 ${stageContext.sha256} --require-session-binding --response-file ${JSON.stringify(responseFile)} --json --progress-jsonl`);
|
|
636
|
+
appendEvent(context, row.controller_id, "stage_entered", { stage: next.stage, session_id: session.id, context_sha256: stageContext.sha256 });
|
|
637
|
+
await prompt(session, `Start the assigned ${next.stage} Stage now. Your first tool call must be this exact standalone command:\n\n${command}\n\nRead the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read before doing Stage work. Stop at this Stage boundary or a declared HITL/Work-graph boundary; do not start a successor Stage yourself.`);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (state.current_session === null)
|
|
641
|
+
throw new AppError("controller_attach_required", "An unfinished Stage has no managed coordinator", 1);
|
|
642
|
+
const session = state.sessions[state.current_session];
|
|
643
|
+
const baseline = new Set(state.native_baseline ?? []);
|
|
644
|
+
const children = controllerNativeChildren(state.last_receipt ?? {}, session.id).filter(child => !baseline.has(child.session_id));
|
|
645
|
+
const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles });
|
|
646
|
+
assertOwner();
|
|
647
|
+
if (fanout.kind === "none")
|
|
648
|
+
throw new AppError("incomplete_subject_turn", "Managed Turn ended without a completed Stage or declared continuation", 1, { stage: next.stage });
|
|
649
|
+
if (fanout.kind === "wait") {
|
|
650
|
+
setStatus(fanout.reason === "qualified_capacity_required" ? "waiting_for_capacity" : "waiting_for_children");
|
|
651
|
+
await delay(1000);
|
|
652
|
+
state.last_receipt = await call(session, ["session", "inspect", "--session-id", session.id, "--state-dir", session.state_dir, "--json"], false);
|
|
527
653
|
saveState(context, row, state);
|
|
528
|
-
|
|
529
|
-
if (next.stage === "merge" && state.merge_claim)
|
|
530
|
-
context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify({ controller_id: row.controller_id, profile: session.profile, session_id: session.id, state_dir: session.state_dir, create: created }), context.now(), state.merge_claim.request_id, row.controller_id, state.merge_claim.token]);
|
|
531
|
-
appendEvent(context, row.controller_id, "session_created", { stage: next.stage, session_id: sessionId, harness: runtimeHarness(session.profile), profile: session.profile });
|
|
654
|
+
continue;
|
|
532
655
|
}
|
|
533
|
-
state.native_baseline = controllerNativeChildren(state.last_receipt ?? {}, session.id).map(child => child.session_id);
|
|
534
|
-
state.stage = next.stage;
|
|
535
|
-
state.last_continuation = null;
|
|
536
|
-
saveState(context, row, state);
|
|
537
656
|
setStatus("running");
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const executable = manifest.adapters[runtimeHarness(resolved.worker)].executable;
|
|
572
|
-
const stopped = await runHarnessAdapter({ executable, args: ["daemon", "stop", "--state-dir", launch.state_dir, "--json"], env: controllerEnvironment(context, manifest), operationId: `${row.controller_id}:${work.work_id}:settle` });
|
|
573
|
-
await confirmStopped(launch.state_dir, stopped);
|
|
574
|
-
appendEvent(context, row.controller_id, "external_work_settled", { work_id: work.work_id, receipt: stopped });
|
|
575
|
-
}));
|
|
576
|
-
const failure = results.find((result) => result.status === "rejected");
|
|
577
|
-
if (failure)
|
|
578
|
-
throw failure.reason;
|
|
579
|
-
state.last_continuation = null;
|
|
657
|
+
if (fanout.kind === "external") {
|
|
658
|
+
const results = await Promise.allSettled(fanout.works.map(async (work) => {
|
|
659
|
+
try {
|
|
660
|
+
assertOwner();
|
|
661
|
+
const receipt = await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage });
|
|
662
|
+
if (fatal)
|
|
663
|
+
throw fatal;
|
|
664
|
+
appendEvent(context, row.controller_id, "external_work_observed", { work_id: work.work_id, receipt });
|
|
665
|
+
const launch = context.db.get("SELECT state_dir, profile_json FROM work_launches WHERE work_id = ?", [work.work_id]);
|
|
666
|
+
const status = context.db.get("SELECT status FROM works WHERE work_id = ?", [work.work_id])?.status;
|
|
667
|
+
if (!launch || !["completed", "failed", "cancelled"].includes(status ?? ""))
|
|
668
|
+
throw new AppError("external_work_unsettled", "External launch returned without its terminal Work receipt; observe it rather than relaunching", 1, { work_id: work.work_id, status });
|
|
669
|
+
const resolved = JSON.parse(launch.profile_json);
|
|
670
|
+
const executable = manifest.adapters[runtimeHarness(resolved.worker)].executable;
|
|
671
|
+
const stopped = await runHarnessAdapter({ executable, args: ["daemon", "stop", "--state-dir", launch.state_dir, "--json"], env: controllerEnvironment(context, manifest), operationId: `${row.controller_id}:${work.work_id}:settle` });
|
|
672
|
+
await confirmStopped(launch.state_dir, stopped);
|
|
673
|
+
appendEvent(context, row.controller_id, "external_work_settled", { work_id: work.work_id, receipt: stopped });
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
await stopAfterFailure(error);
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
}));
|
|
680
|
+
const rejected = results.find((result) => result.status === "rejected");
|
|
681
|
+
if (rejected)
|
|
682
|
+
throw fatal ?? rejected.reason;
|
|
683
|
+
state.last_continuation = null;
|
|
684
|
+
saveState(context, row, state);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (state.last_continuation === fanout.fingerprint)
|
|
688
|
+
throw new AppError("fanout_stage_nonprogressing", "The coordinator repeated an unchanged Work-graph continuation", 1, { stage: next.stage });
|
|
689
|
+
state.last_continuation = fanout.fingerprint;
|
|
580
690
|
saveState(context, row, state);
|
|
581
|
-
|
|
691
|
+
await prompt(session, fanout.prompt);
|
|
582
692
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
await
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
if (!fatal && error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale", "controller_owner_mismatch"].includes(error.code))
|
|
696
|
+
throw error;
|
|
697
|
+
await stopAfterFailure(fatal ?? error);
|
|
698
|
+
throw fatal ?? error;
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
clearInterval(monitor);
|
|
702
|
+
if (stopping)
|
|
703
|
+
await stopping;
|
|
704
|
+
observer.db.close?.();
|
|
588
705
|
}
|
|
589
706
|
}
|
|
590
707
|
function readContexts(file, expected) {
|
|
@@ -12,7 +12,7 @@ export function prepareRunRecoveryDispatch(context, input) {
|
|
|
12
12
|
throw new AppError("usage", "Recovery dispatch requires an operation id and absolute state directory", 2);
|
|
13
13
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
14
14
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
15
|
-
context.db.
|
|
15
|
+
context.db.beginWriteTransaction();
|
|
16
16
|
try {
|
|
17
17
|
const guard = recoveryGuard(context, project.id, run.id);
|
|
18
18
|
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
|