@deksden-com/dd-flow-cli 0.9.0-beta.52 → 0.9.0-beta.54

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.
@@ -0,0 +1,371 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { quote } from "shell-quote";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
7
+ import { requireProjectByRoot } from "./projects.js";
8
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
9
+ import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
10
+ // Conclusive result/check rejections, never transport, ownership, registry or
11
+ // unknown-effect failures. These validators reject before accepting the result.
12
+ export function isRetryableLifecycleRejection(code, details = {}) {
13
+ return ["validation", "schema_validation", "work_checks_failed", "code_review_gate_failed",
14
+ "review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
15
+ "review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
16
+ "evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
17
+ "document_update_not_materialized", "review_repair_no_change"].includes(code)
18
+ || (code === "invalid_work_state" && details.retryable_no_effect === true);
19
+ }
20
+ function storage(context) {
21
+ context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
22
+ id TEXT PRIMARY KEY, scope_json TEXT NOT NULL, command TEXT NOT NULL,
23
+ fingerprint TEXT NOT NULL, status TEXT NOT NULL,
24
+ event_key TEXT UNIQUE, identity_json TEXT, deadline INTEGER, outcome_json TEXT,
25
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
26
+ )`);
27
+ }
28
+ function invocation(command) {
29
+ const parsed = parseLifecycleCommand(command);
30
+ if (parsed.kind !== "standalone" || parsed.invocation.wrapped) {
31
+ throw new AppError("invocation_command_invalid", "A lifecycle attempt requires one direct standalone command", 1);
32
+ }
33
+ return parsed.invocation;
34
+ }
35
+ // Input data may be supplied later (stdin/file contents); authority is bound to
36
+ // the exact operation and literal argv. Existing lifecycle validation owns data.
37
+ function fingerprint(parsed) {
38
+ const options = [...parsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key))
39
+ .map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values])
40
+ .sort(([a], [b]) => a.localeCompare(b));
41
+ return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional, options])).digest("hex");
42
+ }
43
+ function load(context, id) {
44
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
45
+ throw new AppError("invocation_unknown", "This runtime has no issued lifecycle attempts", 1, { invocation_id: id });
46
+ }
47
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
48
+ if (!row)
49
+ throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime", 1, { invocation_id: id });
50
+ return row;
51
+ }
52
+ export function lifecycleInvocationScope(context, id) {
53
+ return JSON.parse(load(context, id).scope_json);
54
+ }
55
+ /** Current, explicitly-issued replacements for commands from the resumed stage.
56
+ * Original prompts remain immutable evidence; callers may present this small
57
+ * overlay after a state transition made an earlier no-effect rejection retryable. */
58
+ export function lifecycleRetryCommands(context, stage) {
59
+ const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
60
+ if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
61
+ return [];
62
+ return context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
63
+ .flatMap((row) => {
64
+ try {
65
+ const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
66
+ const retry = outcome?.error?.details?.retry_command;
67
+ const parsed = invocation(row.command);
68
+ if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
69
+ return [];
70
+ const successor = load(context, commandOption(invocation(retry), "invocation-id") ?? "");
71
+ const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
72
+ return successor.id === latest?.id && successor.status === "issued" ? [retry] : [];
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ });
78
+ }
79
+ export function managedInvocationContext(context, input) {
80
+ const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
81
+ if (input.harness !== "zcode-acp")
82
+ return { ...context, env };
83
+ const project = requireProjectByRoot(context, input.projectRoot);
84
+ const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
85
+ if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
86
+ throw new AppError("invocation_generation_stale", "Managed daemon belongs to another RUN generation", 1);
87
+ const session = state.sessions?.find(session => session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
88
+ if (!state.daemon_id || !session?.root_provider_session_id)
89
+ throw new AppError("invocation_scope_unproven", "Managed ZCode Session has no persisted native root binding", 1);
90
+ const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId: session.root_provider_session_id, runId: input.runId, generation: input.generation };
91
+ env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
92
+ return { ...context, env };
93
+ }
94
+ /** Generated commands retain their attempt across repeated rendering. Only a
95
+ * currently executing command can emit its own successor with the same argv. */
96
+ export function managedLifecycleCommand(context, command) {
97
+ const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
98
+ if (!configured)
99
+ return command;
100
+ const scope = JSON.parse(configured);
101
+ const parsed = invocation(command);
102
+ if (commandOption(parsed, "invocation-id"))
103
+ return command;
104
+ if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
105
+ const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
106
+ ? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
107
+ if (!retained)
108
+ throw new AppError("invocation_command_unprepared", "Read-only inspection has no retained lifecycle command", 1);
109
+ return renderInvocationCommand(retained, command);
110
+ }
111
+ storage(context);
112
+ context.db.exec("SAVEPOINT invocation_command");
113
+ try {
114
+ const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
115
+ if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
116
+ const rendered = renderInvocationCommand(prior, command);
117
+ context.db.exec("RELEASE invocation_command");
118
+ return rendered;
119
+ }
120
+ if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
121
+ throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
122
+ const prepared = issueLifecycleInvocation(context, command, scope);
123
+ context.db.exec("RELEASE invocation_command");
124
+ return prepared.command;
125
+ }
126
+ catch (error) {
127
+ context.db.exec("ROLLBACK TO invocation_command");
128
+ context.db.exec("RELEASE invocation_command");
129
+ throw error;
130
+ }
131
+ }
132
+ export function retryLifecycleInvocationCommand(context, id) {
133
+ const prior = load(context, id);
134
+ if (prior.status !== "executing")
135
+ throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
136
+ const marker = ` --invocation-id ${id}`;
137
+ if (!prior.command.includes(marker))
138
+ throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
139
+ return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, prior.command.replace(marker, ""));
140
+ }
141
+ /** Settle a conclusive rejection and publish its only legal successor atomically.
142
+ * A crash must leave either the old attempt executing, or both the settled
143
+ * outcome and its successor; never a usable successor beside an unknown effect. */
144
+ export function settleLifecycleRejection(context, id, error) {
145
+ storage(context);
146
+ const details = { ...error.details };
147
+ context.db.exec("BEGIN IMMEDIATE");
148
+ try {
149
+ const prior = load(context, id);
150
+ if (prior.status !== "executing")
151
+ throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
152
+ if (isRetryableLifecycleRejection(error.code, error.details)) {
153
+ const retry = retryLifecycleInvocationCommand(context, id);
154
+ details.retry_command = retry;
155
+ details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
156
+ }
157
+ settleLifecycleInvocation(context, id, { error: { ...error, details } });
158
+ context.db.exec("COMMIT");
159
+ Object.assign(error.details, details);
160
+ }
161
+ catch (error) {
162
+ context.db.exec("ROLLBACK");
163
+ throw error;
164
+ }
165
+ }
166
+ /** Recovery preserves the original packet as evidence. Only its retained,
167
+ * never-executed commands receive new authority; unknown effects never replay. */
168
+ export function recoverLifecycleCommands(context, originalPrompt, scope) {
169
+ const ids = [...new Set([...originalPrompt.matchAll(/--invocation-id ([0-9a-f-]{36})\b/g)].map(match => match[1]))];
170
+ const recovered = [];
171
+ for (const id of ids) {
172
+ let prior = load(context, id);
173
+ const oldScope = JSON.parse(prior.scope_json);
174
+ if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
175
+ || oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
176
+ throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
177
+ }
178
+ // The immutable prompt can predate several recoveries. Follow the newest
179
+ // retained attempt, including an unknown successor, rather than reviving
180
+ // an older unexecuted command after the successor already produced effects.
181
+ prior = context.db.get(`SELECT * FROM lifecycle_invocations WHERE fingerprint = ?
182
+ AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') IS ?
183
+ AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ?
184
+ ORDER BY json_extract(scope_json, '$.generation') DESC, rowid DESC LIMIT 1`, [prior.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation]);
185
+ if (prior.status === "executing" || prior.status === "settled") {
186
+ const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
187
+ recovered.push({ invocation_id: id, status: prior.status, outcome,
188
+ instruction: prior.status === "executing" ? "Outcome unknown: reconcile retained operation evidence; do not reissue this command." : "Read the retained outcome; do not repeat completed effects." });
189
+ continue;
190
+ }
191
+ const parsed = invocation(prior.command);
192
+ const argv = [...parsed.argv];
193
+ const at = argv.indexOf("--invocation-id");
194
+ if (at < 0 || argv[at + 1] !== prior.id)
195
+ throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
196
+ argv.splice(at, 2);
197
+ const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
198
+ const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${quote([parsed.executable, ...argv])} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
199
+ const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
200
+ recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
201
+ }
202
+ return recovered;
203
+ }
204
+ export function assertLifecycleInvocationCurrent(context, scope, command) {
205
+ const parsed = invocation(command);
206
+ if (!scope.runId) {
207
+ if ((parsed.operation === "stage_start" && parsed.args.options.has("bootstrap")) || parsed.operation === "session_register")
208
+ return;
209
+ throw new AppError("invocation_scope_invalid", "This lifecycle operation requires an issued RUN scope", 1);
210
+ }
211
+ const project = requireProjectByRoot(context, scope.projectRoot);
212
+ let workId;
213
+ if (parsed.operation.startsWith("work_")) {
214
+ const target = parsed.args.positional[0] ?? "";
215
+ const works = context.db.all("SELECT work_id, run_id FROM works WHERE project_id = ? AND (work_id = ? OR (? = 1 AND work_id LIKE ?))", [project.id, target, /^WRK-\d{3,}$/.test(target) ? 1 : 0, `${target}-%`]);
216
+ if (works.length !== 1 || works[0].run_id !== scope.runId)
217
+ throw new AppError("invocation_scope_mismatch", "Work does not belong to the issued RUN scope", 1);
218
+ workId = works[0].work_id;
219
+ }
220
+ else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
221
+ const target = parsed.args.positional[0];
222
+ const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
223
+ if (run?.id !== scope.runId)
224
+ throw new AppError("invocation_scope_mismatch", "Command does not belong to the issued RUN scope", 1);
225
+ }
226
+ const guard = recoveryGuard(context, project.id, scope.runId);
227
+ // A cooperative pause advances the fence but explicitly admits settlement
228
+ // of the captured WorkSession/Stage attempt. Reuse that exact authority;
229
+ // never turn the invocation layer into a stricter, divergent pause policy.
230
+ if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
231
+ if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
232
+ return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
233
+ if (parsed.operation === "stage_finish")
234
+ return assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
235
+ }
236
+ if ((guard?.generation ?? 0) !== scope.generation)
237
+ throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
238
+ if (parsed.operation !== "recovery_accept")
239
+ assertRunMutationAllowed(context, project.id, scope.runId);
240
+ }
241
+ function checkCommand(row, command) {
242
+ const parsed = invocation(command);
243
+ const scope = JSON.parse(row.scope_json);
244
+ if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
245
+ || path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
246
+ throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
247
+ }
248
+ }
249
+ /** Explicit issuance; callers retain this command. Read-only show/status must
250
+ * return that retained value, not issue a replacement on every observation. */
251
+ export function issueLifecycleInvocation(context, command, scope) {
252
+ storage(context);
253
+ const parsed = invocation(command);
254
+ if (!scope.daemonId || !scope.rootSessionId || !Number.isInteger(scope.generation) || scope.generation < 0
255
+ || !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
256
+ || commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
257
+ throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
258
+ }
259
+ const id = crypto.randomUUID();
260
+ const prepared = commandWithInvocationId(command, id);
261
+ // Validate the actual inserted argv, including quoted heredoc boundaries.
262
+ const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
263
+ checkCommand(row, prepared);
264
+ context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
265
+ return { id, command: prepared };
266
+ }
267
+ function commandWithInvocationId(command, id) {
268
+ const at = shellSuffixOffset(command);
269
+ return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
270
+ }
271
+ /** The attempt owns authority; the caller owns presentation-only shell input.
272
+ * Preserve current stdin/heredoc/response-file text while validating that it
273
+ * still names the exact persisted operation and project. */
274
+ function renderInvocationCommand(row, command) {
275
+ const parsed = invocation(command);
276
+ if (commandOption(parsed, "invocation-id"))
277
+ throw new AppError("invocation_command_invalid", "Rendered lifecycle command already has an invocation id", 1);
278
+ const rendered = commandWithInvocationId(command, row.id);
279
+ checkCommand(row, rendered);
280
+ return rendered;
281
+ }
282
+ /** Native observer and receipt writer commit together. Never await I/O inside
283
+ * this transaction; the callback must write to this same SQLite connection. */
284
+ export function observeLifecycleInvocation(context, input) {
285
+ const initial = load(context, input.id);
286
+ checkCommand(initial, input.command);
287
+ // A completed invocation is a read, even from a new native tool call after
288
+ // reattach. Do not manufacture a new receipt or reject that read as a second
289
+ // executor; the CLI returns the immutable stored outcome without dispatch.
290
+ if (initial.status === "settled")
291
+ return { eventKey: initial.event_key, duplicate: true };
292
+ const scope = JSON.parse(initial.scope_json);
293
+ const identity = input.identity;
294
+ if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
295
+ || !identity.sessionId || !identity.toolCallId
296
+ || (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
297
+ throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: input.id });
298
+ }
299
+ const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
300
+ context.db.exec("BEGIN IMMEDIATE");
301
+ try {
302
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]);
303
+ if (row.status === "settled") {
304
+ context.db.exec("COMMIT");
305
+ return { eventKey: row.event_key, duplicate: true };
306
+ }
307
+ if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
308
+ throw new AppError("invocation_expired", "Late observation cannot revive an expired lifecycle attempt", 1, { invocation_id: input.id });
309
+ }
310
+ if (row.identity_json) {
311
+ if (row.identity_json !== identityJson)
312
+ throw new AppError("invocation_identity_conflict", "Another native tool call already owns this attempt", 1, { invocation_id: input.id });
313
+ context.db.exec("COMMIT");
314
+ return { eventKey: row.event_key, duplicate: true };
315
+ }
316
+ const eventKey = input.recordReceipt();
317
+ const receipt = eventKey && context.db.get("SELECT provider_session_id, daemon_id, status FROM hook_events WHERE event_key = ? AND harness = 'zcode-acp' AND cwd = ?", [eventKey, scope.projectRoot]);
318
+ if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
319
+ throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
320
+ }
321
+ context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status = 'issued'", [identityJson, eventKey, context.now(), input.id]);
322
+ context.db.exec("COMMIT");
323
+ return { eventKey, duplicate: false };
324
+ }
325
+ catch (error) {
326
+ context.db.exec("ROLLBACK");
327
+ throw error;
328
+ }
329
+ }
330
+ /** One caller owns execution; a lost process leaves an explicit unknown
331
+ * outcome, never an automatic replay. Existing recovery decides the next step. */
332
+ export async function awaitLifecycleInvocation(context, input) {
333
+ const initial = load(context, input.id);
334
+ checkCommand(initial, input.command);
335
+ const timeoutMs = input.timeoutMs ?? 30000;
336
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
337
+ throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
338
+ context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
339
+ for (;;) {
340
+ const row = load(context, input.id);
341
+ if (row.status === "settled")
342
+ return { outcome: JSON.parse(row.outcome_json), replay: true };
343
+ if (row.status === "executing")
344
+ throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id });
345
+ input.assertCurrent(JSON.parse(row.scope_json));
346
+ if (row.status === "observed") {
347
+ const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
348
+ if (claimed.changes === 1)
349
+ return { eventKey: row.event_key, replay: false };
350
+ continue;
351
+ }
352
+ if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
353
+ const expired = context.db.run("UPDATE lifecycle_invocations SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'issued'", [context.now(), input.id]);
354
+ if (!expired.changes && row.status !== "expired")
355
+ continue;
356
+ throw new AppError("invocation_receipt_timeout", "No native confirmation before the receipt deadline; lifecycle state was not changed", 1, { invocation_id: input.id });
357
+ }
358
+ await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
359
+ }
360
+ }
361
+ export function settleLifecycleInvocation(context, id, outcome) {
362
+ const encoded = JSON.stringify(outcome);
363
+ if (encoded === undefined)
364
+ throw new AppError("invocation_outcome_invalid", "Invocation outcome must be JSON", 1);
365
+ const changed = context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]);
366
+ if (changed.changes !== 1) {
367
+ const row = load(context, id);
368
+ if (row.status !== "settled" || row.outcome_json !== encoded)
369
+ throw new AppError("invocation_settlement_conflict", "Attempt cannot be settled with a different outcome", 1, { invocation_id: id });
370
+ }
371
+ }
@@ -1,3 +1,4 @@
1
+ import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -7,7 +8,7 @@ import { flowCommand } from "./stage-pause.js";
7
8
  import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
8
9
  import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
9
10
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
10
- import { loadAgentProfile, resolveExecutionPolicy } from "./execution-policy.js";
11
+ import { loadAgentProfile, resolveExecutionPolicy, runtimeHarness } from "./execution-policy.js";
11
12
  /** A deterministic dispatcher; only the launched harness Session performs agent work. */
12
13
  export async function serveMergeRequests(context, input) {
13
14
  const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
@@ -103,9 +104,7 @@ async function dispatch(context, input) {
103
104
  const promptFile = path.join(stateDir, "launch.md");
104
105
  const adapterJournal = path.join(stateDir, "adapter.events.jsonl");
105
106
  const flowExecutable = prepareHarnessFlowExecutable(context, stateDir);
106
- const runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
107
- const stageCommand = `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`;
108
- 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`);
107
+ let runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
109
108
  const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${input.request.run_id}:merge:${input.request.merge_request_id}`, role: "merge", project_id: input.request.project_id, run_id: input.request.run_id, work_id: input.request.executor_work_id, operation_id: `${input.request.merge_request_id}:${token}`, state_dir: stateDir, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, input.request.project_id, input.request.run_id) };
110
109
  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() });
111
110
  input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
@@ -126,6 +125,9 @@ async function dispatch(context, input) {
126
125
  throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
127
126
  Object.assign(retained, { session_id: sessionId, create: created });
128
127
  retain();
128
+ runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(input.profile), stateDir, sessionId, projectRoot: run.project_root, runId: input.request.run_id, generation });
129
+ const stageCommand = managedLifecycleCommand(runtimeContext, `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
130
+ 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`);
129
131
  const prompted = await adapterCall(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60 * 1000, `MERGE ${input.request.merge_request_id} Session ${sessionId} is still running`);
130
132
  Object.assign(retained, { response: prompted });
131
133
  retain();
@@ -26,6 +26,9 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
26
26
  throw new AppError("snapshot_scope_conflict", "Selected Work refers to a Session currently bound to another RUN", 1);
27
27
  const sessions = `SELECT session_id FROM sessions WHERE ${ownProject} AND (run_id IN (SELECT run_id FROM capture_scope) OR session_id IN (SELECT session_id FROM work_sessions WHERE work_id IN (${works})))`;
28
28
  const nativeSessions = `SELECT harness, provider_session_id FROM sessions WHERE ${ownProject} AND session_id IN (${sessions})`;
29
+ const invocations = "json_extract(scope_json, '$.runId') IN (SELECT run_id FROM capture_scope) AND json_extract(scope_json, '$.projectRoot') IN (SELECT root FROM projects WHERE id IN (SELECT project_id FROM capture_scope))";
30
+ const invocationReceipts = db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'lifecycle_invocations'").get()
31
+ ? ` OR event_key IN (SELECT event_key FROM lifecycle_invocations WHERE ${invocations})` : "";
29
32
  const protocols = `SELECT id FROM capture_protocols UNION SELECT subject_id FROM runs WHERE (project_id, id) IN (SELECT * FROM capture_scope) UNION SELECT value FROM merge_requests, json_each(merge_requests.protocol_ids_json) WHERE ${ownRun} UNION SELECT protocol_id FROM flow_jobs WHERE ${ownRun}`;
30
33
  const rules = {
31
34
  dd_flow_writer_contract: "1",
@@ -36,7 +39,8 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
36
39
  work_sessions: `work_id IN (${works})`, work_launches: `work_id IN (${works})`,
37
40
  work_recovery_segments: `work_id IN (${works})`,
38
41
  run_recovery_bindings: `recovery_id IN (SELECT recovery_id FROM run_recovery_guards WHERE ${ownRun})`,
39
- hook_events: `${ownProject} AND (session_id IN (${sessions}) OR (harness, provider_session_id) IN (${nativeSessions}))`,
42
+ lifecycle_invocations: invocations,
43
+ hook_events: `${ownProject} AND (session_id IN (${sessions}) OR (harness, provider_session_id) IN (${nativeSessions})${invocationReceipts})`,
40
44
  harness_usage_snapshots: `${ownProject} AND (harness, provider_session_id) IN (${nativeSessions})`,
41
45
  protocols: `${ownProject} AND id IN (${protocols})`,
42
46
  project_config: ownProject,
@@ -15,6 +15,7 @@ import { requireRun, requireController, controllerReceipt, appendEvent, saveStat
15
15
  import { controllerAdapter, sessionArguments } from "./run-controller-adapter.js";
16
16
  import { spawnController, recordControllerLauncher } from "./run-controller-process.js";
17
17
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
18
+ import { managedInvocationContext } from "./lifecycle-invocations.js";
18
19
  import { runControlStatus } from "./run-control.js";
19
20
  import { runtimeBudgetStatus, waitForRuntimeScopeRecovery } from "./runtime-budget.js";
20
21
  /** CLI observation is bounded; the physical controller owns the recovery work. */
@@ -123,7 +124,7 @@ export async function resumeControllerSession(context, row, manifest, state, ass
123
124
  const session = state.current_session === null ? undefined : state.sessions[state.current_session];
124
125
  if (!session?.id)
125
126
  throw new AppError("recovery_owner_missing", "Recovery lost its retained coordinator Session", 1);
126
- const executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
127
+ let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
127
128
  const scopeControl = manifest.budget ? runtimeBudgetStatus(executionContext, manifest.budget.scope_id).control : null;
128
129
  const assertRecoveryOwner = () => {
129
130
  assertPhysicalOwner();
@@ -156,6 +157,8 @@ export async function resumeControllerSession(context, row, manifest, state, ass
156
157
  const daemon = readDaemon(), daemonId = daemon.daemon_id ?? daemon.config?.daemonId;
157
158
  if (!daemonId || daemonId === (retired.daemon_id ?? retired.config?.daemonId) || started.daemon_id !== daemonId || started.already_running === true)
158
159
  throw new AppError("recovery_daemon_stale", "Recovery requires a fresh daemon, not reuse of the retired physical owner", 1);
160
+ executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
161
+ projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
159
162
  assertRecoveryOwner();
160
163
  const scope = { projectRoot: manifest.request.project_root, runId: row.run_id, recoveryId: recovery.recovery_id };
161
164
  resumeRunRecovery(executionContext, { ...scope, daemonId, harness: runtimeHarness(session.profile), nativeSessionId: session.id });
@@ -1,3 +1,4 @@
1
+ import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -345,7 +346,11 @@ export function supplyRunControllerContext(context, input) {
345
346
  async function executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner) {
346
347
  const policy = manifest.execution_profile.settings.execution;
347
348
  const profiles = manifest.execution_profile.agent_profiles;
348
- const executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined } };
349
+ let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined } };
350
+ const bindInvocationScope = (session) => {
351
+ executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
352
+ projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
353
+ };
349
354
  let lastCapture = null;
350
355
  const setStatus = (status) => {
351
356
  const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
@@ -394,6 +399,8 @@ async function executeController(context, row, manifest, state, assertOwner, ass
394
399
  };
395
400
  for (;;) {
396
401
  assertOwner();
402
+ if (state.current_session !== null && !state.sessions[state.current_session].stopped)
403
+ bindInvocationScope(state.sessions[state.current_session]);
397
404
  const run = requireRun(context, row.project_id, row.run_id);
398
405
  const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
399
406
  const next = view.continuation;
@@ -433,7 +440,7 @@ async function executeController(context, row, manifest, state, assertOwner, ass
433
440
  assertOwner();
434
441
  // The accepted answer is logical intent; only its generation-bound
435
442
  // adapter operation represents a physical dispatch that stop must drain.
436
- const command = `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`;
443
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`);
437
444
  const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
