@deksden-com/dd-flow-cli 0.9.0-beta.0 → 0.9.0-beta.7

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.
@@ -3,7 +3,12 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
6
7
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
8
+ // Receipt reservation precedes child spawn so an interrupted CLI leaves an
9
+ // auditable attempt. A second caller must not mistake that small handoff window
10
+ // for a dead check and overwrite its outcome.
11
+ const receiptStartingGraceMs = 30_000;
7
12
  export function codeExecutionEnvironment(workspaceRoot) { const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12); return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix }; }
8
13
  export function readCodeCheckProfile(workspaceRoot) {
9
14
  const file = path.join(workspaceRoot, profileRelativePath);
@@ -11,14 +16,17 @@ export function readCodeCheckProfile(workspaceRoot) {
11
16
  return { profile: null, hash: null, file };
12
17
  const bytes = fs.readFileSync(file);
13
18
  const value = JSON.parse(bytes.toString("utf8"));
14
- if (value.schema_id !== "dd-flow/code-check-profile@5")
15
- throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@5" });
16
- if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate))
17
- throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for and mandatory_by_gate are invalid", 2, { file });
19
+ if (value.schema_id !== "dd-flow/code-check-profile@6")
20
+ throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@6" });
21
+ if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias))
22
+ throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate or ports_by_alias are invalid", 2, { file });
18
23
  for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
19
24
  for (const alias of aliases ?? [])
20
25
  if (!value.aliases[alias])
21
26
  throw new AppError("invalid_code_check_profile", "A mandatory gate references an unknown alias", 2, { file, gate, alias });
27
+ for (const alias of Object.keys(value.ports_by_alias ?? {}))
28
+ if (!value.aliases[alias])
29
+ throw new AppError("invalid_code_check_profile", "A port declaration references an unknown alias", 2, { file, alias });
22
30
  return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
23
31
  }
24
32
  export function validateCodeCheckCommands(workspaceRoot, commands) {
@@ -33,14 +41,20 @@ export function validateCodeCheckCommands(workspaceRoot, commands) {
33
41
  if (command === prefix || command.startsWith(`${prefix} `))
34
42
  throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file }); return command; });
35
43
  }
36
- export function resolveCodeCheckCommands(workspaceRoot, runId, commands) { const validated = validateCodeCheckCommands(workspaceRoot, commands); const { profile } = readCodeCheckProfile(workspaceRoot); return validated.map((command) => command.startsWith("@check/") ? profile.aliases[command].replaceAll("{run_id}", runId) : command); }
44
+ export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
45
+ const validated = validateCodeCheckCommands(workspaceRoot, commands);
46
+ const { profile } = readCodeCheckProfile(workspaceRoot);
47
+ // `{run_id}` is a declaration-level placeholder, not an alias feature.
48
+ // Plans may use it in a focused raw command as well as in project aliases.
49
+ return validated.map((command) => (command.startsWith("@check/") ? profile.aliases[command] : command).replaceAll("{run_id}", runId));
50
+ }
37
51
  export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
38
52
  const { profile } = readCodeCheckProfile(workspaceRoot);
39
- return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available" }; });
53
+ return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available", ...(profile?.ports_by_alias?.[alias] ? { ports: profile.ports_by_alias[alias] } : {}) }; });
40
54
  }
41
55
  export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
42
56
  export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
43
- export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["code", "readiness"]); }
57
+ export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["work", "code", "readiness"]); }
44
58
  export function checksForRunAt(checks, runAt) { return checks.filter((check) => check.run_at === runAt); }
45
59
  export function validateCheckPlacement(workspaceRoot, checks) { const { profile } = readCodeCheckProfile(workspaceRoot); const aggregateCommands = new Set(projectPolicyCheckDeclarations(workspaceRoot, "code").flatMap((check) => [check.command, profile?.aliases[check.command] ?? check.command])); for (const check of checks)
46
60
  if (check.run_at === "work" && aggregateCommands.has(check.command))
@@ -64,64 +78,157 @@ export function validateCheckDeclaration(workspaceRoot, check) {
64
78
  throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
65
79
  }
66
80
  export async function runCodeChecks(context, input) {
81
+ reconcileUnfinishedChecks(context, input);
67
82
  for (const check of input.checks)
68
83
  validateCheckDeclaration(input.workspaceRoot, check);
69
- const { hash: profileHash } = readCodeCheckProfile(input.workspaceRoot);
70
- const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, input.checks.map((check) => check.command));
71
- const groups = deduplicate(input.checks, resolved);
84
+ const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
85
+ const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; return check.ports || !ports ? check : { ...check, ports }; });
86
+ const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
87
+ const groups = deduplicate(checks, resolved);
72
88
  const receipts = [];
