@deksden-com/dd-flow-cli 0.9.0-beta.18 → 0.9.0-beta.21

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.
@@ -8,6 +8,7 @@ import { parseJsonObject } from "../shared/json.js";
8
8
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
11
+ import { assertRecoveryHookCurrent } from "./run-recovery.js";
11
12
  import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
12
13
  import { registerProject, requireProjectByRoot } from "./projects.js";
13
14
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
@@ -285,7 +286,7 @@ export function handleCodexHook(context, input) {
285
286
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
286
287
  // Resume legitimately receives an answer through stdin. It is matched by
287
288
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
288
- if (lifecycle.analysis.kind === "compound") {
289
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
289
290
  return {
290
291
  ok: false,
291
292
  observed: false,
@@ -299,14 +300,25 @@ export function handleCodexHook(context, input) {
299
300
  }
300
301
  };
301
302
  }
302
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
303
+ const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
303
304
  const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
304
305
  // A bootstrap stage is the first stateful command for a new materialized project.
305
306
  // Its PreToolUse event arrives before stage start can register that project.
306
307
  if (bootstrapStageStart && commandProjectRoot) {
307
308
  registerProject(context, { root: commandProjectRoot });
308
309
  }
309
- const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
310
+ let project;
311
+ try {
312
+ project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
313
+ }
314
+ catch (error) {
315
+ // A terminal command outside a registered flow gets no trusted receipt;
316
+ // the CLI will fail closed if it is actually executed. This keeps hooks
317
+ // inert for unrelated shell-parser probes and stale project directories.
318
+ if (stageFinish || stagePause || workFinish || workFail)
319
+ return { ok: true, observed: false, reason: "project_not_registered" };
320
+ throw error;
321
+ }
310
322
  if (!project)
311
323
  return { ok: true, observed: false, reason: "unrelated_cwd" };
312
324
  // Codex retains the root session_id for a thread-spawned child, while the
@@ -333,6 +345,9 @@ export function handleCodexHook(context, input) {
333
345
  harness: "codex-desktop",
334
346
  providerSessionId: providerSessionId ?? null,
335
347
  parentSessionId,
348
+ // Pinned by the owning app-server process, never derived from current RUN
349
+ // state or an agent-supplied shell command/payload.
350
+ daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
336
351
  sessionId: effectiveSessionId,
337
352
  agentId: rawChildProviderSessionId ?? null,
338
353
  turnId: turnId ?? null,
@@ -364,7 +379,7 @@ export function handleCodexHook(context, input) {
364
379
  session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
365
380
  ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
366
381
  protocol_id: protocolId,
367
- ...(providerSessionId && (flowPayload || stageStart || stageResume || workStart)
382
+ ...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
368
383
  ? {
369
384
  hookSpecificOutput: {
370
385
  hookEventName: "PreToolUse",
@@ -414,7 +429,7 @@ export function handleZcodeEvent(context, input) {
414
429
  const lifecycle = lifecycleFacts(command);
415
430
  if (!lifecycle)
416
431
  return { ok: true, observed: false, reason: "event_not_participating" };
417
- if (lifecycle.analysis.kind === "compound") {
432
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
418
433
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
419
434
  standalone_command: lifecycle.invocation.command
420
435
  });
@@ -521,12 +536,12 @@ export function handleGrokEvent(context, input) {
521
536
  const lifecycle = lifecycleFacts(command);
522
537
  if (!lifecycle)
523
538
  return { ok: true, observed: false, reason: "event_not_participating" };
524
- if (lifecycle.analysis.kind === "compound") {
539
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
525
540
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
526
541
  standalone_command: lifecycle.invocation.command
527
542
  });
528
543
  }
529
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
544
+ const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
530
545
  const expectedRoot = resolveProjectRoot(input.projectRoot);
531
546
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
532
547
  throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
@@ -570,7 +585,7 @@ export function handleGrokEvent(context, input) {
570
585
  });
571
586
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
572
587
  ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
573
- return (flowPayload || stageStart || stageResume || workStart)
588
+ return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
574
589
  ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
575
590
  : result;
576
591
  }
@@ -635,7 +650,7 @@ function handleControlledToolEvent(context, input, event, harness) {
635
650
  const lifecycle = lifecycleFacts(command);
636
651
  if (!lifecycle)
637
652
  return { ok: true, observed: false, reason: "event_not_participating" };
638
- if (lifecycle.analysis.kind === "compound") {
653
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
639
654
  throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
640
655
  }
641
656
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
@@ -747,25 +762,43 @@ function lifecycleFacts(command) {
747
762
  return null;
748
763
  const invocation = analysis.invocation;
749
764
  const stageStart = invocation.operation === "stage_start";
765
+ const stageFinish = invocation.operation === "stage_finish";
766
+ const stagePause = invocation.operation === "stage_pause";
750
767
  const stageResume = invocation.operation === "stage_resume";
751
768
  const workStart = invocation.operation === "work_start";
769
+ const workFinish = invocation.operation === "work_finish";
770
+ const workFail = invocation.operation === "work_fail";
752
771
  return {
753
772
  analysis,
754
773
  invocation,
755
774
  flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
756
775
  stageStart,
776
+ stageFinish,
777
+ stagePause,
757
778
  stageResume,
758
- workStart,
779
+ workStart, workFinish, workFail,
759
780
  bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
760
781
  commandProjectRoot: commandOption(invocation, "project-root"),
761
- stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
762
- runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
763
- workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
782
+ stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
783
+ runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
784
+ workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
764
785
  contextSha256: commandOption(invocation, "context-sha256")
765
786
  };
766
787
  }
788
+ // Worker completion and the two HITL commands intentionally take their
789
+ // payload from stdin. Their generated pipe/heredoc is one lifecycle command,
790
+ // not a second authority. Everything else remains standalone-only.
791
+ function allowsStdinLifecycleCompound(facts) {
792
+ const operation = facts.invocation.operation;
793
+ return (operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
794
+ || ((operation === "stage_pause" || operation === "stage_resume") && facts.invocation.heredoc);
795
+ }
767
796
  function lifecycleMatchKey(facts, projectRoot) {
768
- const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
797
+ const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
798
+ if (invocation.operation === "recovery_accept") {
799
+ const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
800
+ return run && recovery ? recoveryAcceptMatchKey(run, recovery, projectRoot) : null;
801
+ }
769
802
  if (bootstrapStageStart)
770
803
  return bootstrapMatchKey({
771
804
  projectRoot,
@@ -775,12 +808,61 @@ function lifecycleMatchKey(facts, projectRoot) {
775
808
  });
776
809
  if (stageStart && runId && stageName)
777
810
  return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
811
+ if (stageFinish && runId && stageName)
812
+ return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
813
+ if (stagePause && runId && stageName && workId)
814
+ return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
778
815
  if (stageResume && runId && stageName && workId)
779
816
  return stageResumeMatchKey(runId, stageName, workId, projectRoot);
780
817
  if (workStart && workId)
781
- return workStartMatchKey(workId, projectRoot);
818
+ return workStartMatchKey(workId, projectRoot, commandOption(invocation, "recovery-id"));
819
+ if (workFinish && workId)
820
+ return workLifecycleMatchKey("work_finish", workId, projectRoot);
821
+ if (workFail && workId)
822
+ return workLifecycleMatchKey("work_fail", workId, projectRoot);
782
823
  return null;
783
824
  }
825
+ export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
826
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
827
+ }
828
+ export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
829
+ return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
830
+ }
831
+ export function workLifecycleMatchKey(operation, workId, projectRoot) {
832
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
833
+ }
834
+ /** Claims a Work terminal command only after its exact hook receipt has proved
835
+ * the physical Session that issued it. Work code performs the owner check. */
836
+ export function claimWorkLifecycleHookEvent(context, input) {
837
+ const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
838
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
839
+ projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
840
+ }).eventKey;
841
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
842
+ if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
843
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey });
844
+ }
845
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
846
+ if (claimed.changes !== 1)
847
+ throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
848
+ return hookSessionIdentity(context, input.projectId, eventKey);
849
+ }
850
+ /** Claims a lifecycle command only after its exact hook receipt has proved the
851
+ * physical Session that issued it. Stage code performs the Work-owner check. */
852
+ export function claimStageLifecycleHookEvent(context, input) {
853
+ const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
854
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
855
+ projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
856
+ }).eventKey;
857
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
858
+ if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
859
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey });
860
+ }
861
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
862
+ if (claimed.changes !== 1)
863
+ throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
864
+ return hookSessionIdentity(context, input.projectId, eventKey);
865
+ }
784
866
  /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
