@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.122

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 (38) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/hook-ingress.js +4 -3
  4. package/dist/cli/native-hook-ingress.js +34 -2
  5. package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
  6. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +16 -0
  7. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -2
  8. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
  9. package/dist/harness-runtime/lib/dd-zcode.mjs +28 -13
  10. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  11. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  12. package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
  13. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  14. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  15. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  16. package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
  17. package/dist/runtime/context.js +1 -1
  18. package/dist/schemas/flow-run.schema.json +1 -0
  19. package/dist/services/code-checks.js +40 -0
  20. package/dist/services/codex-hook-delivery.js +6 -2
  21. package/dist/services/hooks.js +51 -59
  22. package/dist/services/lifecycle-command.js +1 -383
  23. package/dist/services/lifecycle-contract.js +1 -30
  24. package/dist/services/lifecycle-invocations.js +34 -13
  25. package/dist/services/managed-processes.js +42 -6
  26. package/dist/services/run-control.js +7 -1
  27. package/dist/services/runs.js +60 -47
  28. package/dist/services/runtime-scope-stop.js +23 -2
  29. package/dist/services/stage-report-renderer.js +19 -0
  30. package/dist/services/verification-matrix.js +325 -0
  31. package/dist/services/vnext-code-review.js +25 -8
  32. package/dist/services/vnext-code.js +38 -11
  33. package/dist/services/vnext-merge.js +30 -7
  34. package/dist/services/vnext-plan-review.js +22 -6
  35. package/dist/services/vnext-plan.js +45 -17
  36. package/dist/services/work-registry.js +13 -1
  37. package/dist/storage/database.js +43 -9
  38. package/package.json +4 -1
@@ -777,7 +777,8 @@ export function issueLifecycleInvocation(context, command, scope) {
777
777
  }