73
89
  for (let index = 0; index < groups.length; index += 1) {
74
90
  const group = groups[index];
75
- const ordinal = (context.db.get("SELECT COUNT(*) AS count FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId])?.count ?? 0) + 1;
76
- const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
77
- const id = `${input.workId ?? input.runId}/${localId}`;
78
- const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
79
- const evidenceDir = path.join(directory, "artifacts");
80
- fs.mkdirSync(evidenceDir, { recursive: true });
81
- const stdoutPath = path.join(directory, "stdout.log");
82
- const stderrPath = path.join(directory, "stderr.log");
83
- const receiptPath = path.join(directory, "receipt.json");
84
- const before = workspaceState(input.workspaceRoot);
85
- const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts })).digest("hex");
86
- const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
91
+ const before = workspaceState(input.workspaceRoot, [input.runHome]);
87
92
  const startedAt = context.now();
88
- input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
89
- const stdout = fs.createWriteStream(stdoutPath);
90
- const stderr = fs.createWriteStream(stderrPath);
91
- const result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`));
92
- await Promise.all([closeStream(stdout), closeStream(stderr)]);
93
- const after = workspaceState(input.workspaceRoot);
93
+ const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts, ports: [...group.ports].sort() })).digest("hex");
94
+ const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
95
+ const reserved = reserveReceipt(context, { ...input, group, before, profileHash, inputHash, epoch, startedAt });
96
+ const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
97
+ let resources = {};
98
+ let result = { exitCode: null, error: "check did not start", aborted: true };
99
+ try {
100
+ fs.mkdirSync(evidenceDir, { recursive: true });
101
+ writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
102
+ const managed = registerManagedProcess(context, { kind: "check", ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
103
+ const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
104
+ resources = allocation.ports;
105
+ input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
106
+ const stdout = fs.openSync(stdoutPath, "a");
107
+ const stderr = fs.openSync(stderrPath, "a");
108
+ try {
109
+ result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
110
+ }
111
+ finally {
112
+ allocation.release();
113
+ fs.closeSync(stdout);
114
+ fs.closeSync(stderr);
115
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
116
+ }
117
+ }
118
+ catch (error) {
119
+ result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
120
+ }
121
+ const after = workspaceState(input.workspaceRoot, [input.runHome]);
94
122
  const mutationPaths = changedPaths(before.files, after.files);
95
123
  const artifacts = collectRequiredArtifacts(evidenceDir, group.requiredArtifacts);
96
- if (!artifacts.complete)
97
- fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
98
- if (result.error)
99
- fs.appendFileSync(stderrPath, `\n${result.error}\n`);
100
- if (mutationPaths.length)
101
- fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
124
+ if (fs.existsSync(stderrPath)) {
125
+ if (!artifacts.complete)
126
+ fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
127
+ if (result.error)
128
+ fs.appendFileSync(stderrPath, `\n${result.error}\n`);
129
+ if (mutationPaths.length)
130
+ fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
131
+ }
102
132
  const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 ? "passed" : "failed";
103
- const receipt = { id, local_id: localId, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), gate: group.gate, scope: input.scope, work_id: input.workId ?? null, command: group.command, input_hash: inputHash, verification_epoch: epoch, status, exit_code: result.exitCode, stdout_path: stdoutPath, stderr_path: stderrPath, receipt_path: receiptPath, workspace_fingerprint: before.fingerprint, before_fingerprint: before.fingerprint, after_fingerprint: after.fingerprint, profile_hash: profileHash, mutation_paths: mutationPaths, artifacts: artifacts.items, started_at: startedAt, finished_at: context.now() };
104
- fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
105
- context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, receipt.declaration_id, JSON.stringify(receipt.check_refs), receipt.gate, receipt.command, receipt.input_hash, receipt.verification_epoch, receipt.status, receipt.exit_code, stdoutPath, stderrPath, receiptPath, receipt.workspace_fingerprint, receipt.before_fingerprint, receipt.after_fingerprint, receipt.profile_hash, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.started_at, receipt.finished_at]);
133
+ const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
134
+ context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [receipt.status, receipt.exit_code, receipt.after_fingerprint, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.finished_at, id, input.projectId]);
135
+ writeReceipt(receiptPath, receipt);
106
136
  receipts.push(receipt);
107
137
  input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
108
138
  }
109
139
  return receipts;
110
140
  }
141
+ /**
142
+ * A check receipt is durable even when its invoking CLI disappears. We never
143
+ * guess that an orphan passed: a live process blocks a second execution, and
144
+ * a dead/terminal process becomes an explicit aborted receipt that can retry.
145
+ */
146
+ export function reconcileUnfinishedChecks(context, input) {
147
+ const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
148
+ const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
149
+ const pending = context.db.all(`SELECT id, receipt_path, started_at FROM check_receipts WHERE ${where}`, params);
150
+ if (pending.length === 0)
151
+ return [];
152
+ const processes = managedProcessStatus(context);
153
+ const reconciled = [];
154
+ for (const pendingReceipt of pending) {
155
+ const process = processes.filter((item) => item.check_id === pendingReceipt.id).at(-1);
156
+ const receipt = readReceipt(pendingReceipt.receipt_path);
157
+ const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
158
+ if (process && ["starting", "running", "stopping"].includes(process.state) && processLeaseIsCurrent(process) && (process.state === "starting" || processIsAlive(process))) {
159
+ throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
160
+ }
161
+ if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
162
+ throw new AppError("check_in_progress", "A previous check invocation is still initializing", 1, { check_receipt_id: pendingReceipt.id, initializing: true });
163
+ }
164
+ const after = workspaceState(input.workspaceRoot, input.runHome ? [input.runHome] : []);
165
+ const artifacts = receipt ? collectRequiredArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
166
+ const workspaceUnchanged = receipt?.before_fingerprint === after.fingerprint;
167
+ const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete ? "passed" : "failed";
168
+ const exitCode = completion?.exit_code ?? null;
169
+ const finishedAt = completion?.finished_at ?? context.now();
170
+ context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode, after.fingerprint, JSON.stringify(workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"]), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
171
+ if (process)
172
+ finishManagedProcess(context, { id: process.id, leaseToken: process.lease_token, state: status === "passed" ? "stopped" : "failed", reason: status === "passed" ? "reconciled_completion" : completion === null ? "completion_marker_missing" : "reconciled_check_failed" });
173
+ if (receipt) {
174
+ const repaired = { ...receipt, completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], status, exit_code: exitCode, after_fingerprint: after.fingerprint, mutation_paths: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], artifacts: artifacts.items, finished_at: finishedAt };
175
+ writeReceipt(pendingReceipt.receipt_path, repaired);
176
+ reconciled.push(repaired);
177
+ }
178
+ }
179
+ return reconciled;
180
+ }
181
+ function reserveReceipt(context, input) {
182
+ context.db.exec("BEGIN IMMEDIATE");
183
+ try {
184
+ const rows = context.db.all("SELECT id FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId]);
185
+ const ordinal = rows.reduce((max, row) => Math.max(max, Number(/\/RCP-(\d+)$/.exec(row.id)?.[1] ?? 0)), 0) + 1;
186
+ const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
187
+ const id = `${input.workId ?? input.runId}/${localId}`;
188
+ const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
189
+ const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
190
+ context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef)), input.group.gate, input.group.command, input.inputHash, input.epoch, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
191
+ context.db.exec("COMMIT");
192
+ return reserved;
193
+ }
194
+ catch (error) {
195
+ context.db.exec("ROLLBACK");
196
+ throw error;
197
+ }
198
+ }
199
+ function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, status: input.status, exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
200
+ function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
201
+ function readReceipt(file) { try {
202
+ return JSON.parse(fs.readFileSync(file, "utf8"));
203
+ }
204
+ catch {
205
+ return null;
206
+ } }
207
+ function readCompletion(file) { try {
208
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
209
+ const done = value;
210
+ return value && typeof value === "object" && Number.isInteger(done.exit_code) ? { exit_code: Number(done.exit_code), finished_at: typeof done.finished_at === "string" && Number.isFinite(Date.parse(done.finished_at)) ? done.finished_at : null } : null;
211
+ }
212
+ catch {
213
+ return null;
214
+ } }
215
+ function processLeaseIsCurrent(process) { return Date.parse(process.lease_expires_at ?? "") >= Date.now(); }
111
216
  export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
112
217
  for (const ref of receipt.check_refs)
113
218
  latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
114
219
  export function checkReceipts(context, input) {
115
220
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
116
221
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
117
- return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json) }));
222
+ return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json), resources: readReceiptResources(row.receipt_path) }));
118
223
  }
119
224
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
120
- function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts]); const current = groups.get(key); if (current)
225
+ export function workspaceChangedPaths(root) { const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (status.status !== 0)
226
+ return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
227
+ function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts, ports]); const current = groups.get(key); if (current)
121
228
  current.declarations.push(check);
122
229
  else
123
- groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts }); }); return [...groups.values()]; }
124
- function workspaceState(root) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
230
+ groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
231
+ function workspaceState(root, ignoredRoots = []) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const ignored = ignoredRoots.map((entry) => path.resolve(entry)); const listing = (git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root)).filter((relative) => { const file = path.resolve(root, relative); return !ignored.some((ignoredRoot) => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)); }); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
125
232
  const file = path.join(root, relative);
126
233
  const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
127
234
  const digest = crypto.createHash("sha256").update(content).digest("hex");
@@ -139,6 +246,15 @@ catch {
139
246
  } }
140
247
  function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
141
248
  function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
249
+ function isPortAliasRecord(value) { return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, ports]) => alias.startsWith("@check/") && Array.isArray(ports) && ports.length > 0 && new Set(ports).size === ports.length && ports.every((port) => typeof port === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(port)))); }
250
+ function portEnvironment(ports) { return Object.fromEntries(Object.entries(ports).map(([name, value]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(value)])); }
251
+ function readReceiptResources(file) { try {
252
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
253
+ return { ports: value.resources?.ports ?? {} };
254
+ }
255
+ catch {
256
+ return { ports: {} };
257
+ } }
142
258
  function collectRequiredArtifacts(root, required) { const missing = []; const items = []; for (const relative of required) {
143
259
  if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
144
260
  throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
@@ -151,11 +267,45 @@ function collectRequiredArtifacts(root, required) { const missing = []; const it
151
267
  } return { complete: missing.length === 0, missing, items }; }
152
268
  function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
153
269
  return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
154
- function runCheck(command, cwd, environment, stdout, stderr, heartbeat) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" }); child.stdout.pipe(stdout, { end: false }); child.stderr.pipe(stderr, { end: false }); const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000); let timedOut = false; let error = null; let escalation; const timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, 15 * 60 * 1000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); clearTimeout(timeout); if (escalation)
155
- clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
270
+ function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" }); if (!child.pid) {
271
+ resolve({ exitCode: null, error: "check process did not provide a PID", aborted: true });
272
+ return;
273
+ } try {
274
+ onSpawn(child.pid);
275
+ }
276
+ catch (error) {
277
+ terminateProcessGroup(child.pid, "SIGTERM");
278
+ resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
279
+ return;
280
+ } const inactivityMs = 15 * 60 * 1000; let timeout; const lastOutputSize = new Map(); const armInactivityTimeout = () => { if (timeout)
281
+ clearTimeout(timeout); timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, inactivityMs); }; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
282
+ error = "managed process lease lost";
283
+ timedOut = true;
284
+ terminateProcessGroup(child.pid, "SIGTERM");
285
+ return;
286
+ } for (const file of [stdout, stderr]) {
287
+ try {
288
+ const size = fs.fstatSync(file).size, previous = lastOutputSize.get(file) ?? 0;
289
+ if (size > previous) {
290
+ lastOutputSize.set(file, size);
291
+ armInactivityTimeout();
292
+ }
293
+ }
294
+ catch { /* terminal stream */ }
295
+ } heartbeat(elapsed); }, 15_000); let timedOut = false; let error = null; let escalation; armInactivityTimeout(); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (timeout)
296
+ clearTimeout(timeout); if (escalation)
297
+ clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds without process output" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
298
+ function checkShellScript() {
299
+ return [
300
+ 'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
301
+ '/bin/sh -lc "$DD_FLOW_CHECK_COMMAND"',
302
+ "code=$?",
303
+ 'if [ -n "$completion" ]; then temporary="$completion.tmp.$$"; printf \'{"exit_code":%s,"finished_at":"%s"}\\n\' "$code" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temporary" && mv "$temporary" "$completion"; fi',
304
+ 'exit "$code"'
305
+ ].join("\n");
306
+ }
156
307
  function terminateProcessGroup(pid, signal) { if (!pid)
157
308
  return; try {
158
309
  process.kill(process.platform === "win32" ? pid : -pid, signal);
159
310
  }
160
311
  catch { /* already exited */ } }
161
- function closeStream(stream) { return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve())); }
@@ -80,7 +80,10 @@ export function createEvalRunSnapshot(context, input) {
80
80
  fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
81
81
  recursive: true,
82
82
  verbatimSymlinks: true,
83
- filter: (source) => path.basename(source) !== "engines"
83
+ // Managed checkouts are restored from their Git bundle and workspace
84
+ // payload. Copying their .git indirection into runtime makes the snapshot
85
+ // dependent on the source machine's worktree metadata.
86
+ filter: (source) => !["engines", "checkouts"].includes(path.basename(source))
84
87
  });
85
88
  fs.cpSync(projectRoot, path.join(output, "project"), {
86
89
  recursive: true,
@@ -135,10 +138,10 @@ export function restoreEvalRunSnapshot(context, input) {
135
138
  clearProjectTree(projectRoot, false);
136
139
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
137
140
  replaceProjectTree(projectRoot, sourceProject);
138
- const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
139
141
  context.db.close?.();
140
142
  fs.rmSync(context.ddFlowHome, { recursive: true, force: true });
141
143
  fs.cpSync(sourceRuntime, context.ddFlowHome, { recursive: true, verbatimSymlinks: true });
144
+ const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
142
145
  // The dispatch context was intentionally closed before replacing db.sqlite.
143
146
  // A fresh process owns all subsequent normal commands.
144
147
  const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
@@ -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";
@@ -278,7 +279,7 @@ export function handleCodexHook(context, input) {
278
279
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
279
280
  // Resume legitimately receives an answer through stdin. It is matched by
280
281
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
281
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
282
+ if (lifecycle.analysis.kind === "compound") {
282
283
  return {
283
284
  ok: false,
284
285
  observed: false,
@@ -303,10 +304,11 @@ export function handleCodexHook(context, input) {
303
304
  if (!project)
304
305
  return { ok: true, observed: false, reason: "unrelated_cwd" };
305
306
  const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
307
+ const storageId = sessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", sessionId)) : null;
306
308
  const observedSession = flowPayload
307
- ? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
309
+ ? bindObservedFlowSession(context, project, { ...flowPayload, harness: "codex-desktop", provider_session_id: sessionId ?? flowPayload.provider_session_id ?? null }, storageId ?? undefined)
308
310
  : undefined;
309
- const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
311
+ const effectiveSessionId = observedSession?.session_id ?? storageId;
310
312
  const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
311
313
  const eventKey = hookEventKey(payload, eventName, toolName, command);
312
314
  const inserted = recordHookEvent(context, {
@@ -343,7 +345,7 @@ export function handleCodexHook(context, input) {
343
345
  observed: inserted,
344
346
  duplicate: !inserted,
345
347
  event_key: eventKey,
346
- session_id: effectiveSessionId,
348
+ session: sessionId ? nativeSessionIdentity("codex-desktop", sessionId) : null,
347
349
  protocol_id: protocolId,
348
350
  ...(sessionId && (flowPayload || stageStart || stageResume || workStart)
349
351
  ? {
@@ -377,7 +379,7 @@ export function handleZcodeEvent(context, input) {
377
379
  const lifecycle = lifecycleFacts(command);
378
380
  if (!lifecycle)
379
381
  return { ok: true, observed: false, reason: "event_not_participating" };
380
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
382
+ if (lifecycle.analysis.kind === "compound") {
381
383
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
382
384
  standalone_command: lifecycle.invocation.command
383
385
  });
@@ -403,8 +405,8 @@ export function handleZcodeEvent(context, input) {
403
405
  throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
404
406
  const childProviderSessionId = stringValue(runtime.childSessionId);
405
407
  const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
406
- const sessionId = `zcode-acp:${providerSessionId}`;
407
- const parentSessionId = childProviderSessionId ? `zcode-acp:${rootProviderSessionId}` : null;
408
+ const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
409
+ const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
408
410
  const agentId = stringValue(runtime.agentId);
409
411
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
410
412
  ...flowPayload,
@@ -463,9 +465,8 @@ export function handleZcodeEvent(context, input) {
463
465
  duplicate: !inserted,
464
466
  event_key: eventKey,
465
467
  harness: "zcode-acp",
466
- session_id: sessionId,
467
- provider_session_id: providerSessionId,
468
- parent_session_id: parentSessionId,
468
+ session: nativeSessionIdentity("zcode-acp", providerSessionId),
469
+ ...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
469
470
  daemon_id: daemonId ?? null
470
471
  };
471
472
  }
@@ -485,7 +486,7 @@ export function handleGrokEvent(context, input) {
485
486
  const lifecycle = lifecycleFacts(command);
486
487
  if (!lifecycle)
487
488
  return { ok: true, observed: false, reason: "event_not_participating" };
488
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
489
+ if (lifecycle.analysis.kind === "compound") {
489
490
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
490
491
  standalone_command: lifecycle.invocation.command
491
492
  });
@@ -509,8 +510,8 @@ export function handleGrokEvent(context, input) {
509
510
  if (!rootProviderSessionId || !providerSessionId)
510
511
  throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
511
512
  const isChild = providerSessionId !== rootProviderSessionId;
512
- const sessionId = `grok-acp:${providerSessionId}`;
513
- const parentSessionId = isChild ? `grok-acp:${stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId}` : null;
513
+ const sessionId = storageSessionId(nativeSessionIdentity("grok-acp", providerSessionId));
514
+ const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
514
515
  const daemonId = stringValue(ddGrok.daemonId);
515
516
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
516
517
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
@@ -532,8 +533,8 @@ export function handleGrokEvent(context, input) {
532
533
  projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
533
534
  cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
534
535
  });
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 };
536
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
537
+ ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
537
538
  return (flowPayload || stageStart || stageResume || workStart)
538
539
  ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
539
540
  : result;
@@ -565,8 +566,8 @@ export function handleOpenCodeEvent(context, input) {
565
566
  if (resolveProjectRoot(directory) !== expectedRoot) {
566
567
  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
568
  }
568
- const sessionId = `${harness}:${providerSessionId}`;
569
- const parentSessionId = nativeParentId ? `${harness}:${nativeParentId}` : null;
569
+ const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
570
+ const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
570
571
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
571
572
  const baseKey = `${eventId}:${phase}`;
572
573
  if (phase === "after") {
@@ -577,7 +578,7 @@ export function handleOpenCodeEvent(context, input) {
577
578
  eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
578
579
  eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
579
580
  });
580
- return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId };
581
+ return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
581
582
  }
582
583
  if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
583
584
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
@@ -585,7 +586,7 @@ export function handleOpenCodeEvent(context, input) {
585
586
  const lifecycle = lifecycleFacts(command);
586
587
  if (!lifecycle)
587
588
  return { ok: true, observed: false, reason: "event_not_participating" };
588
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
589
+ if (lifecycle.analysis.kind === "compound") {
589
590
  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
591
  }
591
592
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
@@ -614,7 +615,7 @@ export function handleOpenCodeEvent(context, input) {
614
615
  status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
615
616
  });
616
617
  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 };
618
+ 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
619
  return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
619
620
  }
620
621
  /** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
@@ -736,7 +737,7 @@ export function sessionIdForHookEvent(context, projectId, eventKey) {
736
737
  /** Reads immutable identity facts already captured by PreToolUse. */
737
738
  export function hookSessionIdentity(context, projectId, eventKey) {
738
739
  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
740
+ 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
741
  WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
741
742
  if (!event?.session_id)
742
743
  throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
@@ -747,7 +748,9 @@ export function hookSessionIdentity(context, projectId, eventKey) {
747
748
  parentSessionId: event.parent_session_id,
748
749
  daemonId: event.daemon_id,
749
750
  agentId: event.agent_id,
750
- sessionId: event.harness === "codex-desktop" ? event.agent_id ?? event.session_id : event.session_id,
751
+ // agent_id is an auxiliary provider fact, never a replacement Session ID.
752
+ sessionId: event.session_id,
753
+ nativeSessionId: event.provider_session_id ?? event.session_id,
751
754
  turnId: event.turn_id,
752
755
  transcriptPath: event.transcript_path,
753
756
  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
  }