@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79
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 +8 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +7 -11
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +85 -42
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +207 -94
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
|
@@ -13,25 +13,38 @@ import { appendEvent } from "./run-controller-state.js";
|
|
|
13
13
|
import { recoverCommittedWorkStart } from "./work-registry.js";
|
|
14
14
|
import { observeCommandHook } from "./hooks.js";
|
|
15
15
|
import { runtimeHarness } from "./execution-policy.js";
|
|
16
|
+
import { assertPathWithin } from "../storage/paths.js";
|
|
17
|
+
import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
|
|
16
18
|
// Conclusive result/check rejections, never transport, ownership, registry or
|
|
17
19
|
// unknown-effect failures. These validators reject before accepting the result.
|
|
18
20
|
export function isRetryableLifecycleRejection(code, details = {}) {
|
|
19
|
-
if (
|
|
21
|
+
if (code.startsWith("native_hook_") || code === "invocation_receipt_timeout")
|
|
22
|
+
return false;
|
|
23
|
+
if (code === "fresh_session_required" || details.recoverable === false || details.handoff_required === true || details.effect === "unknown" || details.effect === "committed")
|
|
20
24
|
return false;
|
|
21
|
-
|
|
25
|
+
if (code === "validation" || code === "schema_validation")
|
|
26
|
+
return details.phase === "prepare" && details.effect === "no_effect" && details.recoverable === true;
|
|
27
|
+
return details.recoverable === true || ["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed",
|
|
22
28
|
"review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
23
29
|
"review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
|
|
24
30
|
"evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
|
|
25
31
|
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
26
32
|
|| (code === "invalid_work_state" && details.retryable_no_effect === true);
|
|
27
33
|
}
|
|
34
|
+
/** A correctable rejection either publishes an exact replacement command or
|
|
35
|
+
* proves that the controller must hand the existing Work to another Session.
|
|
36
|
+
* The latter is deliberately not retryable in the caller that just failed. */
|
|
37
|
+
export function isCorrectableLifecycleRejection(code, details = {}) {
|
|
38
|
+
if (details.recoverable === false || details.effect === "unknown" || details.effect === "committed")
|
|
39
|
+
return false;
|
|
40
|
+
return isRetryableLifecycleRejection(code, details)
|
|
41
|
+
|| (code === "fresh_session_required" && details.handoff_required === true
|
|
42
|
+
&& details.recoverable === true);
|
|
43
|
+
}
|
|
28
44
|
function storage(context) {
|
|
29
|
-
context.db.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
event_key TEXT UNIQUE, identity_json TEXT, deadline INTEGER, outcome_json TEXT,
|
|
33
|
-
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
34
|
-
)`);
|
|
45
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
|
|
46
|
+
throw new AppError("invocation_storage_unprepared", "Lifecycle invocation storage is not prepared; start dd-flow normally before issuing native work", 1);
|
|
47
|
+
}
|
|
35
48
|
}
|
|
36
49
|
function invocation(command) {
|
|
37
50
|
const parsed = parseLifecycleCommand(command);
|
|
@@ -44,17 +57,101 @@ function invocation(command) {
|
|
|
44
57
|
// the exact operation and literal argv. Existing lifecycle validation owns data.
|
|
45
58
|
function fingerprint(parsed) {
|
|
46
59
|
const options = [...parsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key))
|
|
47
|
-
.map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values])
|
|
60
|
+
.map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
|
|
48
61
|
.sort(([a], [b]) => a.localeCompare(b));
|
|
49
|
-
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional, options])).digest("hex");
|
|
62
|
+
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
|
|
63
|
+
}
|
|
64
|
+
function normalizedInvocationValue(key, value) {
|
|
65
|
+
if (key === "project-root" && (path.isAbsolute(value) || value === "@project"))
|
|
66
|
+
return "@project";
|
|
67
|
+
if (["result-file", "decision-file", "verification-file"].includes(key))
|
|
68
|
+
return "<validated-input-path>";
|
|
69
|
+
if (value.startsWith("@project") || value.startsWith("@workspace") || value.startsWith("@run"))
|
|
70
|
+
return value.replace(/\\/g, "/");
|
|
71
|
+
if (["result-file", "decision-file", "verification-file", "context-file"].includes(key) && path.isAbsolute(value)) {
|
|
72
|
+
const runRelative = value.replace(/\\/g, "/").match(/\/(?:runs\/)?RUN-[^/]+\/(.+)$/)?.[1];
|
|
73
|
+
if (runRelative)
|
|
74
|
+
return `@run/${runRelative}`;
|
|
75
|
+
}
|
|
76
|
+
const entity = /^(PRJ|RUN|WRK)-\d{3,}(?:-|$)/.exec(value)?.[0]?.replace(/-$/, "");
|
|
77
|
+
if (entity)
|
|
78
|
+
return entity;
|
|
79
|
+
return /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value;
|
|
80
|
+
}
|
|
81
|
+
function withoutInvocationId(command, aliases) {
|
|
82
|
+
const parsed = invocation(command);
|
|
83
|
+
const argv = [...parsed.argv];
|
|
84
|
+
const at = argv.indexOf("--invocation-id");
|
|
85
|
+
if (at >= 0)
|
|
86
|
+
argv.splice(at, 2);
|
|
87
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
88
|
+
const option = argv[index - 1]?.replace(/^--/, "");
|
|
89
|
+
if (option === "project-root")
|
|
90
|
+
argv[index] = "@project";
|
|
91
|
+
else if (option && aliases && ["result-file", "decision-file", "verification-file", "context-file"].includes(option) && path.isAbsolute(argv[index])) {
|
|
92
|
+
for (const [name, root] of [["run", aliases.run], ["workspace", aliases.workspace], ["project", aliases.project]]) {
|
|
93
|
+
if (!root)
|
|
94
|
+
continue;
|
|
95
|
+
const relative = path.relative(root, argv[index]);
|
|
96
|
+
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
|
97
|
+
argv[index] = `@${name}${relative ? `/${relative.replaceAll(path.sep, "/")}` : ""}`;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (!argv[index - 1]?.startsWith("--") && !argv[index].startsWith("--"))
|
|
103
|
+
argv[index] = normalizedInvocationValue("positional", argv[index]);
|
|
104
|
+
}
|
|
105
|
+
const env = Object.fromEntries(Object.entries(parsed.env).filter(([key]) => !["DD_FLOW_INVOCATION_SCOPE", "DD_FLOW_HOME", "DD_FLOW_BIN"].includes(key)));
|
|
106
|
+
const executable = parsed.env.DD_FLOW_BIN || parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
|
|
107
|
+
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
108
|
+
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
109
|
+
return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
110
|
+
}
|
|
111
|
+
function publicInvocationCommand(context, row, command) {
|
|
112
|
+
const rendered = renderInvocationCommand(row, command);
|
|
113
|
+
const scope = JSON.parse(row.scope_json);
|
|
114
|
+
const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
115
|
+
return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
|
|
116
|
+
}
|
|
117
|
+
/** Resolve the small, declared alias vocabulary used by model-facing lifecycle commands.
|
|
118
|
+
* Both native-hook admission and CLI execution call this function so they cannot
|
|
119
|
+
* disagree about the command that an alias denotes. */
|
|
120
|
+
export function expandLifecycleInvocationArgs(context, args, scope, operation) {
|
|
121
|
+
const pathOptions = contextualPathOptionsForLifecycle(operation);
|
|
122
|
+
const run = scope.runId ? context.db.get("SELECT id, short_id, workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
123
|
+
const roots = { project: scope.projectRoot, workspace: run?.workspace_root, run: run?.run_root };
|
|
124
|
+
return args.map((value, index) => {
|
|
125
|
+
if (run && !args[index - 1]?.startsWith("--") && [run.short_id, normalizedInvocationValue("positional", run.id)].includes(value) && !operation.startsWith("work_"))
|
|
126
|
+
return run.id;
|
|
127
|
+
const option = args[index - 1]?.replace(/^--/, "");
|
|
128
|
+
if (!option || !pathOptions.has(option) || !value.startsWith("@"))
|
|
129
|
+
return value;
|
|
130
|
+
const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
|
|
131
|
+
if (!match)
|
|
132
|
+
throw new AppError("usage", `Unknown path alias: ${value}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
133
|
+
const root = roots[match[1]];
|
|
134
|
+
if (!root)
|
|
135
|
+
throw new AppError("usage", `Path alias @${match[1]} is unavailable without a bound RUN`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
136
|
+
return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function canonicalManagedCommand(context, command, scope) {
|
|
140
|
+
const parsed = invocation(command);
|
|
141
|
+
const argv = expandLifecycleInvocationArgs(context, parsed.argv, scope, parsed.operation);
|
|
142
|
+
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
143
|
+
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
144
|
+
const executable = parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
|
|
145
|
+
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
146
|
+
return `${prefix} ${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
50
147
|
}
|
|
51
148
|
function load(context, id) {
|
|
52
149
|
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
|
|
53
|
-
throw new AppError("invocation_unknown", "This runtime has no issued lifecycle
|
|
150
|
+
throw new AppError("invocation_unknown", "This runtime has no issued lifecycle attempt for the requested operation; use a current runtime-issued command without adding an invocation ID", 1, { invocation_id: id, effect: "no_effect" });
|
|
54
151
|
}
|
|
55
152
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
56
153
|
if (!row)
|
|
57
|
-
throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime", 1, { invocation_id: id });
|
|
154
|
+
throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime; use a current runtime-issued command without adding an invocation ID", 1, { invocation_id: id, effect: "no_effect" });
|
|
58
155
|
return row;
|
|
59
156
|
}
|
|
60
157
|
export function lifecycleInvocationScope(context, id) {
|
|
@@ -96,7 +193,7 @@ export function assertLifecycleOutcomes(context, scope) {
|
|
|
96
193
|
ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
|
|
97
194
|
for (const row of rows) {
|
|
98
195
|
const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
|
|
99
|
-
if (typeof error?.code === "string" && !
|
|
196
|
+
if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details))
|
|
100
197
|
throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
|
|
101
198
|
}
|
|
102
199
|
}
|
|
@@ -171,14 +268,16 @@ export function observeLifecycleCommand(context, command) {
|
|
|
171
268
|
if (event)
|
|
172
269
|
context.observeLifecycleHook(project.id, event.id);
|
|
173
270
|
}
|
|
271
|
+
if (!anchor)
|
|
272
|
+
throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { invocation_id: invocationId, effect: "no_effect" });
|
|
174
273
|
}
|
|
175
274
|
return outcome => {
|
|
176
275
|
delete context.observeLifecycleHook;
|
|
177
276
|
if (!anchor)
|
|
178
277
|
return;
|
|
179
278
|
const error = outcome.error ? errorRecord(outcome.error) : undefined;
|
|
180
|
-
const
|
|
181
|
-
const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (
|
|
279
|
+
const correctable = error && isCorrectableLifecycleRejection(error.code, error.details);
|
|
280
|
+
const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? correctable ? "correctable" : "fatal" : "completed" };
|
|
182
281
|
try {
|
|
183
282
|
context.db.writeTransaction(() => {
|
|
184
283
|
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
|
|
@@ -226,35 +325,40 @@ export function lifecycleRetryCommands(context, stage) {
|
|
|
226
325
|
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
227
326
|
if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
228
327
|
return [];
|
|
229
|
-
return context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
328
|
+
return [...new Set(context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
|
|
329
|
+
.flatMap((row) => {
|
|
330
|
+
try {
|
|
331
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
332
|
+
const retry = outcome?.error?.details?.retry_command;
|
|
333
|
+
const parsed = invocation(row.command);
|
|
334
|
+
if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
|
|
335
|
+
return [];
|
|
336
|
+
const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
|
|
337
|
+
return latest?.status === "issued" && (latest.deadline === null || latest.deadline > Date.now()) ? [retry] : [];
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
236
340
|
return [];
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return successor.id === latest?.id && successor.status === "issued" && (successor.deadline === null || successor.deadline > Date.now()) ? [retry] : [];
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
return [];
|
|
243
|
-
}
|
|
244
|
-
});
|
|
341
|
+
}
|
|
342
|
+
}))];
|
|
245
343
|
}
|
|
246
344
|
export function managedInvocationContext(context, input) {
|
|
247
345
|
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
248
|
-
if (input.harness !== "zcode-acp")
|
|
346
|
+
if (input.harness !== "zcode-acp" && input.harness !== "codex-desktop")
|
|
249
347
|
return { ...context, env };
|
|
250
348
|
const project = requireProjectByRoot(context, input.projectRoot);
|
|
251
349
|
const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
|
|
252
350
|
if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
|
|
253
351
|
throw new AppError("invocation_generation_stale", "Managed daemon belongs to another RUN generation", 1);
|
|
254
|
-
const session = state.sessions?.find(session => session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
352
|
+
const session = state.sessions?.find(session => typeof session === "string" ? session === input.sessionId : session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
|
|
353
|
+
// Codex owns a single root Session per controller and persists that root as
|
|
354
|
+
// a string. Child agent_id values arrive later through the native hook and
|
|
355
|
+
// are checked against this root scope; they are not controller Sessions.
|
|
356
|
+
const rootSessionId = input.harness === "codex-desktop"
|
|
357
|
+
? (typeof session === "string" ? session : session?.root_provider_session_id ?? session?.provider_session_id ?? null)
|
|
358
|
+
: (typeof session === "string" ? null : session?.root_provider_session_id ?? null);
|
|
359
|
+
if (!state.daemon_id || !rootSessionId)
|
|
360
|
+
throw new AppError("invocation_scope_unproven", "Managed native Session has no persisted native root binding", 1);
|
|
361
|
+
const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId, runId: input.runId, generation: input.generation };
|
|
258
362
|
env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
|
|
259
363
|
return { ...context, env };
|
|
260
364
|
}
|
|
@@ -265,6 +369,7 @@ export function managedLifecycleCommand(context, command) {
|
|
|
265
369
|
if (!configured)
|
|
266
370
|
return command;
|
|
267
371
|
const scope = JSON.parse(configured);
|
|
372
|
+
command = canonicalManagedCommand(context, command, scope);
|
|
268
373
|
const parsed = invocation(command);
|
|
269
374
|
const suppliedId = commandOption(parsed, "invocation-id");
|
|
270
375
|
// An invocation id is authority, never decorative text. In particular,
|
|
@@ -277,24 +382,25 @@ export function managedLifecycleCommand(context, command) {
|
|
|
277
382
|
}
|
|
278
383
|
checkCommand(retained, command);
|
|
279
384
|
assertRenderableInvocation(retained);
|
|
280
|
-
return command;
|
|
385
|
+
return publicInvocationCommand(context, retained, command);
|
|
281
386
|
}
|
|
282
387
|
if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
|
|
283
388
|
const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
|
|
284
389
|
? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
|
|
285
390
|
if (!retained)
|
|
286
|
-
throw new AppError("invocation_command_unprepared", "The controller has not issued this lifecycle
|
|
287
|
-
return
|
|
391
|
+
throw new AppError("invocation_command_unprepared", "The controller has not issued this lifecycle operation yet. End this Turn and await its next assignment; do not add or reuse an internal invocation ID.", 1);
|
|
392
|
+
return publicInvocationCommand(context, retained, command);
|
|
288
393
|
}
|
|
289
394
|
storage(context);
|
|
290
395
|
return context.db.writeTransaction(() => {
|
|
291
396
|
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)]);
|
|
292
397
|
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
293
|
-
return
|
|
398
|
+
return publicInvocationCommand(context, prior, command);
|
|
294
399
|
}
|
|
295
400
|
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
296
401
|
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
297
|
-
|
|
402
|
+
const issued = issueLifecycleInvocation(context, command, scope);
|
|
403
|
+
return publicInvocationCommand(context, load(context, issued.id), command);
|
|
298
404
|
});
|
|
299
405
|
}
|
|
300
406
|
export function retryLifecycleInvocationCommand(context, id) {
|
|
@@ -304,8 +410,8 @@ export function retryLifecycleInvocationCommand(context, id) {
|
|
|
304
410
|
return successorLifecycleInvocationCommand(context, prior);
|
|
305
411
|
}
|
|
306
412
|
/** A predecessor may publish exactly one successor only while the caller has
|
|
307
|
-
* proved that no lifecycle effect was admitted.
|
|
308
|
-
*
|
|
413
|
+
* proved that no lifecycle effect was admitted. Shared by CLI argument
|
|
414
|
+
* validation and conclusive lifecycle/result rejection, never by hooks. */
|
|
309
415
|
function successorLifecycleInvocationCommand(context, prior) {
|
|
310
416
|
const parsed = invocation(prior.command);
|
|
311
417
|
const argv = [...parsed.argv];
|
|
@@ -322,44 +428,17 @@ function successorLifecycleInvocationCommand(context, prior) {
|
|
|
322
428
|
const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
|
|
323
429
|
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
|
|
324
430
|
}
|
|
325
|
-
/** A native observer can reject a malformed tool call before the CLI claims
|
|
326
|
-
* execution. Persist that fact and its sole replacement atomically: waiting
|
|
327
|
-
* callers must never time out merely because the observer rejected syntax. */
|
|
328
|
-
export function settleLifecycleNoEffectRejection(context, id, command, error, identity) {
|
|
329
|
-
storage(context);
|
|
330
|
-
context.db.beginWriteTransaction();
|
|
331
|
-
try {
|
|
332
|
-
const prior = load(context, id);
|
|
333
|
-
checkCommand(prior, command);
|
|
334
|
-
const scope = JSON.parse(prior.scope_json);
|
|
335
|
-
checkNativeIdentity(scope, identity, id);
|
|
336
|
-
if (prior.status === "settled") {
|
|
337
|
-
context.db.exec("COMMIT");
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
if (scope.runId)
|
|
341
|
-
assertLifecycleInvocationCurrent(context, scope, command);
|
|
342
|
-
if (prior.status !== "issued")
|
|
343
|
-
throw new AppError("invocation_rejection_conflict", "A lifecycle rejection arrived after execution admission", 1, { invocation_id: id });
|
|
344
|
-
settleUnadmittedInvocation(context, prior, error);
|
|
345
|
-
context.db.exec("COMMIT");
|
|
346
|
-
}
|
|
347
|
-
catch (error) {
|
|
348
|
-
context.db.exec("ROLLBACK");
|
|
349
|
-
throw error;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
431
|
// Caller holds the write transaction and has checked authority. Issued (or
|
|
353
432
|
// legacy expired) attempts have never admitted an effect; observed/executing
|
|
354
433
|
// attempts must never receive an automatic replacement.
|
|
355
434
|
function settleUnadmittedInvocation(context, prior, error) {
|
|
356
435
|
if (!["issued", "expired"].includes(prior.status))
|
|
357
436
|
throw new AppError("invocation_rejection_conflict", "Attempt already admitted native execution", 1);
|
|
358
|
-
const details = { ...error.details, effect: "no_effect", recoverable:
|
|
359
|
-
retry_instruction: "Use retry_command as one standalone lifecycle command. The rejected command did not change lifecycle state." };
|
|
437
|
+
const details = { ...error.details, effect: "no_effect", recoverable: false };
|
|
360
438
|
if (context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = ?", [JSON.stringify({ error: { ...error, details } }), context.now(), prior.id, prior.status]).changes !== 1) {
|
|
361
439
|
throw new AppError("invocation_settlement_conflict", "Lifecycle rejection could not be committed", 1, { invocation_id: prior.id });
|
|
362
440
|
}
|
|
441
|
+
return details;
|
|
363
442
|
}
|
|
364
443
|
const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "No native confirmation before the receipt deadline; lifecycle state was not changed", exitCode: 1, details: { invocation_id: id } });
|
|
365
444
|
/** Settle a conclusive rejection and publish its only legal successor atomically.
|
|
@@ -383,6 +462,12 @@ export function settleLifecycleRejection(context, id, error) {
|
|
|
383
462
|
details.storage_retry = 1;
|
|
384
463
|
details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
385
464
|
}
|
|
465
|
+
else if (isCorrectableLifecycleRejection(error.code, error.details)) {
|
|
466
|
+
details.effect = "no_effect";
|
|
467
|
+
details.recoverable = true;
|
|
468
|
+
details.handoff_required = true;
|
|
469
|
+
details.recovery_instruction = "Do not retry this command in the current Session. End the coordinator Turn; the controller will launch the already-registered Work in a fresh child Session.";
|
|
470
|
+
}
|
|
386
471
|
settleLifecycleInvocation(context, id, { error: { ...error, details } });
|
|
387
472
|
context.db.exec("COMMIT");
|
|
388
473
|
Object.assign(error.details, details);
|
|
@@ -477,14 +562,23 @@ function checkCommand(row, command) {
|
|
|
477
562
|
}
|
|
478
563
|
/** Explicit issuance; callers retain this command. Read-only show/status must
|
|
479
564
|
* return that retained value, not issue a replacement on every observation. */
|
|
480
|
-
export function
|
|
481
|
-
|
|
565
|
+
export function prepareLifecycleIssuance(command, value) {
|
|
566
|
+
if (typeof command !== "string" || !command.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
567
|
+
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires command and managed scope", 1);
|
|
568
|
+
}
|
|
569
|
+
const scope = value;
|
|
482
570
|
const parsed = invocation(command);
|
|
483
|
-
if (!scope.daemonId || !scope.rootSessionId || !Number.
|
|
484
|
-
||
|
|
571
|
+
if (typeof scope.daemonId !== "string" || !scope.daemonId.trim() || typeof scope.rootSessionId !== "string" || !scope.rootSessionId.trim() || !Number.isSafeInteger(scope.generation) || scope.generation < 0
|
|
572
|
+
|| (scope.runId !== null && (typeof scope.runId !== "string" || !scope.runId.trim()))
|
|
573
|
+
|| typeof scope.projectRoot !== "string" || !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
|
|
485
574
|
|| commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
|
|
486
575
|
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
|
|
487
576
|
}
|
|
577
|
+
return { command, scope, parsed };
|
|
578
|
+
}
|
|
579
|
+
export function issueLifecycleInvocation(context, command, scope) {
|
|
580
|
+
const { parsed } = prepareLifecycleIssuance(command, scope);
|
|
581
|
+
storage(context);
|
|
488
582
|
const id = crypto.randomUUID();
|
|
489
583
|
const prepared = commandWithInvocationId(command, id);
|
|
490
584
|
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
@@ -527,56 +621,153 @@ function checkNativeIdentity(scope, identity, id) {
|
|
|
527
621
|
}
|
|
528
622
|
}
|
|
529
623
|
export function observeLifecycleInvocation(context, input) {
|
|
530
|
-
|
|
531
|
-
|
|
624
|
+
storage(context);
|
|
625
|
+
const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
|
|
626
|
+
const analysis = parseLifecycleCommand(input.command);
|
|
627
|
+
if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
|
|
628
|
+
return { eventKey: input.recordReceipt(), duplicate: false, invocationId: null };
|
|
629
|
+
const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed') ORDER BY rowid DESC")
|
|
630
|
+
.filter(row => {
|
|
631
|
+
const scope = JSON.parse(row.scope_json);
|
|
632
|
+
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
633
|
+
return false;
|
|
634
|
+
try {
|
|
635
|
+
return fingerprint(invocation(canonicalManagedCommand(context, input.command, scope))) === row.fingerprint;
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
// A known explicit ID remains a compatibility path. A missing or mistyped
|
|
642
|
+
// public ID is corrected only by an exact operation/target match inside the
|
|
643
|
+
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
644
|
+
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
645
|
+
if (!initial) {
|
|
646
|
+
const eventKey = input.recordReceipt();
|
|
647
|
+
if (candidates.length > 1)
|
|
648
|
+
throw new AppError("invocation_ambiguous", "More than one issued lifecycle attempt matches this native command", 1, { effect: "no_effect", matches: candidates.map(row => row.id) });
|
|
649
|
+
return { eventKey, duplicate: false, invocationId: null };
|
|
650
|
+
}
|
|
651
|
+
const resolved = input.id === initial.id ? {} : { invocationId: initial.id };
|
|
532
652
|
// A completed invocation is a read, even from a new native tool call after
|
|
533
653
|
// reattach. Do not manufacture a new receipt or reject that read as a second
|
|
534
654
|
// executor; the CLI returns the immutable stored outcome without dispatch.
|
|
535
655
|
if (initial.status === "settled")
|
|
536
|
-
return { eventKey: initial.event_key, duplicate: true };
|
|
656
|
+
return { eventKey: initial.event_key, duplicate: true, ...resolved };
|
|
537
657
|
const scope = JSON.parse(initial.scope_json);
|
|
658
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
538
659
|
const identity = input.identity;
|
|
539
|
-
checkNativeIdentity(scope, identity,
|
|
660
|
+
checkNativeIdentity(scope, identity, initial.id);
|
|
540
661
|
const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
|
|
541
662
|
context.db.beginWriteTransaction();
|
|
542
663
|
try {
|
|
543
|
-
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [
|
|
664
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
544
665
|
if (row.status === "settled") {
|
|
545
666
|
context.db.exec("COMMIT");
|
|
546
|
-
return { eventKey: row.event_key, duplicate: true };
|
|
667
|
+
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
547
668
|
}
|
|
669
|
+
// Observation records native facts only. CLI owns deadline, argument and
|
|
670
|
+
// RUN-state validation, including decisions about retrying an invocation.
|
|
548
671
|
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
|
|
549
|
-
if (scope.runId)
|
|
550
|
-
assertLifecycleInvocationCurrent(context, scope, input.command);
|
|
551
|
-
settleUnadmittedInvocation(context, row, receiptTimeout(input.id));
|
|
552
672
|
context.db.exec("COMMIT");
|
|
553
|
-
return { eventKey: row.event_key, duplicate: true };
|
|
673
|
+
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
554
674
|
}
|
|
555
|
-
if (row.identity_json) {
|
|
556
|
-
if (row.identity_json !== identityJson)
|
|
557
|
-
throw new AppError("invocation_identity_conflict", "Another native tool call already owns this attempt", 1, { invocation_id: input.id });
|
|
675
|
+
if (row.identity_json === identityJson) {
|
|
558
676
|
context.db.exec("COMMIT");
|
|
559
|
-
return { eventKey: row.event_key, duplicate: true };
|
|
677
|
+
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
560
678
|
}
|
|
561
|
-
const eventKey = input.recordReceipt();
|
|
562
|
-
|
|
679
|
+
const eventKey = input.recordReceipt(scope);
|
|
680
|
+
// A native hook may execute from a provider worktree while the issued
|
|
681
|
+
// lifecycle scope is anchored to the stable project root. Project
|
|
682
|
+
// identity is the canonical boundary; comparing raw cwd would reject a
|
|
683
|
+
// valid receipt before the CLI can run (Codex does this in particular).
|
|
684
|
+
const receipt = eventKey && context.db.get("SELECT provider_session_id, daemon_id, status, harness FROM hook_events WHERE event_key = ? AND harness = ? AND project_id = ?", [eventKey, input.harness ?? "zcode-acp", project.id]);
|
|
563
685
|
if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
|
|
564
686
|
throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
|
|
565
687
|
}
|
|
566
|
-
|
|
688
|
+
// The hook observes intent; it does not own execution. A native shell may
|
|
689
|
+
// reject a tool call before the CLI process exists (for example because
|
|
690
|
+
// its workdir is invalid), after which repeating the same issued command
|
|
691
|
+
// is safe. Keep the newest receipt until the CLI claims the attempt. The
|
|
692
|
+
// observed -> executing compare-and-swap remains the exactly-once boundary.
|
|
693
|
+
const updated = context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status IN ('issued', 'observed')", [identityJson, eventKey, context.now(), initial.id]);
|
|
567
694
|
context.db.exec("COMMIT");
|
|
568
|
-
|
|
695
|
+
// Once execution has been claimed, later PreToolUse observations are
|
|
696
|
+
// retained as hook evidence but cannot replace the executor's receipt.
|
|
697
|
+
return { eventKey: updated.changes === 1 ? eventKey : row.event_key, duplicate: updated.changes !== 1, ...resolved };
|
|
569
698
|
}
|
|
570
699
|
catch (error) {
|
|
571
700
|
context.db.exec("ROLLBACK");
|
|
572
701
|
throw error;
|
|
573
702
|
}
|
|
574
703
|
}
|
|
704
|
+
/** Resolve only a command that a native hook has already bound to an issued
|
|
705
|
+
* attempt. This is the CLI-side half of admission for model-facing commands
|
|
706
|
+
* that intentionally omit the internal UUID. */
|
|
707
|
+
export function observedLifecycleInvocation(context, command) {
|
|
708
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
709
|
+
return null;
|
|
710
|
+
const analysis = parseLifecycleCommand(command);
|
|
711
|
+
if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
|
|
712
|
+
return null;
|
|
713
|
+
const matches = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('observed','executing') ORDER BY rowid DESC")
|
|
714
|
+
.filter(row => {
|
|
715
|
+
try {
|
|
716
|
+
const scope = JSON.parse(row.scope_json);
|
|
717
|
+
return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
if (!matches.length)
|
|
724
|
+
return null;
|
|
725
|
+
if (matches.length !== 1)
|
|
726
|
+
throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
|
|
727
|
+
return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
|
|
728
|
+
}
|
|
575
729
|
/** One caller owns execution; a lost process leaves an explicit unknown
|
|
576
730
|
* outcome, never an automatic replay. Existing recovery decides the next step. */
|
|
577
731
|
export async function awaitLifecycleInvocation(context, input) {
|
|
578
732
|
const initial = load(context, input.id);
|
|
579
|
-
|
|
733
|
+
try {
|
|
734
|
+
if (initial.status === "settled") {
|
|
735
|
+
checkCommand(initial, input.command);
|
|
736
|
+
return { outcome: JSON.parse(initial.outcome_json), replay: true };
|
|
737
|
+
}
|
|
738
|
+
input.validateArgs?.();
|
|
739
|
+
checkCommand(initial, input.command);
|
|
740
|
+
// A terminal command is immutable evidence. Do not require a now-gone
|
|
741
|
+
// caller payload merely to replay its recorded result.
|
|
742
|
+
if (["issued", "observed"].includes(initial.status))
|
|
743
|
+
await input.prepare?.();
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
|
|
747
|
+
throw error;
|
|
748
|
+
const rejectionDetails = context.db.writeTransaction(() => {
|
|
749
|
+
const row = load(context, input.id);
|
|
750
|
+
if (row.status === "settled") {
|
|
751
|
+
const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
|
|
752
|
+
return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
|
|
753
|
+
}
|
|
754
|
+
if (!["issued", "observed"].includes(row.status)) {
|
|
755
|
+
return { effect: "unknown", recoverable: false };
|
|
756
|
+
}
|
|
757
|
+
const scope = JSON.parse(row.scope_json);
|
|
758
|
+
if (scope.runId)
|
|
759
|
+
assertLifecycleInvocationCurrent(context, scope, row.command);
|
|
760
|
+
// No executor has claimed this attempt. Atomically retain the rejected
|
|
761
|
+
// call and issue one corrected command; old IDs only replay the outcome.
|
|
762
|
+
const details = { ...error.details, effect: "no_effect", recoverable: true,
|
|
763
|
+
retry_command: successorLifecycleInvocationCommand(context, row),
|
|
764
|
+
retry_instruction: "Execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state." };
|
|
765
|
+
context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
|
|
766
|
+
return details;
|
|
767
|
+
});
|
|
768
|
+
Object.assign(error.details, rejectionDetails);
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
580
771
|
const timeoutMs = input.timeoutMs ?? 30000;
|
|
581
772
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
582
773
|
throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
|