438
445
  context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
439
446
  state.last_continuation = null;
@@ -497,6 +504,7 @@ async function executeController(context, row, manifest, state, assertOwner, ass
497
504
  throw new AppError("harness_adapter_invalid", "Managed adapter did not return its Session id", 1);
498
505
  session.id = sessionId;
499
506
  saveState(context, row, state);
507
+ bindInvocationScope(session);
500
508
  if (next.stage === "merge" && state.merge_claim)
501
509
  context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify({ controller_id: row.controller_id, profile: session.profile, session_id: session.id, state_dir: session.state_dir, create: created }), context.now(), state.merge_claim.request_id, row.controller_id, state.merge_claim.token]);
502
510
  appendEvent(context, row.controller_id, "session_created", { stage: next.stage, session_id: sessionId, harness: runtimeHarness(session.profile), profile: session.profile });
@@ -507,7 +515,7 @@ async function executeController(context, row, manifest, state, assertOwner, ass
507
515
  saveState(context, row, state);
508
516
  setStatus("running");
509
517
  const responseFile = path.join(row.state_dir, `${next.stage}-${next.attempt ?? 1}.stage-start-response.json`);
510
- const command = `${flowCommand(executionContext)} stage start ${run.id} --stage ${next.stage} --project-root ${JSON.stringify(run.project_root)} --context-file ${JSON.stringify(stageContext.file)} --context-sha256 ${stageContext.sha256} --require-session-binding --response-file ${JSON.stringify(responseFile)} --json --progress-jsonl`;
518
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage start ${run.id} --stage ${next.stage} --project-root ${JSON.stringify(run.project_root)} --context-file ${JSON.stringify(stageContext.file)} --context-sha256 ${stageContext.sha256} --require-session-binding --response-file ${JSON.stringify(responseFile)} --json --progress-jsonl`);
511
519
  appendEvent(context, row.controller_id, "stage_entered", { stage: next.stage, session_id: session.id, context_sha256: stageContext.sha256 });
512
520
  await prompt(session, `Start the assigned ${next.stage} Stage now. Your first tool call must be this exact standalone command:\n\n${command}\n\nRead the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read before doing Stage work. Stop at this Stage boundary or a declared HITL/Work-graph boundary; do not start a successor Stage yourself.`);
513
521
  continue;
@@ -1,3 +1,4 @@
1
+ import { managedLifecycleCommand, recoverLifecycleCommands } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -42,7 +43,7 @@ function rootRecoveryPacket(context, run, guard, owner) {
42
43
  });
43
44
  }
44
45
  export function acceptanceCommand(context, runId, recoveryId, projectRoot) {
45
- return `${flowCommand(context)} run recovery accept ${JSON.stringify(runId)} --recovery-id ${JSON.stringify(recoveryId)} --project-root ${JSON.stringify(projectRoot)} --json`;
46
+ return managedLifecycleCommand(context, `${flowCommand(context)} run recovery accept ${JSON.stringify(runId)} --recovery-id ${JSON.stringify(recoveryId)} --project-root ${JSON.stringify(projectRoot)} --json`);
46
47
  }
47
48
  export function requireRecoveryRun(context, projectId, runId) {
48
49
  const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]);
@@ -258,6 +259,15 @@ export function acceptRunRecovery(context, input) {
258
259
  if (!guard || guard.recovery_id !== input.recoveryId || !binding || binding.session_id !== identity.sessionId || binding.harness !== identity.harness || binding.provider_session_id !== identity.providerSessionId || binding.daemon_id !== identity.daemonId || hook?.match_key !== matchKey)
259
260
  throw new AppError("recovery_binding_mismatch", "Recovery acknowledgement does not belong to the authorized physical Session and generation", 1);
260
261
  packetReceipt = recoveryPacketReceipt(binding);
262
+ if (binding.harness === "zcode-acp") {
263
+ const retained = packetReceipt.packet;
264
+ const scope = { projectRoot: run.project_root, daemonId: binding.daemon_id, rootSessionId: binding.provider_session_id, runId: run.id, generation: guard.generation };
265
+ packetReceipt.lifecycle_recovery = {
266
+ commands: retained.continuation === "stage_boundary" ? [] : recoverLifecycleCommands(context, retained.original_prompt.text, scope),
267
+ instruction: "original_prompt is immutable historical evidence. Do not execute its old invocation IDs or scope. Use only replacement commands below; reconcile unknown outcomes before continuing. At a completed stage boundary return control to the runner.",
268
+ work_list_command: `${flowCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) } })} work ls --run ${run.id} --ready --project-root ${JSON.stringify(run.project_root)} --json`
269
+ };
270
+ }
261
271
  if (guard.status === "recovered" && binding.status === "accepted" && binding.hook_event_key === eventKey && hook.status === "claimed") {
262
272
  context.db.exec("COMMIT");
263
273
  return { ok: true, accepted: true, recovery_id: guard.recovery_id, generation: guard.generation, reused: true, ...packetReceipt };
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { quote } from "shell-quote";
6
+ import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
5
7
  import { AppError } from "../shared/errors.js";
6
8
  import { resolveProjectRoot } from "../storage/paths.js";
7
9
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
@@ -71,16 +73,22 @@ export function resumeStageAfterUser(context, input) {
71
73
  errorCode: "trusted_stage_resume_required",
72
74
  operation: "stage resume"
73
75
  }).eventKey;
74
- const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
75
- fs.writeFileSync(answerPath, input.answer);
76
76
  const now = context.now();
77
77
  const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
78
78
  if (!workSession)
79
79
  throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
80
+ const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
80
81
  context.db.exec("BEGIN IMMEDIATE");
81
82
  let identity;
82
83
  try {
83
84
  identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
85
+ try {
86
+ fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
87
+ }
88
+ catch (error) {
89
+ if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
90
+ throw error;
91
+ }
84
92
  if (workSession.session_id !== identity.sessionId) {
85
93
  bindSessionForResume(context, work.work_id, identity, now);
86
94
  context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
@@ -99,6 +107,7 @@ export function resumeStageAfterUser(context, input) {
99
107
  refreshRunWorkProjection(context, project.id, run.id);
100
108
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
101
109
  const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
110
+ const retryCommands = lifecycleRetryCommands(context, stage.stage);
102
111
  const continuation = [
103
112
  prompt.trimEnd(),
104
113
  "",
@@ -107,6 +116,7 @@ export function resumeStageAfterUser(context, input) {
107
116
  `- Question source: ${stage.pause.question_path}`,
108
117
  `- Answer source: ${answerPath}`,
109
118
  "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
119
+ ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; use only these replacements, never invent an invocation id:", ...retryCommands.map(command => ` ${command}`)] : []),
110
120
  "",
111
121
  "<user_question>",
112
122
  fs.readFileSync(stage.pause.question_path, "utf8"),
@@ -121,7 +131,7 @@ export function resumeStageAfterUser(context, input) {
121
131
  return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: stage.pause.id, question_path: stage.pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
122
132
  }
123
133
  export function stagePauseCommand(context, input) {
124
- return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
134
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
125
135
  }
126
136
  /**
127
137
  * The only shell form allowed for an agent-owned HITL pause. Supplying a
@@ -147,9 +157,11 @@ Effect on scope or acceptance:
147
157
  USER_QUESTION`;
148
158
  }
149
159
  function stageResumeCommand(context, input) {
150
- return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
160
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
151
161
  }
152
162
  export function flowCommand(context) {
163
+ const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
164
+ const prefix = scope ? `DD_FLOW_INVOCATION_SCOPE=${quote([scope])} ` : "";
153
165
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
154
166
  // Desktop/ACP tool processes are not required to inherit the environment of
155
167
  // the adapter that created their Session. In an isolated runtime, a bare
@@ -158,13 +170,13 @@ export function flowCommand(context) {
158
170
  // make every generated lifecycle command self-contained.
159
171
  const executable = context.env.DD_FLOW_BIN;
160
172
  if (executable && path.isAbsolute(executable)) {
161
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
173
+ return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
162
174
  }
163
175
  const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
164
176
  if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
165
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
177
+ return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
166
178
  }
167
- return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
179
+ return context.ddFlowHome === defaultHome ? `${prefix}dd-flow` : `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
168
180
  }
169
181
  function requireRun(context, projectId, id) {
170
182
  const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);