@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.105

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 (68) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +23 -8
  4. package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
  6. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
  7. package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
  9. package/dist/protocol/local-files.js +18 -9
  10. package/dist/runtime/context.js +2 -2
  11. package/dist/services/cleanup.js +81 -32
  12. package/dist/services/code-checks.js +18 -19
  13. package/dist/services/continuation-outcome.js +1 -1
  14. package/dist/services/controller-fanout.js +3 -3
  15. package/dist/services/eval-snapshots.js +69 -41
  16. package/dist/services/execution-policy.js +12 -1
  17. package/dist/services/hooks.js +150 -42
  18. package/dist/services/lanes.js +23 -18
  19. package/dist/services/lifecycle-command.js +11 -2
  20. package/dist/services/lifecycle-invocations.js +291 -54
  21. package/dist/services/managed-processes.js +35 -23
  22. package/dist/services/merge-queue.js +71 -67
  23. package/dist/services/merge-server.js +3 -11
  24. package/dist/services/migrations.js +28 -12
  25. package/dist/services/plan-runtime.js +8 -4
  26. package/dist/services/projects.js +3 -11
  27. package/dist/services/protocols.js +98 -68
  28. package/dist/services/recovery-snapshot-database.js +43 -8
  29. package/dist/services/run-control-worker.js +15 -32
  30. package/dist/services/run-control.js +3 -17
  31. package/dist/services/run-controller-capture.js +58 -27
  32. package/dist/services/run-controller-process.js +21 -30
  33. package/dist/services/run-controller-recovery.js +36 -37
  34. package/dist/services/run-controller.js +81 -86
  35. package/dist/services/run-fork.js +10 -6
  36. package/dist/services/run-projection.js +19 -64
  37. package/dist/services/run-recovery-runtime.js +24 -14
  38. package/dist/services/run-recovery.js +56 -54
  39. package/dist/services/runs.js +218 -160
  40. package/dist/services/runtime-budget.js +19 -45
  41. package/dist/services/runtime-scope-capture.js +8 -6
  42. package/dist/services/runtime-scope-control.js +49 -39
  43. package/dist/services/runtime-scope-resume.js +134 -122
  44. package/dist/services/runtime-scope-worker.js +48 -49
  45. package/dist/services/scope-writer-cleanup.js +26 -0
  46. package/dist/services/stage-context.js +6 -1
  47. package/dist/services/stage-lifecycle.js +1 -1
  48. package/dist/services/stage-pause.js +13 -29
  49. package/dist/services/stage-report-renderer.js +116 -6
  50. package/dist/services/usage.js +2 -9
  51. package/dist/services/vnext-code-review.js +10 -7
  52. package/dist/services/vnext-code.js +9 -17
  53. package/dist/services/vnext-merge.js +64 -21
  54. package/dist/services/vnext-plan-review.js +43 -30
  55. package/dist/services/vnext-plan.js +28 -24
  56. package/dist/services/vnext-protocolize.js +183 -20
  57. package/dist/services/vnext-specify.js +27 -19
  58. package/dist/services/work-registry.js +229 -150
  59. package/dist/storage/database.js +286 -56
  60. package/dist/storage/foreign-key-transaction.js +39 -0
  61. package/dist/storage/immutable-input.js +45 -0
  62. package/dist/storage/import-guard.js +102 -0
  63. package/dist/storage/native-cleanup.js +52 -0
  64. package/dist/storage/projection-lock.js +73 -0
  65. package/dist/storage/writer-contract.js +3 -3
  66. package/dist/storage/writer-migration.js +24 -41
  67. package/package.json +1 -1
  68. package/tools/repair-paused-run-status.mjs +48 -23
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.105
4
+
5
+ ### Patch Changes
6
+
7
+ - Unify managed lifecycle correction and Stage/Work instructions across harnesses; enforce phase-specific workspace ownership; keep accepted Stage, Work, HITL, protocol, and RUN publications recoverable while moving filesystem and process work outside SQLite writer transactions. Retain exact native and physical-owner evidence through controller capture and snapshot restore.
8
+
3
9
  ## 0.9.0-beta.96
4
10
 
5
11
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.104",
4
- "cli_commit": "d8b1b96db830c5e56e8409dfc94837fb73638673",
5
- "built_at": "2026-09-25T13:44:30.094Z",
3
+ "cli_version": "0.9.0-beta.105",
4
+ "cli_commit": "e55ba4f273ae0873e06ebe9ea75e6a6e1eb8b095",
5
+ "built_at": "2026-09-26T22:58:06.111Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
6
6
  import { quote } from "shell-quote";
7
7
  import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../services/lifecycle-invocations.js";
8
8
  import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
9
- import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
9
+ import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, bindLifecyclePublishedPacket, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, retainObservedLifecycleSemantics, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
10
10
  import { createContext, createRouterContext } from "../runtime/context.js";
11
11
  import { migrateStoreWriter } from "../storage/writer-migration.js";
12
12
  import { helpForArgs } from "./help.js";
@@ -147,15 +147,19 @@ export async function runCli(args, io = defaultIo, env = process.env) {
147
147
  }
148
148
  const routerContext = createRouterContext(env);
149
149
  const publicCommand = quote(["dd-flow", ...output.args]);
150
+ const nativeCommand = quote(["dd-flow", ...args]);
150
151
  const lifecycleCommand = parseLifecycleCommand(publicCommand);
151
152
  if (lifecycleCommand.kind === "standalone")
152
153
  validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
153
154
  const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
