@deksden-com/dd-flow-cli 0.9.0-beta.145 → 0.9.0-beta.149

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 (101) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/build-info.json +7 -7
  3. package/dist/cli/command-inputs.js +6 -2
  4. package/dist/cli/command-metadata.js +18 -2
  5. package/dist/cli/diagnostic-invocation.js +46 -0
  6. package/dist/cli/diagnostic.js +120 -0
  7. package/dist/cli/help.js +35 -0
  8. package/dist/cli/memory-flow-admission.js +4 -2
  9. package/dist/cli/memory-init.js +5 -2
  10. package/dist/cli/run-cli.js +16 -15
  11. package/dist/cli.js +6 -0
  12. package/dist/domain/diagnostic-aspects.js +103 -0
  13. package/dist/domain/diagnostic-flows.js +8 -0
  14. package/dist/harness-runtime/lib/agy-native-inspection.mjs +216 -0
  15. package/dist/harness-runtime/lib/agy-native_pb.js +117 -0
  16. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +84 -10
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +306 -34
  19. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +27 -13
  20. package/dist/harness-runtime/lib/dd-codex.mjs +4 -0
  21. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +4 -3
  22. package/dist/harness-runtime/lib/dd-droid.mjs +70 -28
  23. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -3
  24. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +66 -20
  25. package/dist/harness-runtime/lib/dd-opencode.mjs +30 -3
  26. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +7 -6
  27. package/dist/harness-runtime/lib/driver-recovery.mjs +9 -8
  28. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -1
  29. package/dist/harness-runtime/lib/managed-daemon.mjs +11 -3
  30. package/dist/harness-runtime/lib/native-children.d.mts +8 -1
  31. package/dist/harness-runtime/lib/native-children.mjs +114 -8
  32. package/dist/harness-runtime/lib/native-request-proof.mjs +1 -1
  33. package/dist/harness-runtime/lib/observation-clock.d.mts +2 -2
  34. package/dist/harness-runtime/lib/observation-clock.mjs +7 -4
  35. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +1 -1
  36. package/dist/harness-runtime/lib/provider-overload-policy.mjs +1 -1
  37. package/dist/harness-runtime/lib/tool-observations.mjs +1 -1
  38. package/dist/schemas/diagnostic-stage-result.schema.json +354 -0
  39. package/dist/schemas/diagnostic-work-result.schema.json +296 -0
  40. package/dist/schemas/execution-bundle.schema.json +7 -1
  41. package/dist/schemas/project-flow-pack-manifest.schema.json +1 -1
  42. package/dist/services/check-environment.js +21 -0
  43. package/dist/services/check-runner-evidence.js +267 -15
  44. package/dist/services/code-checks.js +23 -15
  45. package/dist/services/controller-execution.js +10 -4
  46. package/dist/services/controller-fanout.js +13 -6
  47. package/dist/services/controller-memory-worker.js +3 -2
  48. package/dist/services/dashboard.js +2 -2
  49. package/dist/services/descriptor-run-settlement.js +5 -3
  50. package/dist/services/descriptor-run-start.js +22 -3
  51. package/dist/services/descriptor-stage-coordinator.js +37 -4
  52. package/dist/services/descriptor-stage-settlement.js +2 -1
  53. package/dist/services/diagnostic-evidence.js +73 -0
  54. package/dist/services/diagnostic-report-publication.js +72 -0
  55. package/dist/services/diagnostic-report.js +67 -0
  56. package/dist/services/diagnostic-run-bundle.js +74 -0
  57. package/dist/services/diagnostic-snapshot.js +159 -0
  58. package/dist/services/diagnostic-stage-handlers.js +108 -0
  59. package/dist/services/diagnostic-stage-results.js +132 -0
  60. package/dist/services/diagnostic-start.js +84 -0
  61. package/dist/services/diagnostic-work-batch.js +34 -0
  62. package/dist/services/diagnostic-work-collection.js +97 -0
  63. package/dist/services/diagnostic-work-registration.js +101 -0
  64. package/dist/services/diagnostic-work-results.js +93 -0
  65. package/dist/services/eval-snapshots.js +85 -29
  66. package/dist/services/execution-bundles.js +19 -7
  67. package/dist/services/flow-catalog.js +6 -1
  68. package/dist/services/frozen-engine.js +37 -2
  69. package/dist/services/hybrid-contracts.js +2 -0
  70. package/dist/services/installed-flow-source.js +25 -3
  71. package/dist/services/lifecycle-invocations.js +147 -34
  72. package/dist/services/memory-init-contexts.js +16 -0
  73. package/dist/services/memory-init-controller-start.js +12 -4
  74. package/dist/services/memory-init-lint-capability.js +5 -5
  75. package/dist/services/memory-init-native-capacity.js +4 -4
  76. package/dist/services/memory-init-preflight.js +10 -16
  77. package/dist/services/memory-init-worktrunk-capability.js +3 -7
  78. package/dist/services/memory-layer.js +4 -3
  79. package/dist/services/memory-tool-probe.js +25 -0
  80. package/dist/services/memory-verification-checks.js +15 -4
  81. package/dist/services/native-daemon-history.js +2 -1
  82. package/dist/services/provider-capacity.js +43 -38
  83. package/dist/services/release-cache.js +8 -0
  84. package/dist/services/run-control.js +2 -1
  85. package/dist/services/run-controller-adapter.js +2 -1
  86. package/dist/services/run-controller-process.js +2 -1
  87. package/dist/services/run-controller-recovery.js +2 -1
  88. package/dist/services/run-controller.js +85 -15
  89. package/dist/services/run-fork.js +1 -1
  90. package/dist/services/runs.js +6 -1
  91. package/dist/services/runtime-scope-resume.js +12 -6
  92. package/dist/services/runtime-scope-stop.js +3 -2
  93. package/dist/services/runtime-service.js +2 -1
  94. package/dist/services/stage-handlers.js +16 -3
  95. package/dist/services/stage-pause.js +4 -1
  96. package/dist/services/version-status.js +1 -1
  97. package/dist/services/vnext-code.js +2 -2
  98. package/dist/services/vnext-fanout.js +31 -1
  99. package/dist/services/work-registry.js +37 -6
  100. package/dist/services/workspace-bootstrap.js +2 -1
  101. package/package.json +6 -3