778
778
  function commandWithInvocationId(command, id) {
779
779
  const at = shellSuffixOffset(command);
780
- return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
780
+ const suffix = command.slice(at);
781
+ return `${command.slice(0, at)} --invocation-id ${id}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
781
782
  }
782
783
  /** The attempt owns authority; the caller owns presentation-only shell input.
783
784
  * Preserve current stdin/heredoc/response-file text while validating that it
@@ -909,11 +910,7 @@ export function observeLifecycleInvocation(context, input) {
909
910
  storage(context);
910
911
  const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
911
912
  const analysis = parseLifecycleCommand(input.command);
912
- if (analysis.kind !== "standalone" || analysis.invocation.wrapped) {
913
- if (analysis.kind !== "none" && ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
914
- && managedNativeOwner(context, input.identity, input.harness, input.projectId)) {
915
- throw new AppError("invocation_command_invalid", "Managed native lifecycle call must be one direct standalone command", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
916
- }
913
+ if (analysis.kind === "none" || analysis.supported === false) {
917
914
  const eventKey = input.recordReceipt();
918
915
  if (analysis.kind !== "none") {
919
916
  const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
@@ -928,22 +925,32 @@ export function observeLifecycleInvocation(context, input) {
928
925
  ...outcome,
929
926
  lifecycle_observation: {
930
927
  code: "lifecycle_shell_syntax_invalid",
931
- reason: analysis.kind === "compound" ? analysis.reason : "wrapped_lifecycle_command",
928
+ reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command",
932
929
  command_fingerprint: fingerprint(analysis.invocation),
933
930
  effect: "no_effect",
934
931
  recoverable: true
935
932
  }
936
933
  }), eventKey]);
937
934
  }
935
+ if (analysis.kind !== "none")
936
+ throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey });
938
937
  return { eventKey, duplicate: false, invocationId: null };
939
938
  }
940
- const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed') ORDER BY rowid DESC")
939
+ // Admit the one parsed leaf, never execute or fingerprint the prefix/suffix.
940
+ const leafCommand = analysis.invocation.command;
941
+ if (input.executionCwd !== undefined && !path.isAbsolute(input.executionCwd))
942
+ throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd must be absolute", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
943
+ const deliveredIdentity = JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId,
944
+ ...(input.executionCwd === undefined ? [] : [{ executionCwd: canonicalPath(input.executionCwd) }])]);
945
+ const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
941
946
  .filter(row => {
947
+ if (["executing", "settled"].includes(row.status) && row.identity_json !== deliveredIdentity)
948
+ return false;
942
949
  const scope = JSON.parse(row.scope_json);
943
950
  if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
944
951
  return false;
945
952
  try {
946
- return fingerprint(invocation(canonicalManagedCommand(context, input.command, scope))) === row.fingerprint;
953
+ return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
947
954
  }
948
955
  catch {
949
956
  return false;
@@ -979,7 +986,7 @@ export function observeLifecycleInvocation(context, input) {
979
986
  const project = requireProjectByRoot(context, scope.projectRoot);
980
987
  const identity = input.identity;
981
988
  checkNativeIdentity(scope, identity, initial.id);
982
- const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
989
+ const identityJson = deliveredIdentity;
983
990
  return context.db.writeTransaction(() => {
984
991
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
985
992
  if (row.status === "settled")
@@ -992,6 +999,9 @@ export function observeLifecycleInvocation(context, input) {
992
999
  if (row.identity_json === identityJson) {
993
1000
  return { eventKey: row.event_key, duplicate: true, ...resolved };
994
1001
  }
1002
+ if (row.identity_json && JSON.stringify(JSON.parse(row.identity_json).slice(0, 5)) === JSON.stringify(JSON.parse(identityJson).slice(0, 5))) {
1003
+ throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
1004
+ }
995
1005
  const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
996
1006
  // A native hook may execute from a provider worktree while the issued
997
1007
  // lifecycle scope is anchored to the stable project root. Project
@@ -1019,7 +1029,7 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
1019
1029
  return null;
1020
1030
  const signature = (value) => {
1021
1031
  const parsed = parseLifecycleCommand(value);
1022
- const candidate = parsed.kind === "standalone" && !parsed.invocation.wrapped ? parsed.invocation : continuationCommand(value);
1032
+ const candidate = parsed.kind !== "none" && parsed.supported ? parsed.invocation : continuationCommand(value);
1023
1033
  if (!candidate)
1024
1034
  return null;
1025
1035
  return JSON.stringify([candidate.operation, candidate.args.positional,
@@ -1080,8 +1090,9 @@ export function observedLifecycleInvocation(context, command, daemonId) {
1080
1090
  if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
1081
1091
  return null;
1082
1092
  const analysis = parseLifecycleCommand(command);
1083
- if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
1093
+ if (analysis.kind === "none" || analysis.supported === false)
1084
1094
  return null;
1095
+ const leafCommand = analysis.invocation.command;
1085
1096
  // `issued` is a rendezvous, not authority: the CLI still blocks in
1086
1097
  // awaitLifecycleInvocation until the native observer commits the receipt.
1087
1098
  const matches = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing') ORDER BY rowid DESC")
@@ -1090,7 +1101,7 @@ export function observedLifecycleInvocation(context, command, daemonId) {
1090
1101
  const scope = JSON.parse(row.scope_json);
1091
1102
  if (daemonId && scope.daemonId !== daemonId)
1092
1103
  return false;
1093
- return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
1104
+ return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
1094
1105
  }
1095
1106
  catch {
1096
1107
  return false;
@@ -1348,6 +1359,16 @@ export async function awaitLifecycleInvocation(context, input) {
1348
1359
  }
1349
1360
  input.assertCurrent(JSON.parse(row.scope_json));
1350
1361
  if (row.status === "observed") {
1362
+ if (row.identity_json) {
1363
+ // Receipt display text is bounded/redacted and may not be valid JSON.
1364
+ // Only the native admission's typed identity binds an execution route.
1365
+ const identity = JSON.parse(row.identity_json);
1366
+ const route = identity[5]?.executionCwd;
1367
+ if (identity.length > 5 && (typeof route !== "string" || !path.isAbsolute(route)))
1368
+ throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd is invalid", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
1369
+ if (typeof route === "string" && canonicalPath(process.cwd()) !== canonicalPath(route))
1370
+ throw new AppError("invocation_directory_mismatch", "Reached CLI cwd differs from the bound shell execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false, expected: route, actual: process.cwd(), actual_provenance: "cli_process_cwd" });
1371
+ }
1351
1372
  const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
1352
1373
  if (claimed.changes === 1)
1353
1374
  return { eventKey: row.event_key, replay: false };
@@ -61,7 +61,12 @@ export function prepareManagedProcessRegistration(context, input) {
61
61
  throw new AppError("validation", "Unique managed operation requires operationId", 2);
62
62
  let metadataJson;
63
63
  try {
64
- metadataJson = JSON.stringify({ ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) });
64
+ const metadata = { ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) };
65
+ delete metadata.process_birth;
66
+ delete metadata.owner_birth;
67
+ if (input.ownerPid)
68
+ metadata.owner_birth = stableProcessBirth(input.ownerPid);
69
+ metadataJson = JSON.stringify(metadata);
65
70
  }
66
71
  catch {
67
72
  throw new AppError("validation", "metadata must be JSON-serializable", 2);
@@ -86,7 +91,8 @@ export function registerManagedProcess(context, rawInput) {
86
91
  if (!input.uniqueActiveOperation)
87
92
  return insertManagedProcess(context, input, metadataJson);
88
93
  const db = registry(context);
89
- if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth }))
94
+ const observerProcessBirth = observer ? stableProcessBirth(input.pid) : null;
95
+ if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth, metadata_json: JSON.stringify({ process_birth: observerProcessBirth }) }))
90
96
  throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
91
97
  return db.writeTransaction(() => {
92
98
  const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
@@ -97,6 +103,7 @@ export function registerManagedProcess(context, rawInput) {
97
103
  const metadata = JSON.parse(metadataJson);
98
104
  if (input.processGroupId)
99
105
  metadata.process_group_id = input.processGroupId;
106
+ metadata.process_birth = observerProcessBirth;
100
107
  const now = context.now();
101
108
  db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
102
109
  result = requireProcess(db, result.id);
@@ -137,9 +144,11 @@ export function confirmManagedProcess(context, input) {
137
144
  const metadata = parseMetadata(current.metadata_json);
138
145
  if (input.processGroupId)
139
146
  metadata.process_group_id = input.processGroupId;
147
+ metadata.process_birth = stableProcessBirth(input.pid);
140
148
  if (input.ownerPid) {
141
149
  metadata.owner_pid = input.ownerPid;
142
150
  metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
151
+ metadata.owner_birth = stableProcessBirth(input.ownerPid);
143
152
  }
144
153
  const result = db.run(`UPDATE managed_processes
145
154
  SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
@@ -216,6 +225,11 @@ export function processIsAlive(record) {
216
225
  catch (error) {
217
226
  return error.code !== "ESRCH";
218
227
  }
228
+ const metadata = record.metadata_json ? parseMetadata(record.metadata_json) : {};
229
+ if (Object.hasOwn(metadata, "process_birth")) {
230
+ const birth = parseBirth(metadata.process_birth), observed = stableProcessBirth(record.pid);
231
+ return !birth || !observed || observed === birth;
232
+ }
219
233
  const started = processStartedAt(record.pid);
220
234
  return !record.pid_started_at || !started || record.pid_started_at === started;
221
235
  }
@@ -223,6 +237,17 @@ export function processTreeIsAlive(record) {
223
237
  if (processIsAlive(record))
224
238
  return true;
225
239
  const group = parseMetadata(record.metadata_json).process_group_id;
240
+ const birth = parseBirth(parseMetadata(record.metadata_json).process_birth);
241
+ // POSIX cannot reuse a leader PID while its old process group survives.
242
+ if (process.platform !== "win32" && group === record.pid && record.pid && birth) {
243
+ try {
244
+ process.kill(record.pid, 0);
245
+ }
246
+ catch { /* A missing leader can still have living children. */ }
247
+ const observed = stableProcessBirth(record.pid);
248
+ if (observed && observed !== birth)
249
+ return false;
250
+ }
226
251
  if (process.platform !== "win32" && typeof group === "number") {
227
252
  try {
228
253
  process.kill(-group, 0);
@@ -238,7 +263,7 @@ export function managedOwnerIsAlive(record) {
238
263
  const metadata = parseMetadata(record.metadata_json);
239
264
  if (typeof metadata.owner_pid !== "number")
240
265
  return null;
241
- return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
266
+ return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null, metadata_json: JSON.stringify({ process_birth: metadata.owner_birth }) });
242
267
  }
243
268
  /**
244
269
  * Expiry proves that observation stopped, not that the owner died. A live PID
@@ -413,16 +438,27 @@ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
413
438
  return new Date(Date.parse(now) + leaseMs).toISOString();
414
439
  }
415
440
  export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
441
+ function stableProcessBirth(pid) {
442
+ const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" } });
443
+ const value = result.status === 0 ? result.stdout.trim() : "";
444
+ return value ? `ps-lstart-utc-c@1:${value}` : null;
445
+ }
446
+ function parseBirth(value) { return typeof value === "string" && value.startsWith("ps-lstart-utc-c@1:") && value.length > "ps-lstart-utc-c@1:".length ? value : null; }
416
447
  function releasePortClaims(db, claims) { for (const claim of claims)
417
448
  db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
418
449
  function terminateOwnedProcess(record, signal) {
419
450
  // Never signal a group merely because its former leader PID was once ours.
420
451
  // PID reuse or a departed leader makes group ownership unprovable.
421
- if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
452
+ if (!record.pid || !record.pid_started_at)
422
453
  return;
423
- const group = parseMetadata(record.metadata_json).process_group_id;
454
+ const metadata = parseMetadata(record.metadata_json);
455
+ const birth = parseBirth(metadata.process_birth);
456
+ if (Object.hasOwn(metadata, "process_birth") ? !birth || stableProcessBirth(record.pid) !== birth : processStartedAt(record.pid) !== record.pid_started_at)
457
+ return;
458
+ const group = metadata.process_group_id;
459
+ // A non-leader PID does not establish ownership of its whole process group.
424
460
  try {
425
- process.kill(process.platform === "win32" || typeof group !== "number" ? record.pid : -group, signal);
461
+ process.kill(process.platform === "win32" || group !== record.pid ? record.pid : -group, signal);
426
462
  }
427
463
  catch { /* inspect again below */ }
428
464
  }