155
+ const nativeEvent = env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone"
156
+ ? observedLifecycleNativeEvent(routerContext, nativeCommand, env.DD_FLOW_DAEMON_ID) : null;
157
+ const diagnostic = env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone"
158
+ ? observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID, observedInvocation?.id) : null;
159
+ if (diagnostic && (!nativeEvent || Number(diagnostic.hook_event_id) > nativeEvent.id)) {
160
+ throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
161
+ }
154
162
  if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
155
- const diagnostic = observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
156
- if (diagnostic)
157
- throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
158
- const nativeEvent = observedLifecycleNativeEvent(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
159
163
  throw new AppError(nativeEvent ? "invocation_assignment_missing" : "invocation_receipt_missing", nativeEvent ? "Native call was observed, but its arguments do not match an issued lifecycle assignment" : "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
160
164
  }
161
165
  const invocationId = observedInvocation?.id ?? suppliedInvocationId;
@@ -253,6 +257,8 @@ export async function runCli(args, io = defaultIo, env = process.env) {
253
257
  }
254
258
  else {
255
259
  invocationSettlement = { context: dispatchContext, id: invocationId };
260
+ if (observedInvocation)
261
+ retainObservedLifecycleSemantics(dispatchContext, invocationId, invocationArgs);
256
262
  dispatchArgs = [...invocationArgs, "--hook-event-id", admission.eventKey];
257
263
  preparedInput?.route?.parsed.options.set("hook-event-id", [admission.eventKey]);
258
264
  dispatchContext.env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(lifecycleInvocationScope(dispatchContext, invocationId));
@@ -268,11 +274,20 @@ export async function runCli(args, io = defaultIo, env = process.env) {
268
274
  if (!retainedOutcome)
269
275
  preflightCliCompatibility(dispatchContext, invocationArgs, env, scopeProjectRoot);
270
276
  if (!retainedOutcome) {
271
- const command = lifecycleCommand.kind === "none" ? publicCommand : quote(["dd-flow", ...dispatchArgs]);
272
- const event = lifecycleCommand.kind === "none" && env.DD_FLOW_DAEMON_ID ? observedLifecycleNativeEvent(dispatchContext, command, env.DD_FLOW_DAEMON_ID) : null;
277
+ const command = !invocationId ? nativeCommand : quote(["dd-flow", ...dispatchArgs]);
278
+ const event = !invocationId ? nativeEvent : null;
273
279
  settleHook = observeLifecycleCommand(dispatchContext, command, event ?? undefined);
274
280
  }
275
281
  const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output, preparedInput);
282
+ if (!retainedOutcome && invocationArgs[0] === "stage" && invocationArgs[1] === "start"
283
+ && result && typeof result === "object" && "prompt_path" in result && typeof result.prompt_path === "string") {
284
+ try {
285
+ bindLifecyclePublishedPacket(dispatchContext, fs.readFileSync(result.prompt_path, "utf8"));
286
+ }
287
+ catch (cause) {
288
+ throw new AppError("invocation_packet_binding_failed", "Stage started but its published lifecycle assignments could not be retained", 1, { effect: "committed", cause: String(cause) });
289
+ }
290
+ }
276
291
  const completedHook = settleHook;
277
292
  settleHook = undefined;
278
293
  completedHook?.({ result });
@@ -303,7 +318,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
303
318
  if (!settleHook && !invocationSettlement) {
304
319
  let earlyContext;
305
320
  try {
306
- const command = quote(["dd-flow", ...output.args]);
321
+ const command = quote(["dd-flow", ...args]);
307
322
  const router = createRouterContext(env);
308
323
  const event = observedLifecycleNativeEvent(router, command, env.DD_FLOW_DAEMON_ID);
309
324
  // A correctable prepare rejection precedes the terminal command's
@@ -19,7 +19,7 @@ async function hook(options) {
19
19
  if (identity.stale) return event === "PreToolUse" ? { decision: "deny", reason: "agy_stale_hook" } : {};
20
20
  if (event === "PreToolUse" || event === "PostToolUse") {
21
21
  const phase = event === "PreToolUse" ? "before" : "after", args = payload.toolCall?.args ?? {};
22
- const normalized = { schema_id: "dd-flow/agy-tool-event@1", event_id: identity.event_id, phase, daemon_id: identity.daemon_id, conversation_id: payload.conversationId, parent_conversation_id: identity.parent_conversation_id, execution_num: payload.executionNum, step_index: payload.stepIdx, tool: payload.toolCall?.name, input: args, workspace_paths: payload.workspacePaths, transcript_path: payload.transcriptPath, model: payload.modelName, profile: identity.profile, ...(phase === "after" ? { error: payload.error ?? "" } : {}) };
22
+ const normalized = { schema_id: "dd-flow/agy-tool-event@1", event_id: identity.event_id, phase, daemon_id: identity.daemon_id, launch_cwd: identity.launch_cwd, conversation_id: payload.conversationId, parent_conversation_id: identity.parent_conversation_id, execution_num: payload.executionNum, step_index: payload.stepIdx, tool: payload.toolCall?.name, input: args, workspace_paths: payload.workspacePaths, transcript_path: payload.transcriptPath, model: payload.modelName, profile: identity.profile, ...(phase === "after" ? { error: payload.error ?? "" } : {}) };
23
23
  await invokeNativeHook({ bin: options["dd-flow-bin"] ?? identity.dd_flow_bin,
24
24
  args: ["agy", "event", "handle", "--project-root", options["project-root"] ?? identity.project_root, "--json"],
25
25
  payload: normalized, home: options["dd-flow-home"] ?? identity.dd_flow_home, timeoutMs: budget.remaining("admission", 15_000) });
@@ -28,7 +28,7 @@ async function writeJson(file, value) { const temporary = `${file}.${process.pid
28
28
  async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
29
29
  async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace a non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
30
30
  const shell = value => `'${String(value).replaceAll("'", `'"'"'`)}'`;
31
- function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary, DD_AGY_HOOK_NODE: process.execPath, DD_AGY_HOOK_ENTRY: config.entryPath, DD_AGY_STATE_DIR: config.stateDir, DD_AGY_DAEMON_ID: config.daemonId }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY"]) delete env[key]; return env; }
31
+ export function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary, DD_AGY_HOOK_NODE: process.execPath, DD_AGY_HOOK_ENTRY: config.entryPath, DD_AGY_STATE_DIR: config.stateDir, DD_AGY_DAEMON_ID: config.daemonId }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY","DD_FLOW_DAEMON_ID"]) delete env[key]; if (config.ddFlowHome && config.daemonId) env.DD_FLOW_DAEMON_ID = config.daemonId; return env; }
32
32
  function errorPayload(error) { return { code: error.code ?? "operation_failed", message: error.message ?? String(error), retryable: error.retryable === true, ...(error.details === undefined ? {} : { details: error.details }) }; }
33
33
  async function forwardUsage(config, sessionId, result, tools, descendants) {
34
34
  if (!config.ddFlowBin || !config.projectRoot) return;
@@ -424,7 +424,7 @@ export class Runtime {
424
424
  async observeHook(event, payload, budget = null) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); budget?.remaining("identity"); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); if (stale) { await this.journal("stale_hook", { hook_event: event, conversation_id: conversationId, step_index: step }); return { stale: true }; } const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(event === "PreToolUse" ? { last_pre_hook: fingerprint } : {}), ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_pre_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const parent = child?.parent_provider_session_id ?? null;
425
425
  const observed = payload.model ? observedProfile(payload) : {};
426
426
  await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: conversationId, parentSessionId: parent, requested: this.config, observed, source: "native.hook", evidence: payload.model ? "configured" : "unavailable", reason: payload.model ? null : "hook_omits_current_model" });
427
- const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null, persistence_timing: this.persistTiming ?? null }); await this.completePendingTurn(); return { event_id: eventId, daemon_id: this.config.daemonId, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
427
+ const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null, persistence_timing: this.persistTiming ?? null }); await this.completePendingTurn(); return { event_id: eventId, daemon_id: this.config.daemonId, launch_cwd: this.config.cwd, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
428
428
  }