@@ -1,15 +1,18 @@
1
- import { assertLifecycleOutcomes, currentLifecycleFailure, managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
+ import { assertLifecycleOutcomes, currentLifecycleFailure, managedInvocationContext, managedLifecycleCommand, awaitLifecycleInvocation, assertLifecycleInvocationCurrent } from "./lifecycle-invocations.js";
2
+ import { isDiagnosticFlow } from "../domain/diagnostic-flows.js";
2
3
  export function recoveryPacketMatchesStage(packet, scope) {
3
4
  return packet.schema_id === 'dd-flow/root-recovery-packet@1' && packet.continuation === 'unfinished_work'
4
5
  && packet.run_id === scope.runId && packet.generation === scope.generation && packet.stage === scope.stage
5
6
  && packet.work_id === scope.workId && packet.paused === false;
6
7
  }
7
8
  import crypto from "node:crypto";
9
+ import { nativeShutdownConfirmed } from "../harness-runtime/lib/native-children.mjs";
8
10
  import fs from "node:fs";
9
11
  import path from "node:path";
10
12
  import { setTimeout as delay } from "node:timers/promises";
11
13
  import { fileURLToPath } from "node:url";
12
14
  import { quote } from "shell-quote";
15
+ import { retainedBrowserEnvironment, validateCheckEnvironment } from "./check-environment.js";
13
16
  import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
14
17
  import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
15
18
  import { publishImmutableInput } from "../storage/immutable-input.js";
@@ -20,7 +23,7 @@ import { requireProjectByRoot } from "./projects.js";
20
23
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
21
24
  import { getFlowRunStatus, setFlowRunRecoveryPaused, assertPreparedRunBundleSqlAuthority } from "./runs.js";
22
25
  import { loadExternalStageContext } from "./stage-context.js";
23
- import { flowCommand } from "./stage-pause.js";
26
+ import { flowCommand, stageResumeSourcePath } from "./stage-pause.js";
24
27
  import { sameSessionProfile, runtimeHarness } from "./execution-policy.js";
25
28
  import { selectControllerExecution } from "./controller-execution.js";
26
29
  import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
@@ -33,6 +36,8 @@ import { initCodexHome } from "./hooks.js";
33
36
  import { launchExternalWork } from "./external-work-launch.js";
34
37
  import { runControllerMemoryTask } from "./controller-memory-task.js";
35
38
  import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
39
+ import { publishPromptDelivery } from "./prompt-delivery.js";
40
+ import { publishStagePacket } from "./stage-report-renderer.js";
36
41
  import { claimQueuedMergeRequest } from "./merge-server.js";
37
42
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
38
43
  import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, retainControllerCapacityIntent, digest, digestBytes, errorRecord } from "./run-controller-state.js";
@@ -135,7 +140,8 @@ export function retainRunControllerLaunch(context, input, prepared = prepareRunC
135
140
  }
136
141
  const manifest = { schema_id: routing.kind === "descriptor" ? "dd-flow/run-controller-manifest@2" : "dd-flow/run-controller-manifest@1", request,
137
142
  ...(routing.kind === "descriptor" ? { execution_binding: routing.binding } : { execution_profile: routing.legacy }),
138
- contexts: frozenContexts, adapters, flow_executable: flowExecutable, resource_home: resourceHome(context), budget };
143
+ contexts: frozenContexts, adapters, flow_executable: flowExecutable, resource_home: resourceHome(context), budget,
144
+ ...(retainedBrowserEnvironment(context.env) ? { check_environment: retainedBrowserEnvironment(context.env) } : {}) };
139
145
  const token = crypto.randomUUID();
140
146
  const now = context.now();
141
147
  const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
@@ -533,7 +539,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
533
539
  const routing = selectControllerExecution(context, requireRun(context, row.project_id, row.run_id), manifest);
534
540
  const policy = routing.policy;
535
541
  const profiles = routing.profiles;
536
- 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, DD_FLOW_CODE_READINESS_OWNER: row.controller_id } };
542
+ let executionContext = { ...context, env: { ...context.env, ...validateCheckEnvironment(manifest.check_environment), DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined, DD_FLOW_CODE_READINESS_OWNER: row.controller_id } };
537
543
  const bindInvocationScope = (session) => {
538
544
  executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
539
545
  projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
@@ -634,9 +640,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
634
640
  const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined, undefined, productive ? cancellation.signal : undefined, productive ? renewLease : undefined);