@@ -11,7 +11,7 @@ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
11
11
  import { runtimeHarness } from "./execution-policy.js";
12
12
  import { harnessKey } from "./harness-config.js";
13
13
  import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
14
- import { reconcileUnfinishedChecks } from "./code-checks.js";
14
+ import { reconcileUnfinishedChecks, terminalCheckSettled } from "./code-checks.js";
15
15
  import { controlStatusReceipt } from "./run-control-receipt.js";
16
16
  import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
17
17
  /** Durable projection only: status never probes a provider or acquires an owner. */
@@ -545,6 +545,12 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
545
545
  return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_lease_unsettled" };
546
546
  if (!record.pid || !record.pid_started_at)
547
547
  return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_identity_unknown" };
548
+ // Code checks mark their process terminal only after proving its tree is
549
+ // empty, then publish the exact check receipt. A later PGID reuse cannot
550
+ // turn that already-settled check back into an owned live process.
551
+ const check = record.kind === "check" && record.project_id === project.id && record.run_id === input.runId ? terminalCheckSettled(context, record) : null;
552
+ if (check)
553
+ return { process_id: record.id, kind: record.kind, status: "completed", receipt: { source: "terminal_check_tree_settled", ...managedProcessBinding(record, home), check_id: record.check_id, finished_at: check.finished_at } };
548
554
  if (!interrupt && processTreeIsAlive(record))