785
867
  export function sessionIdForHookEvent(context, projectId, eventKey) {
786
868
  const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
@@ -869,7 +951,7 @@ export function bootstrapMatchKey(input) {
869
951
  })).digest("hex");
870
952
  }
871
953
  export function claimWorkStartHookEvent(context, input) {
872
- const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
954
+ const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
873
955
  const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
874
956
  if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
875
957
  throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
@@ -887,7 +969,9 @@ export function assertStageStartHookEvent(context, input) {
887
969
  if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
888
970
  throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_key: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
889
971
  }
890
- return hookSessionIdentity(context, input.projectId, input.eventKey);
972
+ const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
973
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
974
+ return identity;
891
975
  }
892
976
  export function claimStageStartHookEvent(context, input) {
893
977
  assertStageStartHookEvent(context, input);
@@ -925,16 +1009,19 @@ export function claimStageResumeHookEvent(context, input) {
925
1009
  if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
926
1010
  throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
927
1011
  }
1012
+ const identity = hookSessionIdentity(context, input.projectId, event.event_key);
1013
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
928
1014
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
929
1015
  if (claimed.changes !== 1)
930
1016
  throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
931
- return hookSessionIdentity(context, input.projectId, event.event_key);
1017
+ return identity;
932
1018
  }
