@deksden-com/dd-flow-cli 0.9.0-beta.63 → 0.9.0-beta.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/run-cli.js +45 -4
  4. package/dist/harness-runtime/lib/native-hook-command.mjs +1 -0
  5. package/dist/harness-runtime/lib/process-json.mjs +15 -6
  6. package/dist/services/cleanup.js +1 -1
  7. package/dist/services/code-checks.js +1 -1
  8. package/dist/services/eval-snapshots.js +2 -2
  9. package/dist/services/harness-adapter.js +61 -9
  10. package/dist/services/hooks.js +26 -0
  11. package/dist/services/lanes.js +1 -1
  12. package/dist/services/lifecycle-invocations.js +194 -22
  13. package/dist/services/managed-processes.js +2 -2
  14. package/dist/services/merge-queue.js +6 -6
  15. package/dist/services/merge-server.js +1 -1
  16. package/dist/services/migrations.js +1 -1
  17. package/dist/services/projects.js +1 -1
  18. package/dist/services/protocols.js +2 -2
  19. package/dist/services/run-control-worker.js +3 -3
  20. package/dist/services/run-control.js +1 -1
  21. package/dist/services/run-controller-adapter.js +8 -3
  22. package/dist/services/run-controller-capture.js +2 -2
  23. package/dist/services/run-controller-process.js +2 -2
  24. package/dist/services/run-controller-recovery.js +3 -3
  25. package/dist/services/run-controller.js +297 -180
  26. package/dist/services/run-recovery-runtime.js +1 -1
  27. package/dist/services/run-recovery.js +3 -3
  28. package/dist/services/runs.js +13 -14
  29. package/dist/services/runtime-budget.js +4 -4
  30. package/dist/services/runtime-scope-control.js +5 -5
  31. package/dist/services/runtime-scope-resume.js +7 -7
  32. package/dist/services/runtime-scope-worker.js +2 -2
  33. package/dist/services/stage-pause.js +2 -2
  34. package/dist/services/vnext-code-review.js +9 -4
  35. package/dist/services/vnext-code.js +10 -3
  36. package/dist/services/vnext-merge.js +16 -21
  37. package/dist/services/vnext-plan-review.js +4 -2
  38. package/dist/services/vnext-plan.js +1 -1
  39. package/dist/services/work-registry.js +198 -121
  40. package/dist/storage/database.js +112 -20
  41. package/dist/storage/writer-contract.js +2 -1
  42. package/dist/storage/writer-migration.js +2 -2
  43. package/package.json +1 -1
@@ -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.exec("BEGIN IMMEDIATE");
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
- failControllerRecovery(context, row, state, error);
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
- // 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);
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 (failure) {
246
- context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
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
- finishManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, state: "stopped", reason: "controller_owner_exited" });
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.exec("BEGIN IMMEDIATE");
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.exec("BEGIN IMMEDIATE");
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, assertOwner, assertPhysicalOwner) {
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
- const receipt = await controllerAdapter(executionContext, row, manifest, session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], assertOwner, operationId);
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
- for (;;) {
422
- assertOwner();
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
- if (next.kind === "terminal") {
443
- await settle();
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
- if (next.kind === "paused") {
449
- setStatus("waiting_for_user");
450
- const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
451
- if (!pause || state.current_session === null)
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
- // The accepted answer is logical intent; only its generation-bound
463
- // adapter operation represents a physical dispatch that stop must drain.
464
- 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)}`);
465
- 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}`);
466
- 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]);
467
- state.last_continuation = null;
468
- saveState(context, row, state);
469
- setStatus("running");
470
- continue;
471
- }
472
- if (next.kind === "blocked" || next.kind === "wait" || !next.stage)
473
- throw new AppError("controller_continuation_blocked", "RUN has no legal managed continuation", 1, { continuation: next });
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
- loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
486
- 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 });
487
- if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
488
- 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]);
489
- if (!request || request.status !== "queued")
490
- throw new AppError("controller_attach_required", "Managed MERGE needs an unclaimed queued request; reconcile an existing owner", 1, { request });
491
- const claim = claimQueuedMergeRequest(context, { requestId: request.merge_request_id, ownerId: row.controller_id, expectedGeneration: row.generation });
492
- if (!claim) {
493
- setStatus("waiting_for_merge");
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
- 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 };
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
- appendEvent(context, row.controller_id, "merge_claimed", { request_id: claim.request.merge_request_id });
565
+ setStatus("running");
566
+ continue;
500
567
  }
