@deksden-com/dd-flow-cli 0.9.0-beta.19 → 0.9.0-beta.22

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";
@@ -323,10 +324,12 @@ export function handleCodexHook(context, input) {
323
324
  // Codex retains the root session_id for a thread-spawned child, while the
324
325
  // payload transcript belongs to the current agent_id. Keep lineage separate
325
326
  // from ownership: otherwise a child overwrites the root transcript binding.
326
- const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
327
- const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
327
+ const eventKey = hookEventKey(payload, eventName, toolName, command);
328
328
  const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
329
329
  const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
330
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "codex-desktop", providerSessionId: providerSessionId ?? null, parentSessionId, daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null, sessionId: storageId, turnId: turnId ?? null, eventName, toolName: toolName ?? null, matchKey, cwd: stringValue(payload.cwd) ?? null });
331
+ const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
332
+ const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
330
333
  const observedSession = flowPayload
331
334
  ? bindObservedFlowSession(context, project, {
332
335
  ...flowPayload,
@@ -337,13 +340,15 @@ export function handleCodexHook(context, input) {
337
340
  : undefined;
338
341
  const effectiveSessionId = observedSession?.session_id ?? storageId;
339
342
  const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
340
- const eventKey = hookEventKey(payload, eventName, toolName, command);
341
343
  const inserted = recordHookEvent(context, {
342
344
  projectId: project.id,
343
345
  protocolId,
344
346
  harness: "codex-desktop",
345
347
  providerSessionId: providerSessionId ?? null,
346
348
  parentSessionId,
349
+ // Pinned by the owning app-server process, never derived from current RUN
350
+ // state or an agent-supplied shell command/payload.
351
+ daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
347
352
  sessionId: effectiveSessionId,
348
353
  agentId: rawChildProviderSessionId ?? null,
349
354
  turnId: turnId ?? null,
@@ -375,7 +380,7 @@ export function handleCodexHook(context, input) {
375
380
  session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
376
381
  ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
377
382
  protocol_id: protocolId,
378
- ...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
383
+ ...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
379
384
  ? {
380
385
  hookSpecificOutput: {
381
386
  hookEventName: "PreToolUse",
@@ -454,6 +459,10 @@ export function handleZcodeEvent(context, input) {
454
459
  const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
455
460
  const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
456
461
  const agentId = stringValue(runtime.agentId);
462
+ const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
463
+ harness: "zcode-acp", providerSessionId, command
464
+ })).digest("hex");
465
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
457
466
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
458
467
  ...flowPayload,
459
468
  harness: "zcode-acp",
@@ -461,9 +470,6 @@ export function handleZcodeEvent(context, input) {
461
470
  agent_id: agentId ?? null,
462
471
  parent_session_id: parentSessionId
463
472
  }, sessionId) : undefined;
464
- const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
465
- harness: "zcode-acp", providerSessionId, command
466
- })).digest("hex");
467
473
  const payload = {
468
474
  session_id: providerSessionId,
469
475
  agent_id: agentId,
@@ -560,12 +566,13 @@ export function handleGrokEvent(context, input) {
560
566
  const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
561
567
  const daemonId = stringValue(ddGrok.daemonId);
562
568
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
563
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
564
- ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
565
- }, sessionId) : undefined;
566
569
  const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
567
570
  harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
568
571
  })).digest("hex");
572
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
573
+ const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
574
+ ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
575
+ }, sessionId) : undefined;
569
576
  const payload = {
570
577
  ...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
571
578
  command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
@@ -581,7 +588,7 @@ export function handleGrokEvent(context, input) {
581
588
  });
582
589
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
583
590
  ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
584
- return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
591
+ return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
585
592
  ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
586
593
  : result;
587
594
  }
@@ -664,6 +671,7 @@ function handleControlledToolEvent(context, input, event, harness) {
664
671
  }
665
672
  }
666
673
  const agentId = stringValue(session.agent_id);
674
+ assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
667
675
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
668
676
  ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
669
677
  parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
@@ -791,6 +799,10 @@ function allowsStdinLifecycleCompound(facts) {
791
799
  }