549
555
  return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_running" };
550
556
  if (boundary)
@@ -85,6 +85,7 @@ export function startFlowRun(context, input, prepared = prepareFlowRunStart(cont
85
85
  run_id: runId,
86
86
  short_id: shortId,
87
87
  flow_kind: flowKind,
88
+ ...(["vnext_specify", "vnext_protocolize"].includes(flowKind) ? { verification_matrix_contract: "dd-flow/verification-matrix@1" } : {}),
88
89
  subject: {
89
90
  id: subjectId,
90
91
  title: subjectId
@@ -554,63 +555,75 @@ export function listFlowRuns(context, input) {
554
555
  .map(flowRunSummary);
555
556
  return { ok: true, project, runs };
556
557
  }
557
- export function prepareFlowRunStageAttachment(context, input) {
558
+ export function prepareFlowRunStageAttachment(context, input, archive = false) {
558
559
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
559
560
  const run = resolveRun(context, project.id, input.runId);
560
- assertRunMutationAllowed(context, project.id, run.id);
561
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
561
562
  const stage = requiredPlain(input.stage, "stage");
562
563
  const dir = prepareStageDirectory(input.dir);
563
564
  const index = authoritativeIndex(run);
564
565
  const status = parseStageStatus(input.status);
565
566
  const existing = index.stage_runs.find((item) => item.stage === stage);
566
567
  assertVnextStageStart(run, index, stage, status, existing);
567
- return { project, run, index, stage, dir, status, existing };
568
- }
569
- export function attachFlowRunStage(context, input) {
570
- // Mutable stage state is re-read immediately before archiving/persisting.
571
- const { project, run, index, stage, dir, status, existing } = prepareFlowRunStageAttachment(context, input);
572
- const runRoot = runArtifactRoot(run);
573
- if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)))
574
- archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
575
- const now = context.now();
576
- if (status === "running" && existing) {
577
- archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
578
- }
579
- const stageRun = {
580
- ...(existing ?? { order: index.stage_runs.length + 1 }),
581
- stage,
582
- dir,
583
- status,
584
- ...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
585
- ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
586
- updated_at: now
587
- };
588
- if (status === "running") {
589
- stageRun.started_at = now;
590
- delete stageRun.completed_at;
591
- stageRun.duration_ms = null;
592
- stageRun.git = gitFacts(run.workspace_root);
593
- stageRun.attempt = nextAttempt(existing);
568
+ if (archive) {
569
+ const runRoot = runArtifactRoot(run);
570
+ if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)) || status === "running" && existing) {
571
+ archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
572
+ }
594
573
  }