429
429
  async finalizeProviderForControl() {
430
430
  const pending = this.providerFinalization;
@@ -31,7 +31,7 @@ function errorPayload(error) { return { code: error.code ?? "operation_failed",
31
31
  function shellQuote(value) { return `'${String(value).replace(/'/g, "'\\''")}'`; }
32
32
 
33
33
  function executable(command, args = []) { const resolved = command ?? process.env.DD_GROK_BIN ?? "grok"; return /\.[cm]?js$/.test(resolved) ? { command: process.execPath, args: [resolved, ...args] } : { command: resolved, args }; }
34
- function isolatedEnv(config) { return { ...process.env, HOME: config.grokHome, GROK_HOME: config.grokHome, ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome } : {}), ...(config.resourceHome ? { DD_FLOW_RESOURCE_HOME: config.resourceHome } : {}), ...(config.authPath ? { GROK_AUTH_PATH: config.authPath } : {}) }; }
34
+ export function isolatedEnv(config) { const env = { ...process.env, HOME: config.grokHome, GROK_HOME: config.grokHome, ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome } : {}), ...(config.resourceHome ? { DD_FLOW_RESOURCE_HOME: config.resourceHome } : {}), ...(config.authPath ? { GROK_AUTH_PATH: config.authPath } : {}) }; delete env.DD_FLOW_DAEMON_ID; if (config.ddFlowHome && config.daemonId) env.DD_FLOW_DAEMON_ID = config.daemonId; return env; }
35
35
  function within(candidate, root) { const resolved = path.resolve(candidate); const parent = path.resolve(root); return resolved === parent || resolved.startsWith(`${parent}${path.sep}`); }
36
36
 
