@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.11

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 (43) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +3 -3
  4. package/dist/cli/run-cli.js +127 -8
  5. package/dist/runtime/context.js +3 -1
  6. package/dist/schemas/code-review-result.schema.json +1 -1
  7. package/dist/schemas/code-work-batch.schema.json +4 -3
  8. package/dist/schemas/code-work-result.schema.json +1 -1
  9. package/dist/schemas/harness-config.schema.json +23 -0
  10. package/dist/schemas/plan-review-decision.schema.json +1 -1
  11. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  12. package/dist/services/cleanup.js +18 -8
  13. package/dist/services/code-checks.js +194 -44
  14. package/dist/services/engines.js +4 -4
  15. package/dist/services/eval-snapshots.js +10 -5
  16. package/dist/services/harness-config.js +66 -0
  17. package/dist/services/hooks.js +62 -28
  18. package/dist/services/lanes.js +1 -0
  19. package/dist/services/managed-processes.js +169 -0
  20. package/dist/services/merge-server.js +8 -2
  21. package/dist/services/portable-refs.js +57 -0
  22. package/dist/services/prompts.js +4 -2
  23. package/dist/services/run-engine-bindings.js +19 -61
  24. package/dist/services/run-projection.js +10 -8
  25. package/dist/services/runs.js +71 -9
  26. package/dist/services/schema-validation.js +11 -11
  27. package/dist/services/session-identity.js +19 -0
  28. package/dist/services/sessions.js +26 -11
  29. package/dist/services/stage-lifecycle.js +15 -8
  30. package/dist/services/stage-pause.js +35 -20
  31. package/dist/services/usage.js +74 -42
  32. package/dist/services/vnext-code-review.js +82 -41
  33. package/dist/services/vnext-code.js +98 -34
  34. package/dist/services/vnext-fanout.js +5 -12
  35. package/dist/services/vnext-merge.js +144 -65
  36. package/dist/services/vnext-plan-review.js +50 -35
  37. package/dist/services/vnext-plan.js +69 -21
  38. package/dist/services/vnext-protocolize.js +6 -6
  39. package/dist/services/vnext-specify.js +6 -6
  40. package/dist/services/work-registry.js +150 -40
  41. package/dist/storage/database.js +128 -2
  42. package/package.json +1 -1
  43. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
@@ -7,6 +7,7 @@ import { AppError } from "../shared/errors.js";
7
7
  import { parseJsonObject } from "../shared/json.js";
8
8
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
+ import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
10
11
  import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
11
12
  import { registerProject, requireProjectByRoot } from "./projects.js";
