@deksden-com/dd-flow-cli 0.9.0-beta.103 → 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 (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +32 -11
  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/harness-runtime/lib/managed-daemon.mjs +11 -6
  10. package/dist/protocol/local-files.js +18 -9
  11. package/dist/runtime/context.js +2 -2
  12. package/dist/services/cleanup.js +81 -32
  13. package/dist/services/code-checks.js +18 -19
  14. package/dist/services/continuation-outcome.js +1 -1
  15. package/dist/services/controller-fanout.js +3 -3
  16. package/dist/services/eval-snapshots.js +69 -41
  17. package/dist/services/execution-policy.js +12 -1
  18. package/dist/services/hooks.js +150 -42
  19. package/dist/services/lanes.js +23 -18
  20. package/dist/services/lifecycle-command.js +11 -2
  21. package/dist/services/lifecycle-invocations.js +291 -54
  22. package/dist/services/managed-processes.js +35 -23
  23. package/dist/services/merge-queue.js +71 -67
  24. package/dist/services/merge-server.js +3 -11
  25. package/dist/services/migrations.js +28 -12
  26. package/dist/services/plan-runtime.js +8 -4
  27. package/dist/services/projects.js +3 -11
  28. package/dist/services/protocols.js +98 -68
  29. package/dist/services/recovery-snapshot-database.js +43 -8
  30. package/dist/services/run-control-worker.js +15 -32
  31. package/dist/services/run-control.js +3 -17
  32. package/dist/services/run-controller-capture.js +58 -27
  33. package/dist/services/run-controller-process.js +21 -30
  34. package/dist/services/run-controller-recovery.js +36 -37
  35. package/dist/services/run-controller.js +81 -86
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +19 -64
  38. package/dist/services/run-recovery-runtime.js +24 -14
  39. package/dist/services/run-recovery.js +56 -54
  40. package/dist/services/runs.js +218 -160
  41. package/dist/services/runtime-budget.js +19 -45
  42. package/dist/services/runtime-scope-capture.js +8 -6
  43. package/dist/services/runtime-scope-control.js +49 -39
  44. package/dist/services/runtime-scope-resume.js +134 -122
  45. package/dist/services/runtime-scope-worker.js +48 -49
  46. package/dist/services/scope-writer-cleanup.js +26 -0
  47. package/dist/services/stage-context.js +6 -1
  48. package/dist/services/stage-lifecycle.js +1 -1
  49. package/dist/services/stage-pause.js +13 -29
  50. package/dist/services/stage-report-renderer.js +116 -6
  51. package/dist/services/usage.js +2 -9
  52. package/dist/services/vnext-code-review.js +10 -7
  53. package/dist/services/vnext-code.js +9 -17
  54. package/dist/services/vnext-merge.js +64 -21
  55. package/dist/services/vnext-plan-review.js +43 -30
  56. package/dist/services/vnext-plan.js +28 -24
  57. package/dist/services/vnext-protocolize.js +183 -20
  58. package/dist/services/vnext-specify.js +27 -19
  59. package/dist/services/work-registry.js +229 -150
  60. package/dist/storage/database.js +286 -56
  61. package/dist/storage/foreign-key-transaction.js +39 -0
  62. package/dist/storage/immutable-input.js +45 -0
  63. package/dist/storage/import-guard.js +102 -0
  64. package/dist/storage/native-cleanup.js +52 -0
  65. package/dist/storage/projection-lock.js +73 -0
  66. package/dist/storage/writer-contract.js +3 -3
  67. package/dist/storage/writer-migration.js +24 -41
  68. package/package.json +1 -1
  69. 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.103",
4
- "cli_commit": "26ffa3e3ac8382c8a83bae2593a91a3e922f10a9",
5
- "built_at": "2026-09-25T12:03:44.786Z",
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,11 +318,17 @@ 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]);
307
- const event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
308
- if (event) {
321
+ const command = quote(["dd-flow", ...args]);
322
+ const router = createRouterContext(env);
323
+ const event = observedLifecycleNativeEvent(router, command, env.DD_FLOW_DAEMON_ID);
324
+ // A correctable prepare rejection precedes the terminal command's
325
+ // single-use claim. Correlate its unique hook by the same lifecycle
326
+ // match key used at dispatch, including compound provider tool shells;
327
+ // otherwise a repaired retry sees two fresh, unclaimed events.
328
+ if (event || (error.details.phase === "prepare" && error.details.effect === "no_effect"
329
+ && fs.existsSync(path.join(router.ddFlowHome, "db.sqlite")))) {
309
330
  earlyContext = createContext({ ...env }, "hook");
310
- observeLifecycleCommand(earlyContext, command, event)({ error });
331
+ observeLifecycleCommand(earlyContext, command, event ?? undefined)({ error });
311
332
  }
312
333
  }
313
334
  catch (persistence) {
@@ -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");
@@ -53,15 +53,20 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
53
53
  }
54
54
 
55
55
  export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
56
- for (const lease of leases.values()) {
57
- if (lease.error) throw lease.error;
58
- if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown" });
59
- }
56
+ for (const lease of leases.values()) if (!lease.stateDir && lease.error) throw lease.error;
60
57
  const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
61
58
  const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
62
59
  if (!owner) return;
63
60
  const record = state.resource_process;
64
61
  if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
62
+ for (const lease of leases.values()) {
63
+ if (lease.stateDir !== stateDir) continue;
64
+ if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown", details: { process_id: lease.record.id, cause: lease.error ? errorRecord(lease.error) : null } });
65
+ // A failed renewal is not a lost lease. Reconfirm before productive work;
66
+ // never dispatch on the old lease merely because it has time remaining.
67
+ if (lease.error && !lease.lost) await heartbeatDaemonProcess(lease.config, lease.record);
68
+ if (lease.error) throw lease.error;
69
+ }
65
70
  for (;;) {
66
71
  assertCurrent();
67
72
  const result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch });
@@ -211,7 +216,7 @@ export async function heartbeatDaemonProcess(config, record) {
211
216
  if (!record?.id || !record.lease_token) return;
212
217
  let lease = leases.get(record.id);
213
218
  if (!lease) {
214
- lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000 };
219
+ lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
215
220
  leases.set(record.id, lease);
216
221
  lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
217
222
  lease.timer.unref();
@@ -228,7 +233,7 @@ export async function heartbeatDaemonProcess(config, record) {
228
233
  lease.error = null;
229
234
  lease.expiresAt = Date.now() + 300_000;
230
235
  } catch (cause) {
231
- lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id } });
236
+ lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id, cause: errorRecord(cause) } });
232
237
  throw lease.error;
233
238
  }
234
239
  })();
@@ -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;