595
- upsertStage(index, stageRun);
596
- index.current_stage = stage;
597
- index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
598
- index.updated_at = now;
599
- persistRunState(context, project, run, index);
600
- appendAudit(context, {
601
- projectId: project.id,
602
- eventType: "flow_run.stage_attached",
603
- payload: { run_id: run.id, stage, dir, status }
604
- });
605
- appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
606
- context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
607
- // A stage transition must not rewrite every Session in the RUN. A RUN may
608
- // contain independent reviewer/worker sessions; their stage is set by the
609
- // stage/work binding that actually owns that Session.
610
- if (status === "running")
611
- refreshRunSessionProjection(context, project.id, run.id);
574
+ return { project, run, index, stage, dir, status, existing, generation, attempt: status === "running" ? nextAttempt(existing) : existing?.attempt ?? "try-001", git: status === "running" ? gitFacts(run.workspace_root) : undefined };
575
+ }
576
+ export function attachFlowRunStage(context, input, prepared = prepareFlowRunStageAttachment(context, input, true)) {
577
+ // Filesystem archive and Git inspection are preparation, never writer work.
578
+ const { project, run, stage, dir, status, generation, git } = prepared;
579
+ const attached = context.db.writeTransaction(() => {
580
+ assertRunMutationAllowed(context, project.id, run.id, generation);
581
+ const latest = requireRunById(context, project.id, run.id);
582
+ const index = authoritativeIndex(latest);
583
+ const existing = index.stage_runs.find((item) => item.stage === stage);
584
+ if ((index.runtime_revision ?? 0) !== (prepared.index.runtime_revision ?? 0) || existing?.attempt !== prepared.existing?.attempt) {
585
+ throw new AppError("stage_revision_changed", "Stage changed during start preparation", 1, { run_id: run.id, stage });
586
+ }
587
+ assertVnextStageStart(latest, index, stage, status, existing);
588
+ const now = context.now();
589
+ const stageRun = {
590
+ ...(existing ?? { order: index.stage_runs.length + 1 }),
591
+ stage,
592
+ dir,
593
+ status,
594
+ ...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
595
+ ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
596
+ updated_at: now
597
+ };
598
+ if (status === "running") {
599
+ stageRun.started_at = now;
600
+ delete stageRun.completed_at;
601
+ stageRun.duration_ms = null;
602
+ if (git)
603
+ stageRun.git = git;
604
+ stageRun.attempt = prepared.attempt;
605
+ }
606
+ upsertStage(index, stageRun);
607
+ index.current_stage = stage;
608
+ index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
609
+ index.updated_at = now;
610
+ persistRunState(context, project, run, index);
611
+ appendAudit(context, {
612
+ projectId: project.id,
613
+ eventType: "flow_run.stage_attached",
614
+ payload: { run_id: run.id, stage, dir, status }
615
+ });
616
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
617
+ context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
618
+ // A stage transition must not rewrite every Session in the RUN. A RUN may
619
+ // contain independent reviewer/worker sessions; their stage is set by the
620
+ // stage/work binding that actually owns that Session.
621
+ if (status === "running")
622
+ context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
623
+ return { stageRun, index };
624
+ }, "stage.attach");
612
625
  const updatedRun = requireRunById(context, project.id, run.id);
613
- return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
626
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: attached.stageRun, index: attached.index, flow_guidance: guidanceForRun(context, updatedRun, attached.index) };
614
627
  }