37
37
  async function writeIsolatedConfig(paths) {
@@ -167,8 +167,9 @@ export async function startDaemon(options) {
167
167
  const previous = await readState(paths.dir); if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config); if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous daemon died with an active or unproven Session tree", false, { daemon_id: previous.daemon_id });
168
168
  await removeSocket(paths.socket, paths.dir); await mkdir(paths.home, { recursive: true, mode: 0o700 }); const importedSession = await materializeSessionArchive(paths, config.sessionArchive); const copiedAuth = await copyAuth(paths, authSource); await writeIsolatedConfig(paths); if (!noFlow) await writeHook(paths, config, absolute(options.entryPath, "dd-grok entry")); const versions = await doctor({ bin: config.bin }); if (importedSession && importedSession.grok_version !== versions.versions.grok) throw new DaemonError("session_archive_version_mismatch", "Grok Build session archive was created by a different CLI version", false, { archive_version: importedSession.grok_version, current_version: versions.versions.grok }); const configIsolation = await inspectIsolatedConfig(config);
169
169
  const resourceProcess = await registerDaemonProcess(config, { kind: "grok-daemon", owner: `grok:${path.basename(paths.dir)}`, operation: `grok-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
170
- const state = { descendants: previous?.descendants ?? [], schema_id: STATE_SCHEMA, daemon_id: randomUUID(), pid: null, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", active_tree: false, recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start", auth_status: copiedAuth ? "copied" : "absent", ...(importedSession ? { imported_session: importedSession } : {}), versions: versions.versions, config_isolation: configIsolation, config, sessions: importedSession ? [{ provider_session_id: importedSession.provider_session_id, adapter_session_id: importedSession.provider_session_id, parent_provider_session_id: null, root_provider_session_id: importedSession.provider_session_id, cwd: importedSession.source_cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess };
171
- await writeState(paths.state, state); const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [absolute(options.entryPath, "dd-grok entry"), "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
170
+ const daemonId = randomUUID();
171
+ const state = { descendants: previous?.descendants ?? [], schema_id: STATE_SCHEMA, daemon_id: daemonId, pid: null, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", active_tree: false, recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start", auth_status: copiedAuth ? "copied" : "absent", ...(importedSession ? { imported_session: importedSession } : {}), versions: versions.versions, config_isolation: configIsolation, config: { ...config, daemonId }, sessions: importedSession ? [{ provider_session_id: importedSession.provider_session_id, adapter_session_id: importedSession.provider_session_id, parent_provider_session_id: null, root_provider_session_id: importedSession.provider_session_id, cwd: importedSession.source_cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess };
172
+ await writeState(paths.state, state); const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [absolute(options.entryPath, "dd-grok entry"), "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(state.config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
172
173
  try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (status.ready) return status; } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
173
174
  catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
174
175
  }
@@ -2,6 +2,8 @@ export type DelegationWork = { work_id: string; start_command: string; launch_po
2
2
  export type DelegationInput = { harness: string; stage: string; capacity: number; works?: DelegationWork[]; totalWorks?: number; remainingWorks?: number };
3
3
  export function delegationAdapter(harness: string): string | null;
4
4
  export function delegationContract(harness: string): string;
5
+ export const lifecycleRetryInstruction: string;
6
+ export const stageLifecycleInstruction: string;
5
7
  export function workerDelegationTask(input: { workId: string; startCommand: string; launchPolicy?: string }): string;
6
8
  export function renderDelegationInstructions(input: DelegationInput): string;
7
9
  export function renderCapacityInstructions(input: { harness: string; maximum: number }): string;
@@ -49,7 +49,14 @@ export function delegationContract(harness) {
49
49
  return `dd-flow/delegation/${key}@${key === "codex" ? 2 : 1}`;
50
50
  }
51
51
 
52
- export const lifecycleRetryInstruction = "If the CLI rejects the call with effect=no_effect, recoverable=true and retry_command, execute that exact retry_command in this same Session after correcting the reported input. Never invent IDs. If the native shell tool rejects the launch before the CLI starts, correct only that tool-call error and repeat the same exact command; do not change its arguments. A hook/transport failure is an infrastructure error: stop and report it without retrying. For any other failure, stop and report the exact error; do not run status as a substitute, call a hook handler, retry blindly, or finish the Work.";
52
+ export const lifecycleRetryInstruction = "A failure is not permission to replay a lifecycle command. For proven effect=no_effect with recoverable=true and retry_command, correct the input and execute that exact issued successor. A check failure can retain receipts while leaving Work running: correct the evidenced cause first, then use the CLI's exact retry_command for an intentional fresh check attempt; never replay a check merely because Work is unsettled. If continuation.kind=repair_required, or a successful result has outcome=repair_required and a repair Work ID, end this Turn for the registered repair child; use any retry_command only after that repair settles and verification is refreshed. Unknown effects require reconciliation, never replay. A committed effect without an explicit continuation is not retryable. A native shell launch refusal before the CLI starts permits correcting only that shell-form error and retrying the same exact command. Otherwise report the primary error and stop; never invent IDs, call hooks directly, or run status as a substitute.";
53
+
54
+ export const stageLifecycleInstruction = [
55
+ "The project root identifies this RUN; use the Stage packet's declared execution workspace and absolute artifact paths. Do not change a native Session's working directory to a workspace provisioned for a later Stage.",
56
+ "In a controller-managed fanout, the coordinator ends the entry Turn after the Work graph is returned; the controller dispatches children. A completed child or Work is not Stage success: settle the declared graph and Stage gate before finishing.",
57
+ "Execute each runtime-issued lifecycle operation as a standalone call. Invocation IDs are internal authority; never add, copy or invent one.",
58
+ lifecycleRetryInstruction
59
+ ].join(" ");
53
60
 
54
61
  export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
55
62
  if (!workId || !startCommand) throw new Error("ready fan-out Work lacks its exact start command");
@@ -62,16 +62,12 @@ export function ensureProtocolFiles(input) {
62
62
  writeState(statePath, { ...state, flow_contract: flowContract, updated_at: input.now });
63
63
  return { ...state, flow_contract: flowContract, updated_at: input.now };
64
64
  }
65
- export function ensureRuntimeProtocolFiles(input) {
65
+ /** Read legacy state and prepare a registration without publishing files. */
66
+ export function prepareRuntimeProtocolFiles(input) {
66
67
  const currentFlowContract = loadProjectFlowContract(input.projectRoot);
67
68
  const statePath = path.join(input.runtimeDir, "state.json");
68
69
  const existing = readStateIfExists(statePath);
69
70
  const flowContract = existing?.flow_contract ?? currentFlowContract;
70
- ensureDir(input.runtimeDir);
71
- const metadataPath = path.join(input.runtimeDir, "metadata.json");
72
- if (!fs.existsSync(metadataPath)) {
73
- fs.writeFileSync(metadataPath, `${JSON.stringify({ protocol_id: input.protocolId, handshake_id: input.handshakeId, project_root: input.projectRoot }, null, 2)}\n`);
74
- }
75
71
  const state = existing ??
76
72
  {
77
73
  schema_version: schemaVersion,
@@ -103,9 +99,22 @@ export function ensureRuntimeProtocolFiles(input) {
103
99
  updated_at: input.now
104
100
  };
105
101
  const workspace = input.workspacePath ? { ...state.workspace, worktree_path: input.workspacePath } : state.workspace;
106
- const nextState = { ...state, workspace, flow_contract: flowContract, updated_at: input.now };
107
- writeState(statePath, nextState);
108
- return nextState;
102
+ return { ...state, workspace, flow_contract: flowContract, updated_at: input.now };
103
+ }
104
+ /** Metadata is a retained write-once compatibility artifact, never authority. */
105
+ export function publishRuntimeProtocolMetadata(input) {
106
+ ensureDir(input.runtimeDir);
107
+ const metadataPath = path.join(input.runtimeDir, "metadata.json");
108
+ if (fs.existsSync(metadataPath))
109
+ return;
110
+ const bytes = `${JSON.stringify({ protocol_id: input.protocolId, handshake_id: input.handshakeId, project_root: input.projectRoot }, null, 2)}\n`;
111
+ try {
112
+ fs.writeFileSync(metadataPath, bytes, { flag: "wx" });
113
+ }
114
+ catch (error) {
115
+ if (error.code !== "EEXIST")
116
+ throw error;
117
+ }
109
118
  }
110
119
  export function readState(projectRoot, protocolId) {
111
120
  return readStateFile(stateJsonPath(projectRoot, protocolId), protocolId);
@@ -8,11 +8,11 @@ export function createRouterContext(env) {
8
8
  now: () => new Date().toISOString()
9
9
  };
10
10
  }
11
- export function createContext(env, mode = "initialize") {
11
+ export function createContext(env, mode = "initialize", importPermitId) {
12
12
  const ddFlowHome = resolveDdFlowHome(env);
13
13
  return {
14
14
  ddFlowHome,
15
- db: getDatabase(ddFlowHome, mode),
15
+ db: getDatabase(ddFlowHome, mode, importPermitId),
16
16
  env,
17
17
  now: () => new Date().toISOString()
18
18
  };
@@ -6,8 +6,9 @@ import { prepareJsonFile } from "../cli/input-preparation.js";
6
6
  import { projectCheckoutRoot, resolveProjectRoot, runtimeStateJsonPath } from "../storage/paths.js";
7
7
  import { appendAudit } from "./audit.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
- import { protocolRunDiagnostics, readProtocolRuntimeState, repairProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
+ import { persistProtocolState, prepareProtocolRuntimeRepair, protocolRunDiagnostics, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
10
  import { publicSessionIdentity } from "./session-identity.js";
11
+ import { publishRunProjections } from "./runs.js";
11
12
  const actionKinds = new Set([
12
13
  "repair_protocol_state",
13
14
  "cancel_queue_job",
@@ -187,30 +188,38 @@ export function prepareCleanupApply(context, input) {
187
188
  }
188
189
  export function cleanupApply(context, input) {
189
190
  const { project, reason, plan } = input.prepared ?? prepareCleanupApply(context, input);
191
+ // A supplied plan can outlive its initial inspection. Refresh external
192
+ // workspace evidence before reserving the writer, then recheck SQL below.
193
+ if (input.prepared)
194
+ validateNoDestructiveSurprises(context, plan, input.force);
195
+ const prepared = prepareCleanupEffects(context, plan);
190
196
  const now = context.now();
191
197
  const results = [];
192
- context.db.beginWriteTransaction();
193
- let executing = false;
198
+ let executing = false, committed = false;
194
199
  try {
195
- validateNoDestructiveSurprises(context, plan, input.force);
196
- for (const action of plan.actions) {
197
- executing = true;
198
- results.push(applyAction(context, action, reason, input.force, now));
199
- }
200
- const changed = results.filter((result) => result.changed).length;
201
- if (changed > 0) {
202
- appendAudit(context, {
203
- projectId: project.id,
204
- eventType: "cleanup.applied",
205
- reason,
206
- forced: input.force,
207
- payload: { project_id: project.id, action_count: plan.actions.length, changed, results }
208
- });
209
- }
210
- context.db.exec("COMMIT");
200
+ context.db.writeTransaction(() => {
201
+ context.db.afterCommit(() => { committed = true; });
202
+ validateNoDestructiveSurprises(context, plan, input.force, false);
203
+ prepared.assertUnchanged();
204
+ for (const action of plan.actions) {
205
+ executing = true;
206
+ results.push(applyAction(context, action, reason, input.force, now, prepared));
207
+ }
208
+ const changed = results.filter((result) => result.changed).length;
209
+ if (changed > 0) {
210
+ appendAudit(context, {
211
+ projectId: project.id,
212
+ eventType: "cleanup.applied",
213
+ reason,
214
+ forced: input.force,
215
+ payload: { project_id: project.id, action_count: plan.actions.length, changed, results }
216
+ });
217
+ }
218
+ });
211
219
  }
212
220
  catch (error) {
213
- context.db.exec("ROLLBACK");
221
+ if (committed || error instanceof AppError && error.details.effect === "committed")
222
+ throw new AppError(error instanceof AppError ? error.code : "cleanup_publication_failed", error instanceof Error ? error.message : "Committed cleanup publication failed", error instanceof AppError ? error.exitCode : 1, { ...(error instanceof AppError ? error.details : {}), effect: "committed", publication_pending: true, attempted_results: results, cause: String(error) });
214
223
  if (executing)
215
224
  throw new AppError("cleanup_apply_failed", "Cleanup failed after execution began; inspect retained artifacts before retrying", 1, { effect: "unknown", database_rolled_back: true, attempted_results: results, cause: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { cause_code: error.code } : {}) });
216
225
  throw error;
@@ -274,7 +283,7 @@ function validateCleanupPlan(value, project) {
274
283
  });
275
284
  return { project_root: project.root, project_id: project.id, actions };
276
285
  }
277
- function validateNoDestructiveSurprises(context, plan, force) {
286
+ function validateNoDestructiveSurprises(context, plan, force, inspectFiles = true) {
278
287
  for (const action of plan.actions) {
279
288
  if (action.kind === "repair_protocol_state")
280
289
  requireProtocol(context, action.protocol_id, action.project_id);
@@ -284,13 +293,13 @@ function validateNoDestructiveSurprises(context, plan, force) {
284
293
  cleanupRunIndex(run.index_json);
285
294
  }
286
295
  }
287
- const destructive = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
296
+ const destructive = inspectFiles && plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
288
297
  if (destructive && !force) {
289
298
  throw new AppError("worktree_dirty", "cleanup apply refuses dirty worktree actions without --force --reason", 1, {
290
299
  path: destructive.worktree_path
291
300
  });
292
301
  }
293
- const unsafeWorktree = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && !safeCleanupWorktreePath(context, action.project_id, action.worktree_path));
302
+ const unsafeWorktree = inspectFiles && plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && !safeCleanupWorktreePath(context, action.project_id, action.worktree_path));
294
303
  if (unsafeWorktree) {
295
304
  throw new AppError("worktree_cleanup_unsafe", "cleanup apply refuses unsafe worktree path", 1, {
296
305
  path: unsafeWorktree.worktree_path
@@ -309,12 +318,53 @@ function validateNoDestructiveSurprises(context, plan, force) {
309
318
  });
310
319
  }
311
320
  }
312
- function applyAction(context, action, reason, force, now) {
321
+ function prepareCleanupEffects(context, plan) {
322
+ const checks = [], files = new Map();
323
+ const repairs = new Map();
324
+ const read = (sql, params) => {
325
+ const value = context.db.get(sql, params), encoded = JSON.stringify(value);
326
+ checks.push(() => { if (JSON.stringify(context.db.get(sql, params)) !== encoded)
327
+ throw new AppError("cleanup_plan_changed", "Cleanup target changed during preparation", 1, { effect: "no_effect" }); });
328
+ return value;
329
+ };
330
+ const remember = (file) => { if (file !== undefined)
331
+ files.set(file, fs.existsSync(file)); };
332
+ for (const action of plan.actions) {
333
+ if (action.kind === "repair_protocol_state") {
334
+ read("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [action.project_id, action.protocol_id]);
335
+ const protocol = requireProtocol(context, action.protocol_id, action.project_id);
336
+ remember(protocol.state_path);
337
+ repairs.set(action, prepareProtocolRuntimeRepair(context, protocol));
338
+ }
339
+ if (action.kind === "discard_stale_run") {
340
+ const run = read("SELECT * FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
341
+ if (run) {
342
+ remember(run.runtime_path);
343
+ remember(run.run_dir);
344
+ remember(run.workspace_root);
345
+ }
346
+ }
347
+ if (action.kind === "close_worktree_record") {
348
+ const record = read("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id]);
349
+ remember(action.worktree_path ?? record?.worktree_path);
350
+ }
351
+ }
352
+ return { repairs, assertUnchanged: () => { for (const check of checks)
353
+ check(); }, exists: (file) => {
354
+ if (!files.has(file))
355
+ throw new AppError("cleanup_plan_changed", "Cleanup target has no prepared filesystem observation", 1, { path: file, effect: "no_effect" });
356
+ return files.get(file);
357
+ } };
358
+ }
359
+ function applyAction(context, action, reason, force, now, prepared) {
313
360
  if (action.kind === "repair_protocol_state" && action.protocol_id) {
314
361
  const protocol = requireProtocol(context, action.protocol_id, action.project_id);
315
362
  const beforePath = protocol.state_path;
316
- const existed = fs.existsSync(beforePath);
317
- repairProtocolRuntimeState(context, protocol);
363
+ const existed = prepared.exists(beforePath), repair = prepared.repairs.get(action);
364
+ context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [repair.statePath, repair.planPath, action.project_id, action.protocol_id]);
365
+ persistProtocolState(context, { ...protocol, state_path: repair.statePath, plan_path: repair.planPath }, repair.state);
366
+ protocol.state_path = repair.statePath;
367
+ appendAudit(context, { protocolId: protocol.id, projectId: protocol.project_id, eventType: "protocol.runtime_state_repaired", payload: { protocol_id: protocol.id, path: repair.statePath, repaired_from: repair.previousStatePath, source: "sqlite" } });
318
368
  return {
319
369
  kind: action.kind,
320
370
  changed: !existed || beforePath !== protocol.state_path,
@@ -369,21 +419,19 @@ function applyAction(context, action, reason, force, now) {
369
419
  if (run.status === "discarded") {
370
420
  const index = discardedRunIndex(run.index_json, reason, now);
371
421
  context.db.run("UPDATE runs SET verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE project_id = ? AND id = ?", [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
372
- if (fs.existsSync(run.runtime_path))
373
- fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
422
+ context.db.afterCommit(() => publishRunProjections(context, action.project_id, action.run_id));
374
423
  return { kind: action.kind, changed: true, run_id: action.run_id };
375
424
  }
376
425
  if (run.status !== "running") {
377
426
  return { kind: action.kind, changed: false, skipped: true, reason: `already_${run.status}`, run_id: action.run_id };
378
427
  }
379
- if (fs.existsSync(run.runtime_path) || fs.existsSync(run.run_dir) || fs.existsSync(run.workspace_root)) {
428
+ if (prepared.exists(run.runtime_path) || prepared.exists(run.run_dir) || prepared.exists(run.workspace_root)) {
380
429
  return { kind: action.kind, changed: false, skipped: true, reason: "runtime_or_workspace_reappeared", run_id: action.run_id };
381
430
  }
382
431
  const index = discardedRunIndex(run.index_json, reason, now);
383
432
  context.db.run(`UPDATE runs SET status = 'discarded', verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = ?
384
433
  WHERE project_id = ? AND id = ? AND status = 'running'`, [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
385
- fs.mkdirSync(run.run_dir, { recursive: true });
386
- fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
434
+ context.db.afterCommit(() => publishRunProjections(context, action.project_id, action.run_id));
387
435
  context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
388
436
  WHERE project_id = ? AND run_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.run_id]);
389
437
  context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE project_id = ? AND run_id = ? AND ended_at IS NULL", [now, action.project_id, action.run_id]);
@@ -425,7 +473,7 @@ function applyAction(context, action, reason, force, now) {
425
473
  }
426
474
  if (action.kind === "close_worktree_record" && action.protocol_id) {
427
475
  const worktreePath = action.worktree_path ?? context.db.get("SELECT worktree_path FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id])?.worktree_path;
428
- const status = worktreePath && fs.existsSync(worktreePath) ? "kept" : "removed";
476
+ const status = worktreePath && prepared.exists(worktreePath) ? "kept" : "removed";
429
477
  const result = context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ?
430
478
  WHERE project_id = ? AND protocol_id = ? AND status = 'active'`, [status, now, now, action.project_id, action.protocol_id]);
431
479
  return {
@@ -444,6 +492,7 @@ function discardedRunIndex(indexJson, reason, now) {
444
492
  index.verdict = "discarded";
445
493
  index.next_action = reason;
446
494
  index.updated_at = now;
495
+ index.runtime_revision = (typeof index.runtime_revision === "number" ? index.runtime_revision : 0) + 1;
447
496
  index.completed_at ??= now;
448
497
  index.finished_at ??= now;
449
498
  return index;
@@ -326,33 +326,40 @@ export function reconcileUnfinishedChecks(context, input) {
326
326
  return reconciled;
327
327
  }
328
328
  function reserveOrReuseReceipt(context, input) {
329
- context.db.beginWriteTransaction();
330
- try {
329
+ // Receipt files and artifact hashes are prepared before reserving SQLite.
330
+ // The compact SQL snapshot below fences races with another check settlement.
331
+ const snapshotSql = "SELECT id, status, exit_code, input_hash, profile_hash, command, gate, retry_of_receipt_id, receipt_path FROM check_receipts WHERE project_id = ? AND run_id = ? ORDER BY reservation_sequence, rowid";
332
+ const snapshot = JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId]));
333
+ const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
334
+ if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
335
+ throw new AppError("check_receipt_changed", "Check receipts changed during evidence preparation", 1);
336
+ const prior = receipts.filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash).at(-1) ?? null;
337
+ const reusablePrior = prior?.status === "passed" && checkReceiptEvidenceValid(prior);
338
+ const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
339
+ const successor = retryIds.size === 1 ? receipts.find((receipt) => receipt.retry_of_receipt_id === [...retryIds][0] && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash) : null;
340
+ const reusableSuccessor = successor?.status === "passed" && checkReceiptEvidenceValid(successor);
341
+ return context.db.writeTransaction(() => {
331
342
  assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
332
- const prior = latestApplicableReceipt(context, input);
343
+ if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
344
+ throw new AppError("check_receipt_changed", "Check receipts changed before reservation", 1);
333
345
  if (prior?.status === "running")
334
346
  throw new AppError("check_in_progress", "A matching check invocation is still running", 1, { check_receipt_id: prior.id });
335
- const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
336
347
  if (retryIds.size > 1)
337
348
  throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
338
349
  const retryId = [...retryIds][0];
339
350
  if (retryId) {
340
351
  if (!input.retryReason?.trim())
341
352
  throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
342
- const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
343
353
  const predecessor = receipts.find(receipt => receipt.id === retryId);
344
354
  if (!predecessor || !["failed", "aborted"].includes(predecessor.status) || predecessor.input_hash !== input.inputHash || predecessor.profile_hash !== input.profileHash || predecessor.command !== input.group.command || predecessor.gate !== input.group.gate)
345
355
  throw new AppError("retry_check_invalid", "Retry predecessor does not match this exact check contract", 2, { check_receipt_id: retryId });
346
- const successor = receipts.find(receipt => receipt.retry_of_receipt_id === retryId && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash);
347
356
  if (prior?.id !== (successor?.id ?? predecessor.id))
348
357
  throw new AppError("retry_check_invalid", "This retry was superseded by a newer check outcome; inspect the latest receipt", 2, { check_receipt_id: retryId, latest_check_receipt_id: prior?.id ?? null, latest_status: prior?.status ?? null });
349
358
  if (successor?.status === "running")
350
359
  throw new AppError("check_in_progress", "The requested retry is already running", 1, { check_receipt_id: successor.id, retry_of_receipt_id: retryId });
351
360
  if (successor) {
352
- if (successor.status === "passed" && checkReceiptEvidenceValid(successor)) {
353
- context.db.exec("COMMIT");
361
+ if (reusableSuccessor)
354
362
  return { receipt: successor };
355
- }
356
363
  throw new AppError("retry_check_invalid", "The requested retry already has a terminal outcome; choose its failed receipt for another retry", 2, { check_receipt_id: successor.id, retry_of_receipt_id: retryId, status: successor.status });
357
364
  }
358
365
  }
@@ -360,10 +367,8 @@ function reserveOrReuseReceipt(context, input) {
360
367
  // failed check again: repairing dependencies/services need not change any
361
368
  // tracked input. Lifecycle invocation replay owns command idempotency;
362
369
  // the running-receipt guard above still prevents concurrent execution.
363
- if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && checkReceiptEvidenceValid(prior)) {
364
- context.db.exec("COMMIT");
370
+ if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && reusablePrior)
365
371
  return { receipt: prior };
366
- }
367
372
  let ordinal = (context.db.get("SELECT COALESCE(MAX(reservation_sequence), 0) + 1 AS sequence FROM check_receipts WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId])?.sequence ?? 1);
368
373
  let localId = `RCP-${String(ordinal).padStart(3, "0")}`, id = `${input.workId ?? input.runId}/${localId}`;
369
374
  while (context.db.get("SELECT 1 FROM check_receipts WHERE id = ?", [id])) {
@@ -374,13 +379,8 @@ function reserveOrReuseReceipt(context, input) {
374
379
  const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
375
380
  const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
376
381
  context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, reservation_sequence, retry_of_receipt_id, retry_reason, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef).sort()), input.group.gate, input.group.command, input.inputHash, input.epoch, ordinal, reserved.retryOf ?? null, reserved.retryReason ?? null, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
377
- context.db.exec("COMMIT");
378
382
  return { reserved };
379
- }
380
- catch (error) {
381
- context.db.exec("ROLLBACK");
382
- throw error;
383
- }
383
+ }, "checks.reserveOrReuseReceipt");
384
384
  }
385
385
  function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
386
386
  function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
@@ -653,7 +653,6 @@ function explicitInputFiles(root, inputs, ignoredRoots) {
653
653
  }
654
654
  function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.reusable ? null : group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
655
655
  function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
656
- function latestApplicableReceipt(context, input) { const candidates = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash); return candidates.at(-1) ?? null; }
657
656
  export function checkReceiptEvidenceValid(receipt) { if (receipt.status !== "passed" || receipt.exit_code !== 0 || receipt.before_fingerprint !== receipt.after_fingerprint || !fs.existsSync(receipt.completion_path) || readCompletion(receipt.completion_path)?.exit_code !== 0)
658
657
  return false; const stored = readReceipt(receipt.receipt_path); if (!stored || stored.status !== "passed" || stored.input_hash !== receipt.input_hash || stored.profile_hash !== receipt.profile_hash)
659
658
  return false; const artifacts = collectCheckArtifacts(path.join(path.dirname(receipt.receipt_path), "artifacts"), stored.required_artifacts ?? []); return artifacts.complete && JSON.stringify(artifacts.items) === JSON.stringify(stored.artifacts ?? []); }
