@deksden-com/dd-flow-cli 0.9.0-beta.51 → 0.9.0-beta.53
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.
- package/CHANGELOG.md +12 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +13 -3
- package/dist/cli/run-cli.js +74 -3
- package/dist/harness-runtime/bin/dd-grok.mjs +3 -4
- package/dist/harness-runtime/bin/dd-zcode.mjs +13 -1
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -19
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +20 -4
- package/dist/harness-runtime/lib/dd-zcode.mjs +85 -9
- package/dist/harness-runtime/lib/native-hook-command.mjs +33 -0
- package/dist/services/cli-operation-classifier.js +2 -0
- package/dist/services/eval-snapshots.js +1 -1
- package/dist/services/external-work-launch.js +5 -2
- package/dist/services/hooks.js +158 -113
- package/dist/services/lifecycle-command.js +63 -5
- package/dist/services/lifecycle-invocations.js +306 -0
- package/dist/services/managed-processes.js +7 -0
- package/dist/services/merge-server.js +6 -4
- package/dist/services/recovery-snapshot-database.js +5 -1
- package/dist/services/run-control.js +5 -1
- package/dist/services/run-controller-recovery.js +4 -1
- package/dist/services/run-controller.js +11 -3
- package/dist/services/run-recovery.js +11 -1
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-stop.js +5 -1
- package/dist/services/stage-pause.js +9 -5
- package/dist/services/vnext-code-review.js +5 -4
- package/dist/services/vnext-code.js +3 -2
- package/dist/services/vnext-merge.js +5 -4
- package/dist/services/vnext-plan-review.js +8 -7
- package/dist/services/vnext-plan.js +3 -2
- package/dist/services/vnext-protocolize.js +5 -4
- package/dist/services/vnext-specify.js +5 -4
- package/dist/services/work-registry.js +6 -3
- package/package.json +1 -1
|
@@ -0,0 +1,306 @@
|
|
|
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) {
|
|
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
|
+
}
|
|
19
|
+
function storage(context) {
|
|
20
|
+
context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
|
|
21
|
+
id TEXT PRIMARY KEY, scope_json TEXT NOT NULL, command TEXT NOT NULL,
|
|
22
|
+
fingerprint TEXT NOT NULL, status TEXT NOT NULL,
|
|
23
|
+
event_key TEXT UNIQUE, identity_json TEXT, deadline INTEGER, outcome_json TEXT,
|
|
24
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
25
|
+
)`);
|
|
26
|
+
}
|
|
27
|
+
function invocation(command) {
|
|
28
|
+
const parsed = parseLifecycleCommand(command);
|
|
29
|
+
if (parsed.kind !== "standalone" || parsed.invocation.wrapped) {
|
|
30
|
+
throw new AppError("invocation_command_invalid", "A lifecycle attempt requires one direct standalone command", 1);
|
|
31
|
+
}
|
|
32
|
+
return parsed.invocation;
|
|
33
|
+
}
|
|
34
|
+
// Input data may be supplied later (stdin/file contents); authority is bound to
|
|
35
|
+
// the exact operation and literal argv. Existing lifecycle validation owns data.
|
|
36
|
+
function fingerprint(parsed) {
|
|
37
|
+
const options = [...parsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key))
|
|
38
|
+
.map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values])
|
|
39
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
40
|
+
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional, options])).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
function load(context, id) {
|
|
43
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
|
|
44
|
+
throw new AppError("invocation_unknown", "This runtime has no issued lifecycle attempts", 1, { invocation_id: id });
|
|
45
|
+
}
|
|
46
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
47
|
+
if (!row)
|
|
48
|
+
throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime", 1, { invocation_id: id });
|
|
49
|
+
return row;
|
|
50
|
+
}
|
|
51
|
+
export function lifecycleInvocationScope(context, id) {
|
|
52
|
+
return JSON.parse(load(context, id).scope_json);
|
|
53
|
+
}
|
|
54
|
+
export function managedInvocationContext(context, input) {
|
|
55
|
+
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
56
|
+
if (input.harness !== "zcode-acp")
|
|
57
|
+
return { ...context, env };
|
|
58
|
+
const project = requireProjectByRoot(context, input.projectRoot);
|
|
59
|
+
const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
|
|
60
|
+
if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
|
|
61
|
+
throw new AppError("invocation_generation_stale", "Managed daemon belongs to another RUN generation", 1);
|
|
62
|
+
const session = state.sessions?.find(session => session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
|
|
63
|
+
if (!state.daemon_id || !session?.root_provider_session_id)
|
|
64
|
+
throw new AppError("invocation_scope_unproven", "Managed ZCode Session has no persisted native root binding", 1);
|
|
65
|
+
const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId: session.root_provider_session_id, runId: input.runId, generation: input.generation };
|
|
66
|
+
env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
|
|
67
|
+
return { ...context, env };
|
|
68
|
+
}
|
|
69
|
+
/** Generated commands retain their attempt across repeated rendering. Only a
|
|
70
|
+
* currently executing command can emit its own successor with the same argv. */
|
|
71
|
+
export function managedLifecycleCommand(context, command) {
|
|
72
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
73
|
+
if (!configured)
|
|
74
|
+
return command;
|
|
75
|
+
const scope = JSON.parse(configured);
|
|
76
|
+
const parsed = invocation(command);
|
|
77
|
+
if (commandOption(parsed, "invocation-id"))
|
|
78
|
+
return command;
|
|
79
|
+
if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
|
|
80
|
+
const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
|
|
81
|
+
? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
|
|
82
|
+
if (!retained)
|
|
83
|
+
throw new AppError("invocation_command_unprepared", "Read-only inspection has no retained lifecycle command", 1);
|
|
84
|
+
return retained.command;
|
|
85
|
+
}
|
|
86
|
+
storage(context);
|
|
87
|
+
context.db.exec("SAVEPOINT invocation_command");
|
|
88
|
+
try {
|
|
89
|
+
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)]);
|
|
90
|
+
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
91
|
+
context.db.exec("RELEASE invocation_command");
|
|
92
|
+
return prior.command;
|
|
93
|
+
}
|
|
94
|
+
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
95
|
+
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
96
|
+
const prepared = issueLifecycleInvocation(context, command, scope);
|
|
97
|
+
context.db.exec("RELEASE invocation_command");
|
|
98
|
+
return prepared.command;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
context.db.exec("ROLLBACK TO invocation_command");
|
|
102
|
+
context.db.exec("RELEASE invocation_command");
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function retryLifecycleInvocationCommand(context, id) {
|
|
107
|
+
const prior = load(context, id);
|
|
108
|
+
if (prior.status !== "executing")
|
|
109
|
+
throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
|
|
110
|
+
const marker = ` --invocation-id ${id}`;
|
|
111
|
+
if (!prior.command.includes(marker))
|
|
112
|
+
throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
|
|
113
|
+
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, prior.command.replace(marker, ""));
|
|
114
|
+
}
|
|
115
|
+
/** Recovery preserves the original packet as evidence. Only its retained,
|
|
116
|
+
* never-executed commands receive new authority; unknown effects never replay. */
|
|
117
|
+
export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
118
|
+
const ids = [...new Set([...originalPrompt.matchAll(/--invocation-id ([0-9a-f-]{36})\b/g)].map(match => match[1]))];
|
|
119
|
+
const recovered = [];
|
|
120
|
+
for (const id of ids) {
|
|
121
|
+
let prior = load(context, id);
|
|
122
|
+
const oldScope = JSON.parse(prior.scope_json);
|
|
123
|
+
if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
|
|
124
|
+
|| oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
|
|
125
|
+
throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
|
|
126
|
+
}
|
|
127
|
+
// The immutable prompt can predate several recoveries. Follow the newest
|
|
128
|
+
// retained attempt, including an unknown successor, rather than reviving
|
|
129
|
+
// an older unexecuted command after the successor already produced effects.
|
|
130
|
+
prior = context.db.get(`SELECT * FROM lifecycle_invocations WHERE fingerprint = ?
|
|
131
|
+
AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') IS ?
|
|
132
|
+
AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ?
|
|
133
|
+
ORDER BY json_extract(scope_json, '$.generation') DESC, rowid DESC LIMIT 1`, [prior.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation]);
|
|
134
|
+
if (prior.status === "executing" || prior.status === "settled") {
|
|
135
|
+
const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
|
|
136
|
+
recovered.push({ invocation_id: id, status: prior.status, outcome,
|
|
137
|
+
instruction: prior.status === "executing" ? "Outcome unknown: reconcile retained operation evidence; do not reissue this command." : "Read the retained outcome; do not repeat completed effects." });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const parsed = invocation(prior.command);
|
|
141
|
+
const argv = [...parsed.argv];
|
|
142
|
+
const at = argv.indexOf("--invocation-id");
|
|
143
|
+
if (at < 0 || argv[at + 1] !== prior.id)
|
|
144
|
+
throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
|
|
145
|
+
argv.splice(at, 2);
|
|
146
|
+
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
147
|
+
const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${quote([parsed.executable, ...argv])} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
|
|
148
|
+
const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
|
|
149
|
+
recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
|
|
150
|
+
}
|
|
151
|
+
return recovered;
|
|
152
|
+
}
|
|
153
|
+
export function assertLifecycleInvocationCurrent(context, scope, command) {
|
|
154
|
+
const parsed = invocation(command);
|
|
155
|
+
if (!scope.runId) {
|
|
156
|
+
if ((parsed.operation === "stage_start" && parsed.args.options.has("bootstrap")) || parsed.operation === "session_register")
|
|
157
|
+
return;
|
|
158
|
+
throw new AppError("invocation_scope_invalid", "This lifecycle operation requires an issued RUN scope", 1);
|
|
159
|
+
}
|
|
160
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
161
|
+
let workId;
|
|
162
|
+
if (parsed.operation.startsWith("work_")) {
|
|
163
|
+
const target = parsed.args.positional[0] ?? "";
|
|
164
|
+
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}-%`]);
|
|
165
|
+
if (works.length !== 1 || works[0].run_id !== scope.runId)
|
|
166
|
+
throw new AppError("invocation_scope_mismatch", "Work does not belong to the issued RUN scope", 1);
|
|
167
|
+
workId = works[0].work_id;
|
|
168
|
+
}
|
|
169
|
+
else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
|
|
170
|
+
const target = parsed.args.positional[0];
|
|
171
|
+
const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
|
|
172
|
+
if (run?.id !== scope.runId)
|
|
173
|
+
throw new AppError("invocation_scope_mismatch", "Command does not belong to the issued RUN scope", 1);
|
|
174
|
+
}
|
|
175
|
+
const guard = recoveryGuard(context, project.id, scope.runId);
|
|
176
|
+
// A cooperative pause advances the fence but explicitly admits settlement
|
|
177
|
+
// of the captured WorkSession/Stage attempt. Reuse that exact authority;
|
|
178
|
+
// never turn the invocation layer into a stricter, divergent pause policy.
|
|
179
|
+
if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
|
|
180
|
+
if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
|
|
181
|
+
return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
|
|
182
|
+
if (parsed.operation === "stage_finish")
|
|
183
|
+
return assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
|
|
184
|
+
}
|
|
185
|
+
if ((guard?.generation ?? 0) !== scope.generation)
|
|
186
|
+
throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
|
|
187
|
+
if (parsed.operation !== "recovery_accept")
|
|
188
|
+
assertRunMutationAllowed(context, project.id, scope.runId);
|
|
189
|
+
}
|
|
190
|
+
function checkCommand(row, command) {
|
|
191
|
+
const parsed = invocation(command);
|
|
192
|
+
const scope = JSON.parse(row.scope_json);
|
|
193
|
+
if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
|
|
194
|
+
|| path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
|
|
195
|
+
throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Explicit issuance; callers retain this command. Read-only show/status must
|
|
199
|
+
* return that retained value, not issue a replacement on every observation. */
|
|
200
|
+
export function issueLifecycleInvocation(context, command, scope) {
|
|
201
|
+
storage(context);
|
|
202
|
+
const parsed = invocation(command);
|
|
203
|
+
if (!scope.daemonId || !scope.rootSessionId || !Number.isInteger(scope.generation) || scope.generation < 0
|
|
204
|
+
|| !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
|
|
205
|
+
|| commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
|
|
206
|
+
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
|
|
207
|
+
}
|
|
208
|
+
const id = crypto.randomUUID();
|
|
209
|
+
const at = shellSuffixOffset(command);
|
|
210
|
+
const prepared = `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
|
|
211
|
+
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
212
|
+
const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
213
|
+
checkCommand(row, prepared);
|
|
214
|
+
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()]);
|
|
215
|
+
return { id, command: prepared };
|
|
216
|
+
}
|
|
217
|
+
/** Native observer and receipt writer commit together. Never await I/O inside
|
|
218
|
+
* this transaction; the callback must write to this same SQLite connection. */
|
|
219
|
+
export function observeLifecycleInvocation(context, input) {
|
|
220
|
+
const initial = load(context, input.id);
|
|
221
|
+
checkCommand(initial, input.command);
|
|
222
|
+
// A completed invocation is a read, even from a new native tool call after
|
|
223
|
+
// reattach. Do not manufacture a new receipt or reject that read as a second
|
|
224
|
+
// executor; the CLI returns the immutable stored outcome without dispatch.
|
|
225
|
+
if (initial.status === "settled")
|
|
226
|
+
return { eventKey: initial.event_key, duplicate: true };
|
|
227
|
+
const scope = JSON.parse(initial.scope_json);
|
|
228
|
+
const identity = input.identity;
|
|
229
|
+
if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
|
|
230
|
+
|| !identity.sessionId || !identity.toolCallId
|
|
231
|
+
|| (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
|
|
232
|
+
throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: input.id });
|
|
233
|
+
}
|
|
234
|
+
const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
|
|
235
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
236
|
+
try {
|
|
237
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]);
|
|
238
|
+
if (row.status === "settled") {
|
|
239
|
+
context.db.exec("COMMIT");
|
|
240
|
+
return { eventKey: row.event_key, duplicate: true };
|
|
241
|
+
}
|
|
242
|
+
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
|
|
243
|
+
throw new AppError("invocation_expired", "Late observation cannot revive an expired lifecycle attempt", 1, { invocation_id: input.id });
|
|
244
|
+
}
|
|
245
|
+
if (row.identity_json) {
|
|
246
|
+
if (row.identity_json !== identityJson)
|
|
247
|
+
throw new AppError("invocation_identity_conflict", "Another native tool call already owns this attempt", 1, { invocation_id: input.id });
|
|
248
|
+
context.db.exec("COMMIT");
|
|
249
|
+
return { eventKey: row.event_key, duplicate: true };
|
|
250
|
+
}
|
|
251
|
+
const eventKey = input.recordReceipt();
|
|
252
|
+
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]);
|
|
253
|
+
if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
|
|
254
|
+
throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
|
|
255
|
+
}
|
|
256
|
+
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]);
|
|
257
|
+
context.db.exec("COMMIT");
|
|
258
|
+
return { eventKey, duplicate: false };
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
context.db.exec("ROLLBACK");
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** One caller owns execution; a lost process leaves an explicit unknown
|
|
266
|
+
* outcome, never an automatic replay. Existing recovery decides the next step. */
|
|
267
|
+
export async function awaitLifecycleInvocation(context, input) {
|
|
268
|
+
const initial = load(context, input.id);
|
|
269
|
+
checkCommand(initial, input.command);
|
|
270
|
+
const timeoutMs = input.timeoutMs ?? 30000;
|
|
271
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
272
|
+
throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
|
|
273
|
+
context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
|
|
274
|
+
for (;;) {
|
|
275
|
+
const row = load(context, input.id);
|
|
276
|
+
if (row.status === "settled")
|
|
277
|
+
return { outcome: JSON.parse(row.outcome_json), replay: true };
|
|
278
|
+
if (row.status === "executing")
|
|
279
|
+
throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id });
|
|
280
|
+
input.assertCurrent(JSON.parse(row.scope_json));
|
|
281
|
+
if (row.status === "observed") {
|
|
282
|
+
const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
|
|
283
|
+
if (claimed.changes === 1)
|
|
284
|
+
return { eventKey: row.event_key, replay: false };
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
|
|
288
|
+
const expired = context.db.run("UPDATE lifecycle_invocations SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'issued'", [context.now(), input.id]);
|
|
289
|
+
if (!expired.changes && row.status !== "expired")
|
|
290
|
+
continue;
|
|
291
|
+
throw new AppError("invocation_receipt_timeout", "No native confirmation before the receipt deadline; lifecycle state was not changed", 1, { invocation_id: input.id });
|
|
292
|
+
}
|
|
293
|
+
await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
export function settleLifecycleInvocation(context, id, outcome) {
|
|
297
|
+
const encoded = JSON.stringify(outcome);
|
|
298
|
+
if (encoded === undefined)
|
|
299
|
+
throw new AppError("invocation_outcome_invalid", "Invocation outcome must be JSON", 1);
|
|
300
|
+
const changed = context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]);
|
|
301
|
+
if (changed.changes !== 1) {
|
|
302
|
+
const row = load(context, id);
|
|
303
|
+
if (row.status !== "settled" || row.outcome_json !== encoded)
|
|
304
|
+
throw new AppError("invocation_settlement_conflict", "Attempt cannot be settled with a different outcome", 1, { invocation_id: id });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -175,6 +175,13 @@ export function claimExpiredManagedProcesses(context, ownerId) {
|
|
|
175
175
|
throw error;
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
/** Workspace gates are leases, not spawned processes. Children retain their own registrations. */
|
|
179
|
+
export function managedLeaseReleased(record) {
|
|
180
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
181
|
+
return record.kind === "workspace-gate" && record.pid === null && record.pid_started_at === null
|
|
182
|
+
&& metadata.process_group_id == null && metadata.role === "check_gate"
|
|
183
|
+
&& ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at);
|
|
184
|
+
}
|
|
178
185
|
export function managedProcessStatus(context) {
|
|
179
186
|
return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
|
|
180
187
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -9,7 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
10
10
|
import { runtimeHarness } from "./execution-policy.js";
|
|
11
11
|
import { harnessKey } from "./harness-config.js";
|
|
12
|
-
import { finishManagedProcess, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
12
|
+
import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
13
13
|
import { reconcileUnfinishedChecks } from "./code-checks.js";
|
|
14
14
|
import { controlStatusReceipt } from "./run-control-receipt.js";
|
|
15
15
|
import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
|
|
@@ -473,6 +473,10 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
473
473
|
}
|
|
474
474
|
}));
|
|
475
475
|
const processResults = Promise.all(processTargets.map(async ({ home, record }) => {
|
|
476
|
+
if (managedLeaseReleased(record))
|
|
477
|
+
return { process_id: record.id, kind: record.kind, status: "completed", receipt: { source: "managed_lease_released", ...managedProcessBinding(record, home), finished_at: record.finished_at } };
|
|
478
|
+
if (record.kind === "workspace-gate")
|
|
479
|
+
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_lease_unsettled" };
|
|
476
480
|
if (!record.pid || !record.pid_started_at)
|
|
477
481
|
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_identity_unknown" };
|
|
478
482
|
if (!interrupt && processTreeIsAlive(record))
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { createContext } from "../runtime/context.js";
|
|
5
5
|
import { getResourceDatabase } from "../storage/database.js";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
-
import { finishManagedProcess, managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
7
|
+
import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
8
8
|
import { requestRuntimeScopeControl, runtimeBudgetStatus, scopeLocalProcessRole, validateRuntimeScopeManifest } from "./runtime-budget.js";
|
|
9
9
|
import { nativeInventoryVersion, recordRunControlIntent, retainedDaemonOperations, runControlStatus } from "./run-control.js";
|
|
10
10
|
import { requestRunControl } from "./run-control-worker.js";
|
|
@@ -125,7 +125,7 @@ export function retainScopeDrain(context, control, processes, runs, native, prob
|
|
|
125
125
|
}
|
|
126
126
|
export function scopeRunProcessEvidence(record, runs, registryHome) {
|
|
127
127
|
const metadata = JSON.parse(record.metadata_json);
|
|
128
|
-
if (!record.pid || !record.pid_started_at || !record.project_id || !record.run_id || !metadata.dd_flow_home)
|
|
128
|
+
if ((!managedLeaseReleased(record) && (!record.pid || !record.pid_started_at)) || !record.project_id || !record.run_id || !metadata.dd_flow_home)
|
|
129
129
|
return null;
|
|
130
130
|
for (const run of runs) {
|
|
131
131
|
if (run.runtime_home !== metadata.dd_flow_home || run.project_id !== record.project_id || run.run_id !== record.run_id)
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
5
|
import { runHarnessAdapter } from "./harness-adapter.js";
|
|
6
|
-
import { managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
6
|
+
import { managedLeaseReleased, managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
7
7
|
import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
|
|
8
8
|
import { retainedDaemonOperations } from "./run-control.js";
|
|
9
9
|
import { historicalDaemonSettlement } from "./native-daemon-history.js";
|
|
@@ -62,6 +62,10 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
|
|
|
62
62
|
for (const record of runtimeBudgetStatus(context, scopeId).processes) {
|
|
63
63
|
if (covered.has(record.id))
|
|
64
64
|
continue;
|
|
65
|
+
if (managedLeaseReleased(record)) {
|
|
66
|
+
nodes.push({ process_id: record.id, settled: true });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
65
69
|
const metadata = JSON.parse(record.metadata_json);
|
|
66
70
|
// RUN control owns active controllers/checks. Adopt only its already-finished
|
|
67
71
|
// physical registrations here; death alone is not a completed RUN operation.
|
|
@@ -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 { 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";
|
|
@@ -121,7 +123,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
121
123
|
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
124
|
}
|
|
123
125
|
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
|
|
126
|
+
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
127
|
}
|
|
126
128
|
/**
|
|
127
129
|
* The only shell form allowed for an agent-owned HITL pause. Supplying a
|
|
@@ -147,9 +149,11 @@ Effect on scope or acceptance:
|
|
|
147
149
|
USER_QUESTION`;
|
|
148
150
|
}
|
|
149
151
|
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
|
|
152
|
+
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
153
|
}
|
|
152
154
|
export function flowCommand(context) {
|
|
155
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
156
|
+
const prefix = scope ? `DD_FLOW_INVOCATION_SCOPE=${quote([scope])} ` : "";
|
|
153
157
|
const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
|
|
154
158
|
// Desktop/ACP tool processes are not required to inherit the environment of
|
|
155
159
|
// the adapter that created their Session. In an isolated runtime, a bare
|
|
@@ -158,13 +162,13 @@ export function flowCommand(context) {
|
|
|
158
162
|
// make every generated lifecycle command self-contained.
|
|
159
163
|
const executable = context.env.DD_FLOW_BIN;
|
|
160
164
|
if (executable && path.isAbsolute(executable)) {
|
|
161
|
-
return
|
|
165
|
+
return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
|
|
162
166
|
}
|
|
163
167
|
const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
|
|
164
168
|
if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
|
|
165
|
-
return
|
|
169
|
+
return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
|
|
166
170
|
}
|
|
167
|
-
return context.ddFlowHome === defaultHome ?
|
|
171
|
+
return context.ddFlowHome === defaultHome ? `${prefix}dd-flow` : `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
|
|
168
172
|
}
|
|
169
173
|
function requireRun(context, projectId, id) {
|
|
170
174
|
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]);
|