501
- let session = state.current_session === null ? null : state.sessions[state.current_session];
502
- if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator)) {
503
- if (session)
504
- await stopSession(session);
505
- assertOwner();
506
- const directory = path.join(row.state_dir, `session-${state.sessions.length + 1}`);
507
- fs.mkdirSync(directory, { mode: 0o700 });
508
- 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 };
509
- if (next.stage === "merge" && state.merge_claim) {
510
- owner.role = "merge";
511
- owner.work_id = state.merge_claim.work_id;
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 = { 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 };
514
- if (runtimeHarness(session.profile) === "codex-desktop") {
515
- session.codex_home = path.join(directory, "codex-home");
516
- 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 } : {}) });
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.sessions.push(session);
519
- state.current_session = state.sessions.length - 1;
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
- await call(session, ["daemon", "start", ...sessionArguments(context, manifest, session)]);
522
- const created = await call(session, ["session", "create", ...sessionArguments(context, manifest, session)]);
523
- const sessionId = adapterSessionId(created);
524
- if (!sessionId)
525
- throw new AppError("harness_adapter_invalid", "Managed adapter did not return its Session id", 1);
526
- session.id = sessionId;
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
- bindInvocationScope(session);
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
- const responseFile = path.join(row.state_dir, `${next.stage}-${next.attempt ?? 1}.stage-start-response.json`);
539
- 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`);
540
- appendEvent(context, row.controller_id, "stage_entered", { stage: next.stage, session_id: session.id, context_sha256: stageContext.sha256 });
541
- 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.`);
542
- continue;
543
- }
544
- if (state.current_session === null)
545
- throw new AppError("controller_attach_required", "An unfinished Stage has no managed coordinator", 1);
546
- const session = state.sessions[state.current_session];
547
- const baseline = new Set(state.native_baseline ?? []);
548
- const children = controllerNativeChildren(state.last_receipt ?? {}, session.id).filter(child => !baseline.has(child.session_id));
549
- 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 });
550
- assertOwner();
551
- if (fanout.kind === "none")
552
- throw new AppError("incomplete_subject_turn", "Managed Turn ended without a completed Stage or declared continuation", 1, { stage: next.stage });
553
- if (fanout.kind === "wait") {
554
- setStatus(fanout.reason === "qualified_capacity_required" ? "waiting_for_capacity" : "waiting_for_children");
555
- await delay(1000);
556
- state.last_receipt = await call(session, ["session", "inspect", "--session-id", session.id, "--state-dir", session.state_dir, "--json"], false);
557
- saveState(context, row, state);
558
- continue;
559
- }
560
- setStatus("running");
561
- if (fanout.kind === "external") {
562
- const results = await Promise.allSettled(fanout.works.map(async (work) => {
563
- assertOwner();
564
- const receipt = await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage });
565
- appendEvent(context, row.controller_id, "external_work_observed", { work_id: work.work_id, receipt });
566
- const launch = context.db.get("SELECT state_dir, profile_json FROM work_launches WHERE work_id = ?", [work.work_id]);
567
- const status = context.db.get("SELECT status FROM works WHERE work_id = ?", [work.work_id])?.status;
568
- if (!launch || !["completed", "failed", "cancelled"].includes(status ?? ""))
569
- 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 });
570
- const resolved = JSON.parse(launch.profile_json);
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
- continue;
691
+ await prompt(session, fanout.prompt);
582
692
  }
583
- if (state.last_continuation === fanout.fingerprint)
584
- throw new AppError("fanout_stage_nonprogressing", "The coordinator repeated an unchanged Work-graph continuation", 1, { stage: next.stage });
585
- state.last_continuation = fanout.fingerprint;
586
- saveState(context, row, state);
587
- await prompt(session, fanout.prompt);
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.exec("BEGIN IMMEDIATE");
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]);