@deksden-com/dd-flow-cli 0.9.0-beta.52 → 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 +6 -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/merge-server.js +6 -4
- package/dist/services/recovery-snapshot-database.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/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
|
+
}
|
|
@@ -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,
|
|
@@ -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 };
|
|
@@ -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]);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { 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";
|
|
@@ -167,7 +168,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
167
168
|
if (fixIds.length && repairs.length === 0) {
|
|
168
169
|
const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, checkRefsByFinding, decisionReasonsByFinding: Object.fromEntries(canonical.decision.findings.map((item) => [item.finding_ref, item.reason])), objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
|
|
169
170
|
fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
|
|
170
|
-
return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke
|
|
171
|
+
return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke next.finish_command with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
|
|
171
172
|
}
|
|
172
173
|
repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
173
174
|
const incompleteRepairs = repairs.filter((work) => work.status !== "completed");
|
|
@@ -215,7 +216,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
215
216
|
}
|
|
216
217
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
217
218
|
});
|
|
218
|
-
return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {}) };
|
|
219
|
+
return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {}) };
|
|
219
220
|
}
|
|
220
221
|
export function reviewGroups(home) {
|
|
221
222
|
const expected = new Set(baselineAspects);
|
|
@@ -260,7 +261,7 @@ export function assertCodeReviewCoverage(groups, reviewers) {
|
|
|
260
261
|
throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW requires every expected reviewer group", 2, { missing_groups: missing });
|
|
261
262
|
}
|
|
262
263
|
export function reviewerTask(home, group) { return `Read-only independent CODE review for ${group.key}. Assess every assigned aspect exactly once: ${group.aspect_ids.join(", ")}. Read the accepted PLAN, ${path.join(home, "05-code", "stage-report.json")}, and any files needed under the bounded CODE evidence root ${path.join(home, "05-code")}. Verify that universal and exclusive constraints (only, any, all, never, whole) and stated exceptions were not narrowed. For every changed mutation guarded by membership, ownership, authorization or parent lifecycle state, trace the decision to the write boundary: the predicate must remain in the write statement or the guard and write must share one explicit transaction with the needed lock. A separate earlier read is not proof of current authority or lifecycle state; report a material finding when this invariant is broken. Evidence closes a claim only when it exercises the named failure mechanism; a sequential negative test does not prove a concurrency race, and proof limits cannot waive an accepted obligation. Report only material, evidenced defects: a violated obligation or rule, direct evidence, impact, and minimum required outcome. Do not report taste, cosmetics, or untargeted refactoring. Use local finding ids FIND-001, FIND-002, and so on; dd-flow adds the Work-qualified canonical reference. Return dd-flow/code-review-result@1.`; }
|
|
263
|
-
function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_root, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the
|
|
264
|
+
function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_root, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the returned next.finish_command; do not reuse an earlier invocation-id. Review is not repeated.", "If the post-repair aggregate gate fails, do not stop after `code_review_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use the returned repair command with the failed receipt, a relevant completed origin Work ID, and a concise repair objective; then stop so the runner can dispatch the new repair. Do not edit invisibly in the root orchestrator.", `Accepted repair checks: ${JSON.stringify(checks)}`, "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", check_refs: ["CHK-CAUSAL-CHECK only when disposition is fix"], def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
|
|
264
265
|
function canonicalReviewEvidence(decision, reviewers) {
|
|
265
266
|
const known = new Set(canonicalCodeFindings(reviewers).map((item) => item.finding_ref));
|
|
266
267
|
const items = new Map(decision.findings.map((item) => [item.finding_ref, item]));
|
|
@@ -420,7 +421,7 @@ function requireRun(context, projectId, runId) { const run = context.db.get("SEL
|
|
|
420
421
|
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
421
422
|
function requireHome(run) { if (!run.run_root)
|
|
422
423
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
|
|
423
|
-
function finishCommand(context, runId, projectRoot, decision) { return `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl
|
|
424
|
+
function finishCommand(context, runId, projectRoot, decision) { return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
|
|
424
425
|
function findFiles(root, name) { if (!fs.existsSync(root))
|
|
425
426
|
return []; const out = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
426
427
|
const file = path.join(root, entry.name);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
3
|
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
3
4
|
import fs from "node:fs";
|
|
@@ -267,7 +268,7 @@ export async function finishVnextCode(context, input) {
|
|
|
267
268
|
outcome: "accepted",
|
|
268
269
|
report_path: path.join(root, "stage-report.json"),
|
|
269
270
|
next_action: next,
|
|
270
|
-
...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json` } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {})
|
|
271
|
+
...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json`) } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {})
|
|
271
272
|
};
|
|
272
273
|
}
|
|
273
274
|
export function addVnextCodeRepair(context, input) {
|
|
@@ -604,7 +605,7 @@ function requireHome(run) {
|
|
|
604
605
|
return run.run_root;
|
|
605
606
|
}
|
|
606
607
|
function finishCommand(context, runId, projectRoot, verificationFile) {
|
|
607
|
-
return `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl
|
|
608
|
+
return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`);
|
|
608
609
|
}
|
|
609
610
|
function readVerification(context, input) {
|
|
610
611
|
const file = path.resolve(input.file);
|