792
800
  function lifecycleMatchKey(facts, projectRoot) {
793
801
  const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
802
+ if (invocation.operation === "recovery_accept") {
803
+ const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
804
+ return run && recovery ? recoveryAcceptMatchKey(run, recovery, projectRoot) : null;
805
+ }
794
806
  if (bootstrapStageStart)
795
807
  return bootstrapMatchKey({
796
808
  projectRoot,
@@ -807,7 +819,7 @@ function lifecycleMatchKey(facts, projectRoot) {
807
819
  if (stageResume && runId && stageName && workId)
808
820
  return stageResumeMatchKey(runId, stageName, workId, projectRoot);
809
821
  if (workStart && workId)
810
- return workStartMatchKey(workId, projectRoot);
822
+ return workStartMatchKey(workId, projectRoot, commandOption(invocation, "recovery-id"));
811
823
  if (workFinish && workId)
812
824
  return workLifecycleMatchKey("work_finish", workId, projectRoot);
813
825
  if (workFail && workId)
@@ -817,6 +829,9 @@ function lifecycleMatchKey(facts, projectRoot) {
817
829
  export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
818
830
  return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
819
831
  }
832
+ export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
833
+ return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
834
+ }
820
835
  export function workLifecycleMatchKey(operation, workId, projectRoot) {
821
836
  return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
822
837
  }
@@ -903,21 +918,24 @@ export function claimRecentMatchingHookEvent(context, input) {
903
918
  /** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
904
919
  export function findRecentMatchingHookEvent(context, input) {
905
920
  const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
906
- const find = () => context.db.get(`SELECT id, event_key FROM hook_events
921
+ const find = () => context.db.all(`SELECT id, event_key FROM hook_events
907
922
  WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
908
- ORDER BY id DESC LIMIT 1`, [input.projectId, input.matchKey, freshAfter]);
909
- let event = find();
923
+ ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter]);
924
+ let events = find();
910
925
  // Some harnesses publish nested-agent tool events concurrently with command
911
926
  // execution. Give the trusted event a short bounded window to reach SQLite.
912
927
  const deadline = Date.now() + 250;
913
- while (!event?.event_key && Date.now() < deadline) {
928
+ while (events.length === 0 && Date.now() < deadline) {
914
929
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
915
- event = find();
930
+ events = find();
916
931
  }
917
- if (!event?.event_key) {
932
+ if (events.length === 0 || !events[0]?.event_key) {
918
933
  throw new AppError(input.errorCode, `${input.operation} requires one matching harness lifecycle event. Stay in this Session and retry the exact standalone lifecycle command; do not create a new Session.`, 1);
919
934
  }
920
- return { id: event.id, eventKey: event.event_key };
935
+ if (events.length > 1) {
936
+ throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
937
+ }
938
+ return { id: events[0].id, eventKey: events[0].event_key };
921
939
  }
922
940
  /** Fallback for Desktop paths that execute the original command after a hook rewrite. */
923
941
  export function claimBootstrapHookEvent(context, input) {
@@ -940,7 +958,7 @@ export function bootstrapMatchKey(input) {
940
958
  })).digest("hex");
941
959
  }
942
960
  export function claimWorkStartHookEvent(context, input) {
943
- const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
961
+ const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
944
962
  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]);
945
963
  if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
946
964
  throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
@@ -958,7 +976,9 @@ export function assertStageStartHookEvent(context, input) {
958
976
  if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
959
977
  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 });
960
978
  }
961
- return hookSessionIdentity(context, input.projectId, input.eventKey);
979
+ const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
980
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
981
+ return identity;
962
982
  }
963
983
  export function claimStageStartHookEvent(context, input) {
964
984
  assertStageStartHookEvent(context, input);
@@ -996,16 +1016,19 @@ export function claimStageResumeHookEvent(context, input) {
996
1016
  if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
997
1017
  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 });
998
1018
  }
1019
+ const identity = hookSessionIdentity(context, input.projectId, event.event_key);
1020
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
999
1021
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
1000
1022
  if (claimed.changes !== 1)
1001
1023
  throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
1002
- return hookSessionIdentity(context, input.projectId, event.event_key);
1024
+ return identity;
1003
1025
  }
1004
- export function workStartMatchKey(workId, projectRoot) {
1026
+ export function workStartMatchKey(workId, projectRoot, recoveryId) {
1005
1027
  return crypto.createHash("sha256").update(JSON.stringify({
1006
1028
  operation: "work_start",
1007
1029
  work_id: workId,
1008
- project_root: resolveProjectRoot(projectRoot)
1030
+ project_root: resolveProjectRoot(projectRoot),
1031
+ ...(recoveryId ? { recovery_id: recoveryId } : {})
1009
1032
  })).digest("hex");
1010
1033
  }
1011
1034
  export function hookStatusForProject(context, projectId) {
@@ -1389,12 +1412,11 @@ function sessionBinding(context, projectId, sessionId) {
1389
1412
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
1390
1413
  }
1391
1414
  function recordHookEvent(context, input) {
1392
- const existing = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1393
- if (existing)
1415
+ if (assertHookEventReplay(context, input))
1394
1416
  return false;
1395
1417
  context.db.run(`INSERT INTO hook_events
1396
- (project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
1397
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1418
+ (project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
1419
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1398
1420
  input.projectId,
1399
1421
  input.protocolId,
1400
1422
  input.harness,
@@ -1421,6 +1443,23 @@ function recordHookEvent(context, input) {
1421
1443
  ]);
1422
1444
  return true;
1423
1445
  }
1446
+ /** A duplicate delivery is harmless only when all durable identity facts match. */
1447
+ function assertHookEventReplay(context, input) {
1448
+ const existing = context.db.get(`SELECT harness, provider_session_id, parent_session_id, daemon_id, session_id, turn_id, event_name, tool_name, match_key, cwd
1449
+ FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
1450
+ if (existing) {
1451
+ const observed = {
1452
+ harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
1453
+ daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
1454
+ event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
1455
+ };
1456
+ if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
1457
+ throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
1458
+ }
1459
+ return true;
1460
+ }
1461
+ return false;
1462
+ }
1424
1463
  function projectForHook(context, explicitRoot, cwd) {
1425
1464
  if (explicitRoot)
1426
1465
  return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
@@ -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,6 +170,8 @@ 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";
@@ -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 {