635
641
  const confirmStopped = async (stateDir, receipt) => {
636
642
  const fail = () => new AppError("controller_native_shutdown_unproven", "The managed daemon is not proven clean and closed", 1, { state_dir: stateDir, receipt });
637
- if (receipt.stopped !== true || receipt.clean !== true)
638
- throw fail();
639
643
  const binding = requireManagedDaemonBinding(executionContext, { stateDir, projectId: row.project_id, runId: row.run_id });
644
+ if (!binding.state.daemon_id || receipt.stopped !== true || !nativeShutdownConfirmed(receipt, { daemonId: binding.state.daemon_id }))
645
+ throw fail();
640
646
  const deadline = performance.now() + 30_000;
641
647
  while (processTreeIsAlive(binding.process) && performance.now() < deadline)
642
648
  await delay(100);
@@ -681,6 +687,45 @@ async function executeController(context, row, manifest, state, assertOwnership,
681
687
  if (!pending)
682
688
  await reconcilePromptPredecessor(session);
683
689
  const firstOperationId = pending?.root_operation_id ?? operationId ?? `${row.controller_id}:prompt:${crypto.randomUUID()}`;
690
+ const hitlText = async () => {
691
+ const answerId = firstOperationId.slice(0, -`:prompt:${row.generation}`.length);
692
+ const answer = context.db.get("SELECT input_json,receipt_json FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND status IN ('accepted','completed')", [answerId, row.controller_id]);
693
+ const accepted = answer && JSON.parse(answer.input_json);
694
+ const packet = answer?.receipt_json && JSON.parse(answer.receipt_json).hitl_packet;
695
+ if (!packet || !accepted || packet.generation !== row.generation || packet.pause_id !== accepted.pause_id || packet.stage !== accepted.stage || packet.work_id !== accepted.work_id || packet.answer_file !== accepted.file || packet.answer_sha256 !== accepted.sha256)
696
+ throw new AppError("controller_hitl_packet_invalid", "Accepted HITL continuation lost its exact binding", 1);
697
+ const issued = context.db.get("SELECT id,status,scope_json,command FROM lifecycle_invocations WHERE id = ?", [packet.invocation_id ?? null]);
698
+ if (!issued || issued.scope_json !== executionContext.env.DD_FLOW_INVOCATION_SCOPE)
699
+ throw new AppError("controller_hitl_packet_invalid", "Accepted HITL command belongs to another scope", 1);
700
+ if (issued.status === "executing" || issued.status === "settled") {
701
+ const recovered = await awaitLifecycleInvocation(executionContext, { id: issued.id, command: issued.command, assertCurrent: scope => assertLifecycleInvocationCurrent(executionContext, scope, issued.command) });
702
+ if (!recovered.replay)
703
+ throw new AppError("controller_hitl_reconciliation_required", "Accepted resume is not committed", 1);
704
+ const outcome = recovered.outcome;
705
+ if (!outcome.result)
706
+ throw new AppError("controller_hitl_reconciliation_required", "Accepted resume has no committed continuation", 1);
707
+ if (outcome.result.resume_source_path !== packet.resume_source_path || typeof outcome.result.answer_path !== "string")
708
+ throw new AppError("controller_hitl_packet_invalid", "Committed resume differs from the accepted packet locator", 1);
709
+ publishStagePacket(executionContext, row.project_id, row.run_id, packet.stage, outcome.result.answer_path);
710
+ publishStagePacket(executionContext, row.project_id, row.run_id, packet.stage, packet.resume_source_path);
711
+ const bytes = fs.readFileSync(outcome.result.answer_path);
712
+ if (digestBytes(bytes) !== packet.answer_sha256)
713
+ throw new AppError("context_checksum_mismatch", "Committed answer differs from its accepted bytes", 1);
714
+ fs.mkdirSync(path.dirname(packet.answer_file), { recursive: true, mode: 0o700 });
715
+ publishImmutableInput(packet.answer_file, bytes, "hitl_answer_conflict");
716
+ publishPromptDelivery(outcome.result);
717
+ const delivery = outcome.result.prompt_delivery;
718
+ const promptPath = delivery?.path ?? outcome.result.prompt_path;
719
+ if (typeof promptPath !== "string" || !path.isAbsolute(promptPath))
720
+ throw new AppError("controller_hitl_packet_invalid", "Committed resume has no absolute prompt delivery", 1);
721
+ // Keep continuation bytes stable across a retained overload episode.
722
+ // Its conditional instructions point at this restored exact custody.
723
+ return controllerStageResumePrompt(packet);
724
+ }
725
+ if (issued.status !== "issued" || digestBytes(fs.readFileSync(packet.answer_file)) !== packet.answer_sha256)
726
+ throw new AppError("context_checksum_mismatch", "Accepted HITL answer or command changed before resume", 1);
727
+ return controllerStageResumePrompt(packet);
728
+ };
684
729
  const send = (value, id) => {
685
730
  const retained = context.db.get("SELECT input_json FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND generation = ?", [id, row.controller_id, row.generation]);
686
731
  if (retained) {
@@ -714,7 +759,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
714
759
  promptPending = true;
715
760
  observedPromptOperation = firstOperationId;
716
761
  try {
717
- receipt = await send(text, firstOperationId);
762
+ receipt = await send(hitl ? await hitlText() : text, firstOperationId);
718
763
  }
719
764
  finally {
720
765
  promptPending = false;
@@ -722,9 +767,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
722
767
  }
723
768
  }
724
769
  catch (error) {
725
- const resumeCommand = hitl ? text.split("\n\n")[1] : undefined;
726
- if (hitl && !resumeCommand?.includes(" stage resume "))
727
- throw error;
770
+ const hitlContinuationText = hitl && terminalProviderOverload(error, session.id) ? await hitlText() : undefined;
728
771
  const inspect = (args, id) => runHarnessAdapter({ executable: manifest.adapters[runtimeHarness(session.profile)].executable,
729
772
  args, env: controllerEnvironment(context, manifest, session), runtimeOwner: session.owner, operationId: id, timeoutMs: 30_000, signal: cancellation.signal });
730
773
  const resumed = await continueProviderCapacity({ error, sessionId: session.id, operationId: firstOperationId, signal: cancellation.signal, allowSettledDescendants: true, atomicIntent: true,
@@ -781,7 +824,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
781
824
  },
782
825
  retainIntent: intent => retainControllerCapacityIntent(context, row, state, intent, session.id, kind ?? "stage", assertOwner),
783
826
  prompt: (id, value) => send(value, id),
784
- ...(resumeCommand ? { continuationText: `Continue the same accepted HITL task in this Session. First inspect current durable RUN and pause state. Only if the exact pause still awaits the accepted answer, run this previously issued command once:\n\n${resumeCommand}\n\nNever replay an already committed answer. Otherwise use only the current issued successor.` } : {}) });
827
+ ...(hitlContinuationText ? { continuationText: hitlContinuationText } : {}) });
785
828
  if (!resumed)
786
829
  throw error;
787
830
  receipt = resumed;
@@ -845,6 +888,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
845
888
  await prompt(session, fs.readFileSync(file, "utf8"), pending.root_operation_id, pending.kind === "stage" ? undefined : pending.kind);
846
889
  continue;
847
890
  }
891
+ if (next.kind !== "paused" && state.current_session !== null) {
892
+ const committed = context.db.get("SELECT operation_id,receipt_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json,'$.action') = 'user_answer' AND json_extract(receipt_json,'$.hitl_packet.generation') = ? AND json_extract(receipt_json,'$.hitl_packet.stage') = ? ORDER BY created_at LIMIT 1", [row.controller_id, row.generation, state.stage]);
893
+ if (committed) {
894
+ const packet = JSON.parse(committed.receipt_json).hitl_packet;
895
+ const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(packet), `${committed.operation_id}:prompt:${row.generation}`, "hitl");
896
+ context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = json_set(receipt_json,'$.delivered',json('true'),'$.session_id',?), updated_at = ? WHERE operation_id = ? AND status = 'accepted'", [adapterSessionId(receipt), context.now(), committed.operation_id]);
897
+ state.last_continuation = null;
898
+ saveState(context, row, state);
899
+ continue;
900
+ }
901
+ }
848
902
  const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