12
13
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
@@ -267,7 +268,13 @@ export function handleCodexHook(context, input) {
267
268
  if (eventName !== "PreToolUse") {
268
269
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
269
270
  }
270
- const sessionId = stringValue(payload.session_id);
271
+ const rootProviderSessionId = stringValue(payload.session_id) ?? null;
272
+ // Codex deliberately retains its legacy root session_id inside a
273
+ // thread-spawned subagent. `agent_id` is the current child Thread id and
274
+ // therefore the only valid identity for a fresh Work.
275
+ const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
276
+ const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
277
+ const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
271
278
  const turnId = stringValue(payload.turn_id);
272
279
  const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
273
280
  const command = lifecycleCommandFromPayload(payload);
@@ -278,7 +285,7 @@ export function handleCodexHook(context, input) {
278
285
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
279
286
  // Resume legitimately receives an answer through stdin. It is matched by
280
287
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
281
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
288
+ if (lifecycle.analysis.kind === "compound") {
282
289
  return {
283
290
  ok: false,
284
291
  observed: false,
@@ -302,21 +309,28 @@ export function handleCodexHook(context, input) {
302
309
  const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
303
310
  if (!project)
304
311
  return { ok: true, observed: false, reason: "unrelated_cwd" };
305
- const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
312
+ const binding = rootProviderSessionId ? upsertSessionBindingFromPayload(context, project, rootProviderSessionId, payload) : undefined;
313
+ const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
314
+ const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
306
315
  const observedSession = flowPayload
307
- ? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
316
+ ? bindObservedFlowSession(context, project, {
317
+ ...flowPayload,
318
+ harness: "codex-desktop",
319
+ provider_session_id: providerSessionId ?? flowPayload.provider_session_id ?? null,
320
+ parent_session_id: parentSessionId
321
+ }, storageId ?? undefined)
308
322
  : undefined;
309
- const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
323
+ const effectiveSessionId = observedSession?.session_id ?? storageId;
310
324
  const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
311
325
  const eventKey = hookEventKey(payload, eventName, toolName, command);
312
326
  const inserted = recordHookEvent(context, {
313
327
  projectId: project.id,
314
328
  protocolId,
315
329
  harness: "codex-desktop",
316
- providerSessionId: sessionId ?? null,
317
- parentSessionId: null,
330
+ providerSessionId: providerSessionId ?? null,
331
+ parentSessionId,
318
332
  sessionId: effectiveSessionId,
319
- agentId: stringValue(payload.agent_id) ?? null,
333
+ agentId: rawChildProviderSessionId ?? null,
320
334
  turnId: turnId ?? null,
321
335
  eventName,
322
336
  toolName: toolName ?? null,
@@ -343,9 +357,10 @@ export function handleCodexHook(context, input) {
343
357
  observed: inserted,
344
358
  duplicate: !inserted,
345
359
  event_key: eventKey,
346
- session_id: effectiveSessionId,
360
+ session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
361
+ ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
347
362
  protocol_id: protocolId,
348
- ...(sessionId && (flowPayload || stageStart || stageResume || workStart)
363
+ ...(providerSessionId && (flowPayload || stageStart || stageResume || workStart)
349
364
  ? {
350
365
  hookSpecificOutput: {
351
366
  hookEventName: "PreToolUse",
@@ -356,6 +371,24 @@ export function handleCodexHook(context, input) {
356
371
  : {})
357
372
  };
358
373
  }
374
+ /** The hook protocol does not carry the immediate parent. Recover it only from this child's own native transcript. */
375
+ function codexNativeParentId(payload, childId) {
376
+ const transcript = stringValue(payload.transcript_path);
377
+ if (!transcript || !path.isAbsolute(transcript))
378
+ return null;
379
+ try {
380
+ const firstLine = fs.readFileSync(transcript, "utf8").split("\n", 1)[0];
381
+ if (!firstLine)
382
+ return null;
383
+ const record = JSON.parse(firstLine);
384
+ return record.type === "session_meta" && record.payload?.id === childId && typeof record.payload.parent_thread_id === "string"
385
+ ? record.payload.parent_thread_id
386
+ : null;
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ }
359
392
  /** Convert a zcode-acp Bash tool notification into the same trusted lifecycle receipt used by Codex hooks. */
360
393
  export function handleZcodeEvent(context, input) {
361
394
  const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
@@ -377,7 +410,7 @@ export function handleZcodeEvent(context, input) {
377
410
  const lifecycle = lifecycleFacts(command);
378
411
  if (!lifecycle)
379
412
  return { ok: true, observed: false, reason: "event_not_participating" };
380
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
413
+ if (lifecycle.analysis.kind === "compound") {
381
414
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
382
415
  standalone_command: lifecycle.invocation.command
383
416
  });
@@ -403,8 +436,8 @@ export function handleZcodeEvent(context, input) {
403
436
  throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
404
437
  const childProviderSessionId = stringValue(runtime.childSessionId);
405
438
  const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
406
- const sessionId = `zcode-acp:${providerSessionId}`;
407
- const parentSessionId = childProviderSessionId ? `zcode-acp:${rootProviderSessionId}` : null;
439
+ const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
440
+ const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
408
441
  const agentId = stringValue(runtime.agentId);
409
442
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
410
443
  ...flowPayload,
@@ -463,9 +496,8 @@ export function handleZcodeEvent(context, input) {
463
496
  duplicate: !inserted,
464
497
  event_key: eventKey,
465
498
  harness: "zcode-acp",
466
- session_id: sessionId,
467
- provider_session_id: providerSessionId,
468
- parent_session_id: parentSessionId,
499
+ session: nativeSessionIdentity("zcode-acp", providerSessionId),
500
+ ...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
469
501
  daemon_id: daemonId ?? null
470
502
  };
471
503
  }
@@ -485,7 +517,7 @@ export function handleGrokEvent(context, input) {
485
517
  const lifecycle = lifecycleFacts(command);
486
518
  if (!lifecycle)
487
519
  return { ok: true, observed: false, reason: "event_not_participating" };
488
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
520
+ if (lifecycle.analysis.kind === "compound") {
489
521
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
490
522
  standalone_command: lifecycle.invocation.command
491
523
  });
@@ -509,8 +541,8 @@ export function handleGrokEvent(context, input) {
509
541
  if (!rootProviderSessionId || !providerSessionId)
510
542
  throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
511
543
  const isChild = providerSessionId !== rootProviderSessionId;
512
- const sessionId = `grok-acp:${providerSessionId}`;
513
- const parentSessionId = isChild ? `grok-acp:${stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId}` : null;
544
+ const sessionId = storageSessionId(nativeSessionIdentity("grok-acp", providerSessionId));
545
+ const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
514
546
  const daemonId = stringValue(ddGrok.daemonId);
515
547
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
516
548
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
@@ -532,8 +564,8 @@ export function handleGrokEvent(context, input) {
532
564
  projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
533
565
  cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
534
566
  });
535
- const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "grok-acp", session_id: sessionId,
536
- provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId ?? null };
567
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
568
+ ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
537
569
  return (flowPayload || stageStart || stageResume || workStart)
538
570
  ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
539
571
  : result;
@@ -565,8 +597,8 @@ export function handleOpenCodeEvent(context, input) {
565
597
  if (resolveProjectRoot(directory) !== expectedRoot) {
566
598
  throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
567
599
  }
568
- const sessionId = `${harness}:${providerSessionId}`;
569
- const parentSessionId = nativeParentId ? `${harness}:${nativeParentId}` : null;
600
+ const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
601
+ const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
570
602
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
571
603
  const baseKey = `${eventId}:${phase}`;
572
604
  if (phase === "after") {
@@ -577,7 +609,7 @@ export function handleOpenCodeEvent(context, input) {
577
609
  eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
578
610
  eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
579
611
  });
580
- return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId };
612
+ return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
581
613
  }
582
614
  if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
583
615
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
@@ -585,7 +617,7 @@ export function handleOpenCodeEvent(context, input) {
585
617
  const lifecycle = lifecycleFacts(command);
586
618
  if (!lifecycle)
587
619
  return { ok: true, observed: false, reason: "event_not_participating" };
588
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
620
+ if (lifecycle.analysis.kind === "compound") {
589
621
  throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
590
622
  }
591
623
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
@@ -614,7 +646,7 @@ export function handleOpenCodeEvent(context, input) {
614
646
  status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
615
647
  });
616
648
  recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
617
- const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId, provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId };
649
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
618
650
  return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
619
651
  }
620
652
  /** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
@@ -736,7 +768,7 @@ export function sessionIdForHookEvent(context, projectId, eventKey) {
736
768
  /** Reads immutable identity facts already captured by PreToolUse. */
737
769
  export function hookSessionIdentity(context, projectId, eventKey) {
738
770
  const event = context.db.get(`SELECT he.id, he.harness, he.provider_session_id, he.parent_session_id, he.daemon_id, he.session_id, he.agent_id, he.turn_id, he.transcript_path, he.provider, he.model, he.reasoning, he.mode, he.agent_type, COALESCE(he.cwd, csb.cwd) AS cwd
739
- FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = he.session_id
771
+ FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = COALESCE(he.provider_session_id, he.session_id)
740
772
  WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
741
773
  if (!event?.session_id)
742
774
  throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
@@ -747,7 +779,9 @@ export function hookSessionIdentity(context, projectId, eventKey) {
747
779
  parentSessionId: event.parent_session_id,
748
780
  daemonId: event.daemon_id,
749
781
  agentId: event.agent_id,
750
- sessionId: event.harness === "codex-desktop" ? event.agent_id ?? event.session_id : event.session_id,
782
+ // agent_id is an auxiliary provider fact, never a replacement Session ID.
783
+ sessionId: event.session_id,
784
+ nativeSessionId: event.provider_session_id ?? event.session_id,
751
785
  turnId: event.turn_id,
752
786
  transcriptPath: event.transcript_path,
753
787
  provider: event.provider,
@@ -288,6 +288,7 @@ export async function waitAcquireLaneLock(context, input) {
288
288
  timeoutSeconds
289
289
  });
290
290
  }
291
+ input.progress?.(`lane ${lane} is waiting at queue position ${String(result.payload.position ?? "unknown")}; next update in ${pollIntervalSeconds} seconds`);
291
292
  await delay(pollIntervalSeconds * 1000);
292
293
  }
293
294
  }
@@ -0,0 +1,169 @@
1
+ import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import net from "node:net";
4
+ import { getResourceDatabase } from "../storage/database.js";
5
+ const defaultLeaseMs = 15 * 60_000;
6
+ export function resourceHome(context) {
7
+ return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
8
+ }
9
+ export function registerManagedProcess(context, input) {
10
+ const db = registry(context);
11
+ const now = context.now();
12
+ const id = input.id ?? `PROC-${crypto.randomUUID()}`;
13
+ const token = crypto.randomUUID();
14
+ db.run(`INSERT INTO managed_processes
15
+ (id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
16
+ VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify(input.metadata ?? {})]);
17
+ return requireProcess(db, id);
18
+ }
19
+ export function confirmManagedProcess(context, input) {
20
+ const db = registry(context);
21
+ const now = context.now();
22
+ const current = requireProcess(db, input.id);
23
+ const metadata = parseMetadata(current.metadata_json);
24
+ if (input.processGroupId)
25
+ metadata.process_group_id = input.processGroupId;
26
+ const result = db.run(`UPDATE managed_processes
27
+ SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
28
+ WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
29
+ if (result.changes !== 1)
30
+ throw new Error(`Managed process cannot be confirmed: ${input.id}`);
31
+ return requireProcess(db, input.id);
32
+ }
33
+ export function heartbeatManagedProcess(context, input) {
34
+ const db = registry(context);
35
+ return db.run("UPDATE managed_processes SET lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [leaseExpiry(context.now(), input.leaseMs), context.now(), input.id, input.leaseToken]).changes === 1;
36
+ }
37
+ export function finishManagedProcess(context, input) {
38
+ const db = registry(context);
39
+ const now = context.now();
40
+ const updated = db.run("UPDATE managed_processes SET state = ?, termination_reason = ?, finished_at = ?, updated_at = ?, lease_expires_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned')", [input.state, input.reason ?? null, now, now, now, input.id, input.leaseToken]).changes === 1;
41
+ if (updated)
42
+ db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
43
+ return updated;
44
+ }
45
+ export function processIsAlive(record) {
46
+ if (!record.pid)
47
+ return false;
48
+ try {
49
+ process.kill(record.pid, 0);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ return !record.pid_started_at || record.pid_started_at === processStartedAt(record.pid);
55
+ }
56
+ function processTreeIsAlive(record) {
57
+ const group = parseMetadata(record.metadata_json).process_group_id;
58
+ if (process.platform !== "win32" && typeof group === "number") {
59
+ try {
60
+ process.kill(-group, 0);
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ return processIsAlive(record);
68
+ }
69
+ /** Claims only expired records. Callers must still verify `processIsAlive` before stopping a PID. */
70
+ export function claimExpiredManagedProcesses(context, ownerId) {
71
+ const db = registry(context);
72
+ const now = context.now();
73
+ db.exec("BEGIN IMMEDIATE");
74
+ try {
75
+ const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
76
+ const claimed = [];
77
+ for (const candidate of candidates) {
78
+ const token = crypto.randomUUID();
79
+ const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
80
+ if (result.changes === 1)
81
+ claimed.push({ ...candidate, owner_id: ownerId, lease_token: token, state: "orphaned", updated_at: now });
82
+ }
83
+ db.exec("COMMIT");
84
+ return claimed;
85
+ }
86
+ catch (error) {
87
+ db.exec("ROLLBACK");
88
+ throw error;
89
+ }
90
+ }
91
+ export function managedProcessStatus(context) {
92
+ return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
93
+ }
94
+ /** Reconcile only records the system owns and has atomically claimed. */
95
+ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
96
+ const claimed = claimExpiredManagedProcesses(context, ownerId);
97
+ const outcomes = [];
98
+ for (const processRecord of claimed) {
99
+ if (!processTreeIsAlive(processRecord)) {
100
+ finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_not_alive" });
101
+ outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
102
+ continue;
103
+ }
104
+ terminateOwnedProcess(processRecord, "SIGTERM");
105
+ await delay(graceMs);
106
+ if (processTreeIsAlive(processRecord)) {
107
+ terminateOwnedProcess(processRecord, "SIGKILL");
108
+ await delay(Math.min(graceMs, 250));
109
+ }
110
+ if (processTreeIsAlive(processRecord)) {
111
+ outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
112
+ continue;
113
+ }
114
+ finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
115
+ outcomes.push({ id: processRecord.id, outcome: "stopped" });
116
+ }
117
+ return outcomes;
118
+ }
119
+ export async function reservePorts(context, input) {
120
+ const db = registry(context);
121
+ const claims = [];
122
+ const ports = {};
123
+ try {
124
+ for (const name of input.names) {
125
+ for (;;) {
126
+ const port = await availablePort();
127
+ const key = String(port);
128
+ const token = crypto.randomUUID();
129
+ const now = context.now();
130
+ const result = db.run("INSERT OR IGNORE INTO managed_resources (resource_kind, resource_key, owner_id, lease_token, lease_expires_at, process_id, metadata_json, created_at, updated_at) VALUES ('port', ?, ?, ?, ?, ?, ?, ?, ?)", [key, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.processId ?? null, JSON.stringify({ name }), now, now]);
131
+ if (result.changes === 1) {
132
+ claims.push({ key, token });
133
+ ports[name] = port;
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ }
139
+ catch (error) {
140
+ releasePortClaims(db, claims);
141
+ throw error;
142
+ }
143
+ return { ports, release: () => releasePortClaims(db, claims) };
144
+ }
145
+ function registry(context) { return getResourceDatabase(resourceHome(context)); }
146
+ function requireProcess(db, id) { const row = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]); if (!row)
147
+ throw new Error(`Managed process is missing: ${id}`); return row; }
148
+ function leaseExpiry(now, leaseMs = defaultLeaseMs) { return new Date(Date.parse(now) + leaseMs).toISOString(); }
149
+ function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
150
+ function releasePortClaims(db, claims) { for (const claim of claims)
151
+ db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
152
+ function terminateOwnedProcess(record, signal) {
153
+ if (!record.pid || !record.pid_started_at || !processTreeIsAlive(record))
154
+ return;
155
+ const group = parseMetadata(record.metadata_json).process_group_id;
156
+ try {
157
+ process.kill(process.platform === "win32" || typeof group !== "number" ? record.pid : -group, signal);
158
+ }
159
+ catch { /* inspect again below */ }
160
+ }
161
+ function parseMetadata(value) { try {
162
+ const parsed = JSON.parse(value);
163
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
164
+ }
165
+ catch {
166
+ return {};
167
+ } }
168
+ function availablePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : undefined; server.close((error) => error ? reject(error) : port ? resolve(port) : reject(new Error("Could not allocate local port"))); }); }); }
169
+ function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
@@ -5,6 +5,7 @@ import { AppError } from "../shared/errors.js";
5
5
  import { flowCommand } from "./stage-pause.js";
6
6
  import { validateSchema } from "./schema-validation.js";
7
7
  import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
8
+ import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
8
9
  /** A deterministic dispatcher; only the launched harness Session performs agent work. */
9
10
  export async function serveMergeRequests(context, input) {
10
11
  const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
@@ -63,6 +64,8 @@ async function dispatch(context, input) {
63
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]);
64
65
  if (claimed.changes !== 1)
65
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);
68
+ leaseHeartbeat.unref();
66
69
  const stateDir = path.join(input.root, input.request.merge_request_id);
67
70
  fs.mkdirSync(stateDir, { recursive: true });
68
71
  const promptFile = path.join(stateDir, "launch.md");
@@ -71,8 +74,8 @@ async function dispatch(context, input) {
71
74
  fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
72
75
  event(input.journal, { type: "launch_intent", server_id: input.serverId, request_id: input.request.merge_request_id, token, profile: input.profile, prompt_file: promptFile, at: context.now() });
73
76
  input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
74
- const executable = context.env[`DD_FLOW_${input.profile.harness.toUpperCase()}_ADAPTER`] ?? `dd-${input.profile.harness}`;
75
- 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, "--json"];
77
+ const executable = resolveHarnessCommand(context.ddFlowHome, input.profile.harness, "adapter");
78
+ 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"];
76
79
  try {
77
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 }) });
78
81
  await adapterCall(["daemon", "start", ...common]);
@@ -95,6 +98,9 @@ async function dispatch(context, input) {
95
98
  context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_lost_after_stage_start", error: String(error) }), context.now(), input.request.merge_request_id]);
96
99
  throw error;
97
100
  }
101
+ finally {
102
+ clearInterval(leaseHeartbeat);
103
+ }
98
104
  }
99
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') 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]))
100
106
  return false; selected.add(request.project_id); return true; }).slice(0, limit); }
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ export function assertPortableArtifactRef(value, input) {
5
+ const parsed = parseArtifactRef(value);
6
+ let file;
7
+ let root;
8
+ if (parsed.path.startsWith("run://")) {
9
+ const prefix = `run://${input.runId}/`;
10
+ if (!parsed.path.startsWith(prefix) || parsed.path.slice(prefix.length).split("/").includes(".."))
11
+ throw new AppError("invalid_evidence_ref", "Artifact RUN reference must stay in the current RUN", 2, { ref: value });
12
+ root = input.runHome;
13
+ file = path.join(root, parsed.path.slice(prefix.length));
14
+ }
15
+ else {
16
+ root = input.workspaceRoot;
17
+ if (!parsed.path)
18
+ throw new AppError("invalid_evidence_ref", "Artifact reference must name a file", 2, { ref: value });
19
+ file = path.isAbsolute(parsed.path) ? parsed.path : path.join(root, parsed.path);
20
+ }
21
+ if (!fs.existsSync(file))
22
+ throw new AppError("evidence_ref_missing", "Artifact reference does not exist", 2, { ref: value, resolved_path: file });
23
+ const realRoot = fs.realpathSync(root);
24
+ const realFile = fs.realpathSync(file);
25
+ if (!contained(realRoot, realFile))
26
+ throw new AppError("invalid_evidence_ref", "Artifact reference must stay inside its declared workspace", 2, { ref: value, resolved_path: realFile, root: realRoot });
27
+ if (parsed.lines)
28
+ validateLineRanges(realFile, parsed.lines, value);
29
+ return realFile;
30
+ }
31
+ function parseArtifactRef(value) {
32
+ if (typeof value !== "string" || !value.trim())
33
+ throw new AppError("invalid_evidence_ref", "Artifact reference must be a non-empty string", 2, { ref: value });
34
+ const [rawPath, ...fragments] = value.split("#");
35
+ if (fragments.length > 1 || (fragments.length === 1 && !fragments[0]))
36
+ throw new AppError("invalid_evidence_ref", "Artifact reference has an invalid fragment", 2, { ref: value });
37
+ const match = rawPath.match(/^(.*):(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)$/);
38
+ if (!match)
39
+ return { path: rawPath, fragment: fragments[0] ?? null, lines: null };
40
+ const lines = match[2].split(",").map((item) => {
41
+ const [startText, endText] = item.split("-");
42
+ const start = Number(startText);
43
+ const end = Number(endText ?? startText);
44
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start)
45
+ throw new AppError("invalid_evidence_ref", "Artifact line ranges must be positive and ordered", 2, { ref: value, range: item });
46
+ return { start, end };
47
+ });
48
+ return { path: match[1], fragment: fragments[0] ?? null, lines };
49
+ }
50
+ function contained(root, file) { const relative = path.relative(root, file); return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); }
51
+ function validateLineRanges(file, ranges, ref) {
52
+ const source = fs.readFileSync(file, "utf8");
53
+ const lines = source === "" ? 0 : source.replace(/\r?\n$/, "").split(/\r?\n/).length;
54
+ const invalid = ranges.find((range) => range.end > lines);
55
+ if (invalid)
56
+ throw new AppError("evidence_line_out_of_range", "Artifact line range exceeds the referenced file", 2, { ref, file, line_count: lines, invalid_range: invalid });
57
+ }
@@ -136,7 +136,9 @@ export function renderWorkerPrompt(context, input) {
136
136
  };
137
137
  }
138
138
  function runHomePath(run) {
139
- return run.run_home_path ?? path.dirname(run.run_index_path);
139
+ if (!run.run_root)
140
+ throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
141
+ return run.run_root;
140
142
  }
141
143
  function readWorkerTask(taskFile, runHome) {
142
144
  const resolved = path.resolve(taskFile);
@@ -204,7 +206,7 @@ function protocolIdForRun(context, projectId, runId) {
204
206
  return run.subject_id;
205
207
  }
206
208
  function requireRun(context, projectId, runId) {
207
- const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_index_path, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
209
+ const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
208
210
  if (!row)
209
211
  throw new AppError("not_found", `Run is not found: ${runId}`, 1);
210
212
  return row;
@@ -2,36 +2,18 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
4
  export const runEngineBindingSchemaId = "dd-flow/run-engine-binding@1";
5
- export function findRunHome(ddFlowHome, projectRoot, runIdOrAlias) {
6
- const projectsRoot = path.join(ddFlowHome, "projects");
7
- if (!fs.existsSync(projectsRoot))
5
+ /** Resolve a RUN from SQLite authority; never discover one by scanning storage paths. */
6
+ export function locateRunRoot(db, projectRoot, runIdOrAlias) {
7
+ const rows = db.all(`SELECT r.project_id, r.id AS run_id, r.run_root
8
+ FROM runs r JOIN projects p ON p.id = r.project_id
9
+ WHERE p.root = ? AND (r.id = ? OR r.short_id = ?)`, [realpathOrResolve(projectRoot), runIdOrAlias, runIdOrAlias]).filter((row) => Boolean(row.run_root));
10
+ if (rows.length > 1)
11
+ throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, { project_root: realpathOrResolve(projectRoot), candidates: rows.map((row) => row.run_id) });
12
+ const row = rows[0];
13
+ if (!row?.run_root)
8
14
  return null;
9
- const expectedRoot = realpathOrResolve(projectRoot);
10
- const matches = [];
11
- for (const projectId of fs.readdirSync(projectsRoot)) {
12
- const runsRoot = path.join(projectsRoot, projectId, "runs");
13
- if (!isDirectory(runsRoot))
14
- continue;
15
- for (const runId of fs.readdirSync(runsRoot)) {
16
- if (!runIdMatches(runId, runIdOrAlias))
17
- continue;
18
- const runHome = path.join(runsRoot, runId);
19
- const authorityPath = path.join(runHome, "run.json");
20
- const bindingPath = path.join(runHome, "engine-binding.json");
21
- const binding = readRunEngineBinding(bindingPath, { allowMissing: true });
22
- const authorityRoot = binding?.project_root ?? projectRootFromAuthority(authorityPath);
23
- if (!authorityRoot || realpathOrResolve(authorityRoot) !== expectedRoot)
24
- continue;
25
- matches.push({ project_id: projectId, run_id: runId, run_home: runHome, authority_path: authorityPath, binding_path: bindingPath });
26
- }
27
- }
28
- if (matches.length > 1) {
29
- throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, {
30
- project_root: expectedRoot,
31
- candidates: matches.map((match) => match.run_id)
32
- });
33
- }
34
- return matches[0] ?? null;
15
+ const runRoot = path.resolve(row.run_root);
16
+ return { project_id: row.project_id, run_id: row.run_id, run_root: runRoot, authority_path: path.join(runRoot, "run.json"), binding_path: path.join(runRoot, "engine-binding.json") };
35
17
  }
36
18
  export function readRunEngineBinding(file, options = {}) {
37
19
  if (!fs.existsSync(file)) {
@@ -86,6 +68,14 @@ export function allRunEngineBindings(ddFlowHome) {
86
68
  }
87
69
  return bindings;
88
70
  }
71
+ function isDirectory(value) {
72
+ try {
73
+ return fs.statSync(value).isDirectory();
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
89
79
  function isRunEngineBinding(value) {
90
80
  if (!value || typeof value !== "object" || Array.isArray(value))
91
81
  return false;
@@ -115,32 +105,6 @@ function sameEngine(left, right) {
115
105
  && left.integrity_checksum === right.integrity_checksum
116
106
  && left.snapshot_root === right.snapshot_root;
117
107
  }
118
- function projectRootFromAuthority(file) {
119
- try {
120
- const value = JSON.parse(fs.readFileSync(file, "utf8"));
121
- const execution = recordValue(value.execution);
122
- const workspace = recordValue(value.workspace);
123
- const project = recordValue(value.project);
124
- return stringValue(execution?.project_root)
125
- ?? stringValue(workspace?.project_root)
126
- ?? stringValue(project?.root)
127
- ?? stringValue(value.project_root);
128
- }
129
- catch {
130
- return null;
131
- }
132
- }
133
- function runIdMatches(fullId, idOrAlias) {
134
- return fullId === idOrAlias || /^RUN-[0-9]+$/.test(idOrAlias) && fullId.startsWith(`${idOrAlias}-`);
135
- }
136
- function isDirectory(value) {
137
- try {
138
- return fs.statSync(value).isDirectory();
139
- }
140
- catch {
141
- return false;
142
- }
143
- }
144
108
  function realpathOrResolve(value) {
145
109
  try {
146
110
  return fs.realpathSync(value);
@@ -149,9 +113,3 @@ function realpathOrResolve(value) {
149
113
  return path.resolve(value);
150
114
  }
151
115
  }
152
- function recordValue(value) {
153
- return value && typeof value === "object" && !Array.isArray(value) ? value : null;
154
- }
155
- function stringValue(value) {
156
- return typeof value === "string" && value.length > 0 ? value : null;
157
- }