@@ -12,7 +12,7 @@ export function assertNoCorrelatedRejection(context, scope) {
12
12
  const latest = new Map();
13
13
  for (const row of rows) {
14
14
  const value = JSON.parse(row.outcome_json);
15
- latest.set(JSON.stringify([value.operation, value.session_id ?? null, value.work_id ?? null, value.attempt ?? null]), value);
15
+ latest.set(JSON.stringify([value.operation, value.session_id ?? null, value.correlation_key ?? value.fingerprint ?? value.work_id ?? null, value.attempt ?? null]), value);
16
16
  }
17
17
  for (const value of latest.values())
18
18
  if (value.error && !isRegisteredRepair(context, value.error.details.continuation, scope))
@@ -3,7 +3,7 @@ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout,
3
3
  import { getFlowRunVariables } from "./runs.js";
4
4
  import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
6
- import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
6
+ import { renderDelegationInstructions, stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
7
7
  import { assertWorkLaunchReady } from "./work-registry.js";
8
8
  function semanticCompletionPrompt(stage, completed) {
9
9
  const task = stage === "plan-review"
@@ -15,7 +15,7 @@ function semanticCompletionPrompt(stage, completed) {
15
15
  `All declared ${stage} child Work has settled (${completed} completed).`,
16
16
  task,
17
17
  "Then invoke the exact Stage finish command from the original authoritative packet.",
18
- "Only if that command actually returns continuation.kind = repair_required has repair Work been registered; in that case end this Turn immediately so the controller can dispatch it. Otherwise follow the command's actual result.",
18
+ "If that command returns either outcome = repair_required with a repair Work ID, or error.details.continuation.kind = repair_required, the repair Work is already registered. End this Turn so the controller can dispatch it. Otherwise follow the command's actual result.",
19
19
  "Do not create speculative repair Work or start a successor Stage."
20
20
  ].join(" ");
21
21
  }
@@ -27,7 +27,7 @@ export function controllerStageEntryPrompt(stage, command) {
27
27
  "Read the complete authoritative packet from the command's response_file result, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.",
28
28
  "If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
29
29
  "Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
30
- "Execute each runtime-issued lifecycle operation and target as one standalone call. Internal invocation IDs are intentionally absent; never add, copy or invent one. For a no-effect input rejection, correct the input and use its returned retry_command. If a required command is missing or no safe retry is returned, report the exact error and end the Turn so the controller can handle it."
30
+ stageLifecycleInstruction
31
31
  ].join("\n\n");
32
32
  }
33
33
  /** The shared controller owns this former eval-only Work-graph decision. */