849
903
  if (state.current_session !== null && (completed || next.stage !== state.stage) && childObserver(state.sessions[state.current_session])?.failed().length)
850
904
  throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids completing or advancing its Stage", 1);
@@ -890,13 +944,29 @@ async function executeController(context, row, manifest, state, assertOwnership,
890
944
  assertOwner();
891
945
  // The accepted answer is logical intent; only its generation-bound
892
946
  // adapter operation represents a physical dispatch that stop must drain.
893
- const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
894
- const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(pause.id, command), `${answer.operation_id}:prompt:${row.generation}`, "hitl");
947
+ let invocationId;
948
+ const capture = executionContext.captureLifecycleAssignment;
949
+ executionContext.captureLifecycleAssignment = id => { invocationId = id; capture?.(id); };
950
+ let command;
951
+ try {
952
+ command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
953
+ }
954
+ finally {
955
+ if (capture)
956
+ executionContext.captureLifecycleAssignment = capture;
957
+ else
958
+ delete executionContext.captureLifecycleAssignment;
959
+ }
960
+ if (!invocationId)
961
+ throw new AppError("controller_hitl_packet_invalid", "Accepted resume did not capture its issued identity", 1);
962
+ const packet = { pause_id: pause.id, stage: next.stage, work_id: input.work_id, answer_file: input.file, answer_sha256: input.sha256, command, generation: row.generation, invocation_id: invocationId, resume_source_path: stageResumeSourcePath(pause, fs.readFileSync(input.file, "utf8")) };
963
+ context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'accepted'", [JSON.stringify({ hitl_packet: packet }), context.now(), answer.operation_id]);
964
+ const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(packet), `${answer.operation_id}:prompt:${row.generation}`, "hitl");
895
965
  const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
896
966
  if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
897
967
  throw new AppError("controller_answer_not_applied", "The answer Turn completed without resuming the assigned HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage, effect: "no_progress" });
898
968
  }
899
- 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]);
969
+ context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = json_set(receipt_json,'$.delivered',json('true'),'$.session_id',?), updated_at = ? WHERE operation_id = ?", [adapterSessionId(receipt), context.now(), answer.operation_id]);
900
970
  state.last_continuation = null;
901
971
  saveState(context, row, state);
902
972
  setStatus("running");
@@ -1129,7 +1199,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
1129
1199
  continue;
1130
1200
  }
1131
1201
  }
1132
- const memoryFlow = ["mb-init", "mb-upgrade"].includes(run.flow_kind);
1202
+ const memoryFlow = ["mb-init", "mb-upgrade"].includes(run.flow_kind) || isDiagnosticFlow(run.flow_kind);
1133
1203
  const fanoutInput = { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles, dispatchFence: retainedFailures.length > 0 };
1134
1204
  const fanout = memoryFlow ? await runControllerMemoryTask(executionContext, { operation: "fanout", input: fanoutInput }, cancellation.signal)
1135
1205
  : await nextControllerFanout(executionContext, fanoutInput);
@@ -28,7 +28,7 @@ export function prepareRunFork(input) {
28
28
  }
29
29
  prepareForkEngine(engineVersion, input.integrityChecksum);
30
30
  const manifest = prepareEvalRunSnapshot(snapshot);