933
- export function workStartMatchKey(workId, projectRoot) {
1019
+ export function workStartMatchKey(workId, projectRoot, recoveryId) {
934
1020
  return crypto.createHash("sha256").update(JSON.stringify({
935
1021
  operation: "work_start",
936
1022
  work_id: workId,
937
- project_root: resolveProjectRoot(projectRoot)
1023
+ project_root: resolveProjectRoot(projectRoot),
1024
+ ...(recoveryId ? { recovery_id: recoveryId } : {})
938
1025
  })).digest("hex");
939
1026
  }
940
1027
  export function hookStatusForProject(context, projectId) {
@@ -138,7 +138,7 @@ function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
138
138
  executable,
139
139
  argv,
140
140
  env,
141
- args: parseCommandArgs(argv.slice(2)),
141
+ args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
142
142
  command: quote(segment.words),
143
143
  rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
144
144
  wrapped,
@@ -170,15 +170,25 @@ function wrappedInvocationFromSegment(segment) {
170
170
  return parseLifecycleCommandInner(payload, true);
171
171
  }
172
172
  function lifecycleOperation(argv) {
173
+ if (argv[0] === "run" && argv[1] === "recovery" && argv[2] === "accept")
174
+ return "recovery_accept";
173
175
  const key = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
174
176
  if (key === "session register")
175
177
  return "session_register";
176
178
  if (key === "stage start")
177
179
  return "stage_start";
180
+ if (key === "stage finish")
181
+ return "stage_finish";
182
+ if (key === "stage pause")
183
+ return "stage_pause";
178
184
  if (key === "stage resume")
179
185
  return "stage_resume";
180
186
  if (key === "work start")
181
187
  return "work_start";
188
+ if (key === "work finish")
189
+ return "work_finish";
190
+ if (key === "work fail")
191
+ return "work_fail";
182
192
  return null;
183
193
  }
184
194
  export function parseCommandArgs(argv) {
@@ -6,6 +6,7 @@ import { flowCommand } from "./stage-pause.js";
6
6
  import { validateSchema } from "./schema-validation.js";
7
7
  import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
8
8
  import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
9
+ import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
9
10
  /** A deterministic dispatcher; only the launched harness Session performs agent work. */
10
11
  export async function serveMergeRequests(context, input) {
11
12
  const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
@@ -59,12 +60,23 @@ export function mergeServerStatus(context) { return { ok: true, servers: context
59
60
  export function stopMergeServer(context, input) { const changed = context.db.run("UPDATE merge_servers SET status = 'stopping', heartbeat_at = ? WHERE server_id = ? AND status = 'running'", [context.now(), input.serverId]); if (changed.changes !== 1)
60
61
  throw new AppError("not_found", "Running merge-server was not found", 1, { server_id: input.serverId }); return { ok: true, server_id: input.serverId, status: "stopping" }; }
61
62
  async function dispatch(context, input) {
63
+ let generation;
64
+ context.db.exec("BEGIN IMMEDIATE");
62
65
  const token = crypto.randomUUID();
63
66
  const leaseUntil = new Date(Date.now() + 120_000).toISOString();
64
- const claimed = context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued' AND execution_route = 'server'", [input.serverId, token, leaseUntil, context.now(), input.request.merge_request_id]);
65
- if (claimed.changes !== 1)
66
- return;
67
- const leaseHeartbeat = setInterval(() => { const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
67
+ try {
68
+ generation = assertRunMutationAllowed(context, input.request.project_id, input.request.run_id);
69
+ const claimed = context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued' AND execution_route = 'server'", [input.serverId, token, leaseUntil, context.now(), input.request.merge_request_id]);
70
+ context.db.exec("COMMIT");
71
+ if (claimed.changes !== 1)
72
+ return;
73
+ }
74
+ catch (error) {
75
+ context.db.exec("ROLLBACK");
76
+ throw error;
77
+ }
78
+ const leaseHeartbeat = setInterval(() => { if (guarded(context, input.request) || (recoveryGuard(context, input.request.project_id, input.request.run_id)?.generation ?? 0) !== generation)
79
+ return; const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
68
80
  leaseHeartbeat.unref();
69
81
  const stateDir = path.join(input.root, input.request.merge_request_id);
70
82
  fs.mkdirSync(stateDir, { recursive: true });
@@ -77,13 +89,14 @@ async function dispatch(context, input) {
77
89
  const executable = resolveHarnessCommand(context.ddFlowHome, input.profile.harness, "adapter");
78
90
  const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", input.request.target_workspace, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, input.profile.harness), "--json"];
79
91
  try {
80
- const adapterCall = (args, timeoutMs, progressMessage) => runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) });
92
+ const adapterCall = (args, timeoutMs, progressMessage) => { assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation); return runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) }); };
81
93
  await adapterCall(["daemon", "start", ...common]);
82
94
  const created = await adapterCall(["session", "create", ...common]);
83
95
  const sessionId = adapterSessionId(created);
84
96
  if (!sessionId)
85
97
  throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
86
98
  const prompted = await adapterCall(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60 * 1000, `MERGE ${input.request.merge_request_id} Session ${sessionId} is still running`);
99
+ assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation);
87
100
  const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
88
101
  context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ profile: input.profile, executable, session_id: sessionId, response: prompted }), context.now(), input.request.merge_request_id]);
89
102
  event(input.journal, { type: "adapter_settled", request_id: input.request.merge_request_id, session_id: sessionId, request_status: request?.status ?? null, at: context.now() });
@@ -91,6 +104,10 @@ async function dispatch(context, input) {
91
104
  context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
92
105
  }
93
106
  catch (error) {
107
+ if (error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code)) {
108
+ event(input.journal, { type: "recovery_dispatch_blocked", request_id: input.request.merge_request_id, code: error.code, at: context.now() });
109
+ throw error;
110
+ }
94
111
  const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
95
112
  if (request?.status === "dispatching")
96
113
  context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
@@ -102,9 +119,11 @@ async function dispatch(context, input) {
102
119
  clearInterval(leaseHeartbeat);
103
120
  }
104
121
  }
105
- function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
122
+ function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (guarded(context, request) || selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
106
123
  return false; selected.add(request.project_id); return true; }).slice(0, limit); }
107
- function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
124
+ function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id, project_id, run_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
125
+ if (guarded(context, request))
126
+ continue;
108
127
  const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
109
128
  if (work?.status === "created")
110
129
  context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_requeued" }), context.now(), request.merge_request_id]);
@@ -119,6 +138,7 @@ catch (error) {
119
138
  } validateSchema({ schemaName: "agent-profile", file }); if (value.id !== id)
120
139
  throw new AppError("agent_profile_invalid", "Agent profile id does not match its requested filename", 2, { file, expected: id, actual: value.id ?? null }); return value; }
121
140
  function event(file, value) { fs.appendFileSync(file, `${JSON.stringify(value)}\n`); }
141
+ function guarded(context, request) { const guard = recoveryGuard(context, request.project_id, request.run_id); return Boolean(guard && ["draining", "sealed", "resuming"].includes(guard.status)); }
122
142
  function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
123
143
  function processAlive(pid) { if (!pid)
124
144
  return false; try {