615
628
  /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
616
629
  export function withStageSettlement(context, operation) {
@@ -1,9 +1,11 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { createContext } from "../runtime/context.js";
4
5
  import { AppError } from "../shared/errors.js";
5
6
  import { runHarnessAdapter } from "./harness-adapter.js";
6
- import { managedLeaseReleased, managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
7
+ import { managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
8
+ import { terminalCheckSettled } from "./code-checks.js";
7
9
  import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
8
10
  import { retainedDaemonOperations } from "./run-control.js";
9
11
  import { historicalDaemonSettlement } from "./native-daemon-history.js";
@@ -80,10 +82,29 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
80
82
  const metadata = JSON.parse(record.metadata_json);
81
83
  // RUN control owns active controllers/checks. Adopt only its already-finished
82
84
  // physical registrations here; death alone is not a completed RUN operation.
85
+ const terminalCheck = record.kind === "check" && metadata.role === "check" && record.check_id && record.project_id && record.run_id
86
+ ? (() => {
87
+ const home = managedProcessRuntimeHome(record, context);
88
+ if (!home)
89
+ return null;
90
+ let reader;
91
+ try {
92
+ reader = createContext({ ...context.env, DD_FLOW_HOME: home }, "read_existing");
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ try {
98
+ return terminalCheckSettled(reader, record);
99
+ }
100
+ finally {
101
+ reader.db.close?.();
102
+ }
103
+ })() : null;
83
104
  if ((record.kind === "run-controller" && metadata.role === "controller" && record.operation_id
84
105
  || record.kind === "check" && metadata.role === "check" && record.check_id)
85
106
  && record.project_id && record.run_id && ["stopped", "failed"].includes(record.state)
86
- && record.pid && record.pid_started_at && !processTreeIsAlive(record)) {
107
+ && record.pid && record.pid_started_at && (terminalCheck || !processTreeIsAlive(record))) {
87
108
  const operationId = record.operation_id ?? record.check_id;
88
109
  nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
89
110
  continue;
@@ -39,8 +39,10 @@ export function acceptStagePacket(context, input) {
39
39
  context.db.afterCommit(() => publishStagePacket(context, input.projectId, input.runId, input.stage, input.prepared.file));
40
40
  }
41
41
  export function publishStagePacket(context, projectId, runId, stage, file) {
42
+ assertPublicationPath(context, projectId, runId, file);
42
43
  fs.mkdirSync(path.dirname(file), { recursive: true });
43
44
  withProjectionLock(`${file}.publish-lock`, () => {
45
+ assertPublicationPath(context, projectId, runId, file);
44
46
  const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
45
47
  const index = run ? JSON.parse(run.index_json) : null;
46
48
  const currentAttempt = index?.stage_runs?.find((item) => item.stage === stage)?.attempt;
@@ -76,7 +78,9 @@ export function acceptStageReport(context, input) {
76
78
  }
77
79
  /** Replays the latest committed receipt, never an obsolete attempt. */
78
80
  export function publishStageReport(context, projectId, runId, stage, root) {
81
+ assertPublicationPath(context, projectId, runId, root);
79
82
  withProjectionLock(`${root}.report-publish-lock`, () => {
83
+ assertPublicationPath(context, projectId, runId, root);
80
84
  const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
81
85
  const index = run ? JSON.parse(run.index_json) : null;
82
86
  const currentAttempt = index?.stage_runs?.find((item) => item.stage === stage)?.attempt;
@@ -87,6 +91,7 @@ export function publishStageReport(context, projectId, runId, stage, root) {
87
91
  fs.mkdirSync(root, { recursive: true });
88
92
  for (const [name, bytes] of Object.entries(receipt.bytes)) {
89
93
  const file = path.join(root, `stage-report.${name === "json" ? "json" : name === "markdown" ? "md" : "html"}`);
94
+ assertPublicationPath(context, projectId, runId, file);
90
95
  try {
91
96
  fs.writeFileSync(file, bytes, { flag: "wx" });
92
97
  }
@@ -99,6 +104,20 @@ export function publishStageReport(context, projectId, runId, stage, root) {
99
104
  }
100
105
  });
101
106
  }
107
+ /** Recheck the actual destination after preparation: a missing leaf may have
108
+ * an existing symlink ancestor, and retained packet bytes never authorize it. */
109
+ function assertPublicationPath(context, projectId, runId, file) {
110
+ const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
111
+ const root = run?.run_root && path.resolve(run.run_root), destination = path.resolve(file);
112
+ if (!root || !destination.startsWith(`${root}${path.sep}`))
113
+ throw new AppError("publication_pending", "Stage publication escaped its RUN", 1, { effect: "committed", run_id: runId, file });
114
+ let ancestor = destination;
115
+ while (!fs.existsSync(ancestor) && ancestor !== root)
116
+ ancestor = path.dirname(ancestor);
117
+ const actual = fs.realpathSync(ancestor), realRoot = fs.realpathSync(root);
118
+ if (actual !== realRoot && !actual.startsWith(`${realRoot}${path.sep}`))
119
+ throw new AppError("publication_pending", "Stage publication escaped its RUN through a symlink", 1, { effect: "committed", run_id: runId, file });
120
+ }
102
121
  /** Admission repair for predecessor reports; the stage being retried is archived separately. */
103
122
  export function reconcileRunStageReports(context, projectId, runId, exceptStage) {
104
123
  const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_report' ORDER BY id DESC", [projectId]);