31
- if (manifest.schema_id !== "dd-flow/eval-run-snapshot@5" || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
31
+ if (!["dd-flow/eval-run-snapshot@5", "dd-flow/eval-run-snapshot@7"].includes(manifest.schema_id) || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
32
32
  throw new AppError("fork_checkpoint_invalid", "Fork requires a sealed stage-entry snapshot", 2, { snapshot });
33
33
  }
34
34
  const operational = prepareSnapshotOperationalDecision({ snapshot, runId: manifest.run_id, projectRoot: path.join(output, "project"), now: new Date().toISOString(), ...(input.operationalDecisionFile ? { file: input.operationalDecisionFile } : {}) });
@@ -27,6 +27,7 @@ import { RetainedExecutionBundle } from "./execution-bundles.js";
27
27
  import { admitBundledFlow, bundledHandlerContext } from "./stage-handlers.js";
28
28
  import { hybridDigest } from "./hybrid-contracts.js";
29
29
  import { projectPacketLocators, runContextRelocations } from "./stage-context.js";
30
+ import { readRunEngineBinding } from "./run-engine-bindings.js";
30
31
  const runSchemaId = "dd-flow/flow-run@3";
31
32
  const runtimeSchemaId = "dd-flow/flow-run@3";
32
33
  const allowedRunFlowKinds = [
@@ -36,6 +37,7 @@ const allowedRunFlowKinds = [
36
37
  "mb-init",
37
38
  "mb-upgrade",
38
39
  "mb-audit",
40
+ "project-review",
39
41
  "mb-distill",
40
42
  "mb-upgrade-review",
41
43
  "mb-sdlc-review",
@@ -1843,7 +1845,10 @@ export function readFlowRunExecutionBundle(context, input) {
1843
1845
  throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority changed", 1);
1844
1846
  }
1845
1847
  const admission = { ...retained, receipts: new Map(retained.receipts), descriptorContext: bundledHandlerContext(new Map()) };
1846
- const bundle = RetainedExecutionBundle.read(path.join(runArtifactRoot(run), "execution-bundle"), anchor.manifest_sha256, admission);
1848
+ const relocations = runContextRelocations(context, run.project_id, run.id);
1849
+ const binding = relocations.length ? readRunEngineBinding(path.join(runArtifactRoot(run), "engine-binding.json")) : null;
1850
+ const relocation = binding?.source === "fork_upgrade" ? { binding, relocations } : undefined;
1851
+ const bundle = RetainedExecutionBundle.read(path.join(runArtifactRoot(run), "execution-bundle"), anchor.manifest_sha256, admission, relocation);
1847
1852
  assertBundleRunIdentity(context, run, bundle);
1848
1853
  if (bundle.manifest.descriptor.sha256 !== anchor.descriptor_sha256 || bundle.manifest.flow_id !== anchor.flow_id) {
1849
1854
  throw new AppError("run_bundle_anchor_invalid", "RUN bundle descriptor identity changed", 1);
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { nativeShutdownConfirmed } from "../harness-runtime/lib/native-children.mjs";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { isDeepStrictEqual } from "node:util";
@@ -16,15 +17,19 @@ import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
16
17
  import { harnessKey } from "./harness-config.js";
17
18
  import { canonicalNativeOperation } from "../harness-runtime/lib/adapter-timeouts.mjs";
18
19
  import { retainedDaemonReply } from "../harness-runtime/lib/daemon-observation.mjs";
20
+ import { validateNativeLaunchEnvironment } from "../harness-runtime/lib/daemon-operations.mjs";
19
21
  const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
20
- const nativeLaunchEnvironmentKeys = ["CODEX_HOME", "DD_FLOW_CONFIG_HOME", "DD_FLOW_HOME", "DD_FLOW_BIN", "DD_FLOW_RESOURCE_HOME"];
21
22
  function nativeLaunchEnvironment(context, saved) {
22
23
  const env = { ...context.env };
23
24
  for (const name of ["DD_FLOW_CONTROLLER_TOKEN", "DD_FLOW_CONTROL_TOKEN", "DD_FLOW_SCOPE_CONTROL_TOKEN", "DD_FLOW_DAEMON_ID", "DD_FLOW_RUNTIME_OWNER"])
24
25
  delete env[name];
25
- if (!saved || Object.keys(saved).some(name => !nativeLaunchEnvironmentKeys.includes(name)))
26
- throw new AppError("runtime_scope_native_launch_required", "Native launch has an unsupported environment", 1);
27
- for (const name of nativeLaunchEnvironmentKeys) {
26
+ try {
27
+ validateNativeLaunchEnvironment(saved);
28
+ }
29
+ catch {
30
+ throw new AppError("runtime_scope_native_launch_required", "Native launch has an unsupported or invalid configuration environment", 1);
31
+ }
32
+ for (const name of Object.keys(saved)) {
28
33
  if (saved[name] === null)
29
34
  delete env[name];
30
35
  else if (typeof saved[name] === "string")
@@ -460,8 +465,9 @@ async function retireScopeNative(context, intent, source, assertCurrent) {
460
465
  }
461
466
  if (!launch || launch.schema_id !== "dd-flow/native-launch@1" || launch.executable !== source.runtime_owner?.adapter_executable || !path.isAbsolute(launch.executable) || !path.isAbsolute(launch.cwd) || !Array.isArray(launch.args) || launch.args[0] !== "daemon" || launch.args[1] !== "start" || launch.args[launch.args.indexOf("--state-dir") + 1] !== source.state_dir || !isDeepStrictEqual(launchOwner, source.runtime_owner))
462
467
  throw new AppError("runtime_scope_native_launch_required", "Native recovery requires the original owned launch contract", 1);
463
- if (!launch.env || Object.keys(launch.env).some(name => !nativeLaunchEnvironmentKeys.includes(name)) || nativeLaunchEnvironmentKeys.some(name => launch.env[name] !== null && typeof launch.env[name] !== "string") || launch.args.some(arg => typeof arg !== "string"))
468
+ if (launch.args.some(arg => typeof arg !== "string"))
464
469
  throw new AppError("runtime_scope_native_launch_required", "Native recovery requires explicit configuration presence before retiring its owner", 1);
470
+ nativeLaunchEnvironment(context, launch.env);
465
471
  let dispatch = false;
466
472
  const preparedInventory = db.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'scope-resume-native' AND resource_key = ?", [key]) ? null : nativeInventoryVersion(source.state_dir);
467
473
  const operation = db.writeTransaction(() => {
@@ -494,7 +500,7 @@ async function retireScopeNative(context, intent, source, assertCurrent) {
494
500
  assertOwner();
495
501
  db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-resume-native' AND resource_key = ?", [JSON.stringify(operation), context.now(), key]);
496
502
  }
497
- if (operation.receipt.stopped !== true || operation.receipt.clean !== true || state().shutdown_state !== "clean" || processTreeIsAlive(record))
503
+ if (operation.receipt.stopped !== true || !nativeShutdownConfirmed(operation.receipt, { daemonId: source.daemon_id }) || state().shutdown_state !== "clean" || processTreeIsAlive(record))
498
504
  return { ...source, ...operation, pending: true, reason: "native_retirement_settlement_required" };
499
505
  assertOwner();
500
506
  operation.status = "retired";
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { nativeShutdownConfirmed } from "../harness-runtime/lib/native-children.mjs";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import os from "node:os";
@@ -30,7 +31,7 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
30
31
  if (!state.daemon_id || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || !retainedResourceHomeMatches(context, owner.resource_home) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.id !== record.id || state.resource_process.lease_token !== record.lease_token || state.pid !== record.pid)
31
32
  throw new AppError("runtime_scope_owner_unknown", "Native owner does not match its registration", 1);
32
33
  const closed = state.shutdown_state === "clean" && state.active_tree === false && !processTreeIsAlive(record)
33
- ? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
34
+ ? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && nativeShutdownConfirmed(operation.result, { daemonId: state.daemon_id ?? "" })) : undefined;
34
35
  const operationId = closed ? String(closed.operation_id) : `scope-stop:${crypto.createHash("sha256").update(JSON.stringify([scopeId, record.id, state.daemon_id])).digest("hex")}`;
35
36
  const requested = path.join(metadata.state_dir, "operations", crypto.createHash("sha256").update(operationId).digest("hex"), "requested.json");
36
37
  const prior = fs.existsSync(requested);
@@ -56,7 +57,7 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
56
57
  const finalOwner = managedProcessStatus(context).find(item => item.id === record.id);
57
58
  if (!finalOwner || finalOwner.lease_token !== record.lease_token || finalOwner.pid !== record.pid || finalOwner.pid_started_at !== record.pid_started_at || after.resource_process?.id !== record.id || after.resource_process.lease_token !== record.lease_token || JSON.stringify(after.config?.runtime_owner) !== JSON.stringify(owner))
58
59
  throw new AppError("runtime_scope_owner_changed", "Native ownership changed during control", 1);
59
- if (result?.stopped !== true || result.clean !== true || after.daemon_id !== state.daemon_id || after.shutdown_state !== "clean" || after.active_tree !== false || processTreeIsAlive(finalOwner))
60
+ if (result?.stopped !== true || !nativeShutdownConfirmed(result, { daemonId: state.daemon_id }) || after.daemon_id !== state.daemon_id || after.shutdown_state !== "clean" || after.active_tree !== false || processTreeIsAlive(finalOwner))
60
61
  throw new AppError("runtime_scope_not_settled", "Native close has not proved whole-tree settlement", 1);
61
62
  const processes = managedProcessStatus(context).filter(item => managedProviderBelongsToDaemon(item, finalOwner, after) && item.operation_id?.startsWith(`${owner.operation_id}:`) && !processTreeIsAlive(item)).map(item => ({ process_id: item.id, native_settled: true, binding: managedProcessBinding(item, resourceHome(context)) }));
62
63
  return { process_id: record.id, state_dir: metadata.state_dir, settled: true, native_settled: true, physical_stopped: true, operation_id: operationId, owner_operation_id: owner.operation_id, processes };
@@ -7,6 +7,7 @@ import { assertRunMutationAllowed } from "./run-recovery.js";
7
7
  import { requireProjectByRoot } from "./projects.js";
8
8
  import { resolveRun } from "./runs.js";
9
9
  import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
10
+ import { retainedBrowserEnvironment } from "./check-environment.js";
10
11
  import { confirmManagedProcess, finishManagedProcess, renewManagedProcess, managedProcessStatus, processTreeIsAlive, registerManagedProcess, reservePorts, resourceHome, stopManagedProcess } from "./managed-processes.js";
11
12
  export function prepareRuntimeService(context, input) {
12
13
  validateObservationDuration(input.timeoutMs);
@@ -33,7 +34,7 @@ export async function startRuntimeService(context, input) {
33
34
  const save = () => { const temporary = `${receiptPath}.${process.pid}.tmp`; fs.writeFileSync(temporary, JSON.stringify(receipt, null, 2)); fs.renameSync(temporary, receiptPath); };
34
35
  try {
35
36
  const allocation = await reservePorts(context, { ownerId: record.owner_id, processId: record.id, names: input.ports });
36
- const environment = { ...codeExecutionEnvironment(run.workspace_root), ...Object.fromEntries(Object.entries(allocation.ports).map(([name, port]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(port)])), DD_FLOW_EVIDENCE_DIR: directory, DD_FLOW_CHECK_COMPLETION_FILE: path.join(directory, "completion.json") };
37
+ const environment = { ...codeExecutionEnvironment(run.workspace_root, retainedBrowserEnvironment(context.env)), ...Object.fromEntries(Object.entries(allocation.ports).map(([name, port]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(port)])), DD_FLOW_EVIDENCE_DIR: directory, DD_FLOW_CHECK_COMPLETION_FILE: path.join(directory, "completion.json") };
37
38
  const quoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
38
39
  Object.assign(receipt, { ports: allocation.ports, environment: Object.fromEntries(Object.entries(environment).filter(([key]) => key.startsWith("DD_FLOW_PORT_"))), stop_command: `DD_FLOW_HOME=${quoted(context.ddFlowHome)} DD_FLOW_RESOURCE_HOME=${quoted(resourceHome(context))} dd-flow runtime process stop --id ${record.id} --lease-token ${record.lease_token} --json` });
39
40
  save();
@@ -10,6 +10,8 @@ import { ResolvedFlowDescriptor } from "./flow-descriptors.js";
10
10
  import { vnextStages } from "../domain/stage-catalog.js";
11
11
  import { sdlcHandlerIdentities as ids } from "../domain/stage-handler-identities.js";
12
12
  import { memoryInitStageHandlers, memoryUpgradeStageHandlers } from "./memory-stage-dispatch.js";
13
+ import { diagnosticStageHandlers } from "./diagnostic-stage-handlers.js";
14
+ import { isDiagnosticFlow, diagnosticFlowStages } from "../domain/diagnostic-flows.js";
13
15
  /** Engine-owned code, not a plugin registration surface or executable descriptor expression. */
14
16
  export const sdlcStageHandlers = Object.freeze({
15
17
  specify: Object.freeze({ ...ids.specify, entry: launchVnextSpecify, finish: finishVnextSpecifyStage }),
@@ -23,8 +25,8 @@ export const sdlcStageHandlers = Object.freeze({
23
25
  /** A fresh inventory cannot mutate the registered handler functions. */
24
26
  export function bundledHandlerContext(blobs) {
25
27
  return {
26
- entryHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers), ...Object.values(memoryUpgradeStageHandlers)].map(handler => handler.entryId)),
27
- finishHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers), ...Object.values(memoryUpgradeStageHandlers)].map(handler => handler.finishId)), blobs
28
+ entryHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers), ...Object.values(memoryUpgradeStageHandlers), ...diagnosticStageHandlers].map(handler => handler.entryId)),
29
+ finishHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers), ...Object.values(memoryUpgradeStageHandlers), ...diagnosticStageHandlers].map(handler => handler.finishId)), blobs
28
30
  };
29
31
  }
30
32
  /** Handler identity is also stage/flow scoped: a known CODE handler is not a memory handler. */
@@ -64,9 +66,20 @@ export function admitBundledFlow(input, sha256, blobs) {
64
66
  throw new AppError("flow_handler_scope_invalid", "Memory init stage requires its own registered result schema", 2, { phase: "prepare", effect: "no_effect", stage: stage.id });
65
67
  }
66
68
  }
69
+ if (isDiagnosticFlow(flow.descriptor.id)) {
70
+ const stages = diagnosticFlowStages[flow.descriptor.id];
71
+ if (flow.descriptor.entry_stage !== "preflight" || flow.descriptor.terminal_stages.length !== 1 || flow.descriptor.terminal_stages[0] !== "report"
72
+ || flow.descriptor.stages.length !== stages.length || flow.descriptor.edges.length !== stages.length - 1
73
+ || flow.descriptor.stages.some((stage, index) => stage.id !== stages[index] || stage.directory !== `${String(index + 1).padStart(2, "0")}-${stage.id}`)
74
+ || stages.slice(0, -1).some((stage, index) => !flow.descriptor.edges.some(edge => edge.from === stage && edge.to === stages[index + 1] && edge.on === "accepted"))
75
+ || flow.descriptor.stages.some(stage => JSON.parse(flow.stageBlob(stage.id, "result_schema").toString("utf8")).$id !== "dd-flow/diagnostic-stage-result@1")) {
76
+ throw new AppError("flow_handler_scope_invalid", "Diagnostic handlers require their registered four-stage layout and result schema", 2, { phase: "prepare", effect: "no_effect" });
77
+ }
78
+ }
67
79
  for (const stage of flow.descriptor.stages) {
68
80
  const handlers = flow.descriptor.id === "sdlc" ? Object.values(sdlcStageHandlers) : flow.descriptor.id === "mb-init" ? Object.values(memoryInitStageHandlers)
69
- : flow.descriptor.id === "mb-upgrade" ? Object.values(memoryUpgradeStageHandlers) : [];
81
+ : flow.descriptor.id === "mb-upgrade" ? Object.values(memoryUpgradeStageHandlers)
82
+ : diagnosticStageHandlers.filter(handler => handler.flowId === flow.descriptor.id);
70
83
  const handler = handlers.find(candidate => candidate.stage === stage.id);
71
84
  if (!handler || stage.entry_handler !== handler.entryId || stage.finish_handler !== handler.finishId) {
72
85
  throw new AppError("flow_handler_scope_invalid", "Registered handlers do not belong to this flow stage", 2, { phase: "prepare", effect: "no_effect", flow_id: flow.descriptor.id, stage: stage.id });
@@ -19,6 +19,9 @@ import { prepareAgentOutputFile } from "../storage/agent-output.js";
19
19
  import { latestRunVerificationMatrix } from "./verification-matrix.js";
20
20
  import { acceptedIntentReviewFingerprint, prepareAcceptedIntentSources, validatePreparedAcceptedIntentReview } from "./accepted-intent.js";
21
21
  export { flowCommand } from "./runtime-command.js";
22
+ export function stageResumeSourcePath(pause, answer) {
23
+ return path.join(path.dirname(pause.question_path), `resume-${crypto.createHash("sha256").update(`${pause.id}\0${answer}`).digest("hex")}.json`);
24
+ }
22
25
  export function prepareStagePause(context, input) {
23
26
  if (typeof input.question !== "string" || !input.question.trim())
24
27
  throw new AppError("validation", "stage pause requires a non-empty user question", 2);
@@ -119,7 +122,7 @@ export function resumeStageAfterUser(context, input) {
119
122
  // Capture the exact continuation inputs and already-issued commands before
120
123
  // accepting the answer. A publication retry must not issue new authority.
121
124
  const retryCommands = lifecycleRetryCommands(context, stage.stage);
122
- const resumeSourcePath = path.join(path.dirname(pause.question_path), `resume-${crypto.createHash("sha256").update(`${pause.id}\0${input.answer}`).digest("hex")}.json`);
125
+ const resumeSourcePath = stageResumeSourcePath(pause, input.answer);
123
126
  let review;
124
127
  let reviewPreparationError;
125
128
  const previousAnswer = context.db.get("SELECT MAX(id) AS id FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ?", [project.id, run.id])?.id ?? 0;
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { parse as parseYaml } from "yaml";
5
- export const canonicalOnlyFlowNames = Object.freeze(["mb-init.md", "mb-upgrade.md", "mb-distill.md"]);
5
+ export const canonicalOnlyFlowNames = Object.freeze(["mb-init.md", "mb-upgrade.md", "mb-distill.md", "mb-audit.md", "review.md"]);
6
6
  export function resolveStatusProjectRoot(input) {
7
7
  if (input.requestedRoot) {
8
8
  const absolute = path.resolve(input.requestedRoot);
@@ -194,7 +194,7 @@ export async function finishVnextCode(context, input) {
194
194
  const retainedMatrix = latestRunVerificationMatrix(context, project.id, run.id, stage);
195
195
  const retainedMatrixInstruction = verificationMatrixInstruction(retainedMatrix?.metadata ?? null, home, "the current semantic verification and repair");
196
196
  if (verification.verdict === "blocked") {
197
- return { ok: true, run_id: run.id, stage, outcome: "blocked", verification, ...(retainedMatrix ? { verification_matrix: retainedMatrix.metadata } : {}), instruction: `CODE remains running. Resolve the stated external or user-input blocker in this same coordinator session, update code-verification.json, then invoke this same stage finish command again.\n${retainedMatrixInstruction}` };
197
+ return { ok: true, run_id: run.id, stage, outcome: "blocked", verification, ...(retainedMatrix ? { verification_matrix: retainedMatrix.metadata } : {}), instruction: `CODE remains running. Resolve the stated external or user-input blocker in this same coordinator session, update code-verification.json, then invoke next.finish_command. Do not repeat while the blocker remains unresolved.\n${retainedMatrixInstruction}`, next: { finish_command: finishCommand(context, run.id, projectRoot, input.verificationFile) } };
198
198
  }
199
199
  if (verification.verdict === "passed" && verification.unresolved.length > 0) {
200
200
  throw new AppError("verification_contradictory", "A passed CODE verification cannot contain unresolved obligations", 2, { unresolved: verification.unresolved });
@@ -209,7 +209,7 @@ export async function finishVnextCode(context, input) {
209
209
  verification,
210
210
  objective: verification.summary
211
211
  });
212
- return { ok: true, run_id: run.id, stage, outcome: "repair_required", verification, repair, ...(retainedMatrix ? { verification_matrix: retainedMatrix.metadata } : {}), instruction: `The semantic verification is not accepted. The repair Work is already registered: do not invoke its start_command in this coordinator Session. End this Turn so the controller can launch it in a fresh child Session. After that Work completes, update code-verification.json and invoke this same stage finish command again.\n${retainedMatrixInstruction}` };
212
+ return { ok: true, run_id: run.id, stage, outcome: "repair_required", verification, repair, ...(retainedMatrix ? { verification_matrix: retainedMatrix.metadata } : {}), instruction: `The semantic verification is not accepted. The repair Work is already registered: do not invoke its start_command in this coordinator Session. End this Turn so the controller can launch it in a fresh child Session. After that Work completes, update code-verification.json and invoke next.finish_command.\n${retainedMatrixInstruction}`, next: { finish_command: finishCommand(context, run.id, projectRoot, input.verificationFile) } };
213
213
  }
214
214
  const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
215
215
  if (input.retryCheckId) {
@@ -15,6 +15,8 @@ import { renderNativeChildFollowupInstruction } from "../harness-runtime/lib/del
15
15
  import { readDescriptorRootWork } from "./descriptor-root-work.js";
16
16
  import { selectMemoryCompositionRegistration } from "./memory-composition-registration.js";
17
17
  import { safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
18
+ import { isDiagnosticFlow } from "../domain/diagnostic-flows.js";
19
+ import { readRegisteredDiagnosticGraph } from "./diagnostic-work-registration.js";
18
20
  export const subagentCapacityKey = "runtime.subagent.available_slots";
19
21
  /** External concurrency is policy, never evidence of native child qualification. */
20
22
  export function getStageFanoutCapacity(context, input) {
@@ -176,6 +178,30 @@ export function memoryStageFanoutDescriptor(context, input) {
176
178
  }
177
179
  return { schema_id: "dd-flow/work-fanout@2", kind: "work_fanout", scope, stage: input.stage, parent_work_id: root.workId, dispatch: "none", capacity_required: true };
178
180
  }
181
+ /** Diagnostic graphs are registered by the Stage handler, never fanout.json. */
182
+ export function diagnosticStageFanoutDescriptor(context, input) {
183
+ if (!["audit", "analyze", "review"].includes(input.stage))
184
+ return null;
185
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
186
+ const run = context.db.get("SELECT flow_kind,index_json FROM runs WHERE project_id=? AND id=?", [project.id, input.runId]);
187
+ if (!run || !isDiagnosticFlow(run.flow_kind))
188
+ return null;
189
+ const index = JSON.parse(run.index_json);
190
+ if (index.current_stage !== input.stage || !index.stage_runs.some(stage => stage.stage === input.stage && stage.status === "running"))
191
+ return null;
192
+ const bundle = readFlowRunExecutionBundle(context, input);
193
+ if (!bundle || bundle.manifest.flow_id !== run.flow_kind)
194
+ throw new AppError("stage_graph_conflict", "Diagnostic graph requires its frozen bundle", 1);
195
+ const scope = { projectId: project.id, runId: input.runId, stage: input.stage };
196
+ const root = readDescriptorRootWork(context, scope, bundle), graph = readRegisteredDiagnosticGraph(context, scope);
197
+ const members = stageWorkIds(context, project.id, input.runId, input.stage);
198
+ if (!graph || graph.parent_work_id !== root.workId || graph.bundle_sha256 !== bundle.sha256 || root.status !== "running"
199
+ || !members.has(root.workId) || members.size !== Object.keys(graph.work_ids).length + 1
200
+ || Object.values(graph.work_ids).some(id => !members.has(id)))
201
+ throw new AppError("stage_graph_conflict", "Diagnostic graph requires its exact registered Work; replay the issued Stage entry if registration was interrupted", 1);
202
+ return { schema_id: "dd-flow/work-fanout@2", kind: "work_fanout", scope: stageGraphScope(context, project.id, input.runId, input.stage),
203
+ stage: input.stage, parent_work_id: root.workId, dispatch: "none", capacity_required: true };
204
+ }
179
205
  /** Read-only projection for an external orchestrator. dd-flow keeps the graph authority. */
180
206
  export function getVnextFanoutStatus(context, input) {
181
207
  const projectRoot = resolveProjectRoot(input.projectRoot);
@@ -189,7 +215,8 @@ export function getVnextFanoutStatus(context, input) {
189
215
  const bundle = index.execution_bundle ? readFlowRunExecutionBundle(context, { projectRoot, runId: input.runId }) : null;
190
216
  const stageRoot = path.join(run.run_root, bundle ? bundle.descriptor.stage(input.stage).directory : vnextStageDirectory(input.stage));
191
217
  const memoryDescriptor = memoryStageFanoutDescriptor(context, input);
192
- let descriptor = memoryDescriptor ?? readFanoutDescriptor(stageRoot);
218
+ const diagnosticDescriptor = diagnosticStageFanoutDescriptor(context, input);
219
+ let descriptor = memoryDescriptor ?? diagnosticDescriptor ?? (bundle && isDiagnosticFlow(bundle.manifest.flow_id) ? null : readFanoutDescriptor(stageRoot));
193
220
  // Registration is authoritative even if projection publication was interrupted,
194
221
  // or an otherwise review-off stage acquired an aggregate repair graph.
195
222
  if (!descriptor && context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'repair_intents'")) {
@@ -218,6 +245,9 @@ export function getVnextFanoutStatus(context, input) {
218
245
  // Only SQL-owned memory registration can attest a deliberately empty
219
246
  // graph. A mutable fanout.json must never turn missing Work into success.
220
247
  let registeredEmpty = false;
248
+ if (diagnosticDescriptor && !hasWork) {
249
+ registeredEmpty = Object.keys(readRegisteredDiagnosticGraph(context, { projectId: project.id, runId: input.runId, stage: input.stage }).work_ids).length === 0;
250
+ }
221
251
  if (memoryDescriptor && input.stage === "upgrade" && !hasWork
222
252
  && context.db.get("SELECT 1 FROM audit_events WHERE project_id=? AND event_type='memory.composition_registration' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.stage')='upgrade' AND json_extract(payload_json,'$.scope.attempt')=? AND json_extract(payload_json,'$.scope.cycle')=? AND json_extract(payload_json,'$.scope.parent_work_id')=? LIMIT 1", [project.id, input.runId, currentScope.attempt, currentScope.cycle, descriptor.parent_work_id])) {
223
253
  const registered = selectMemoryCompositionRegistration(context, { ...currentScope, stage: "upgrade", parent_work_id: descriptor.parent_work_id });