@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.91
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 +467 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1421 -384
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- 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/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +57 -13
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +543 -96
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +132 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +750 -73
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +587 -299
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +103 -17
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1122 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +283 -27
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +73 -28
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +793 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +368 -74
- package/dist/services/runtime-budget.js +434 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +99 -102
- package/dist/services/stage-pause.js +84 -55
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +195 -88
- package/dist/services/vnext-code.js +285 -252
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +160 -17
- package/dist/services/vnext-merge.js +291 -104
- package/dist/services/vnext-plan-review.js +139 -81
- package/dist/services/vnext-plan.js +138 -69
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +88 -65
- package/dist/services/work-registry.js +716 -168
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +459 -32
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { quote } from "shell-quote";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
7
|
+
import { shortEntityReference } from "../shared/entity-references.js";
|
|
8
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
9
|
+
import { commandOption, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
10
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
11
|
+
import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
12
|
+
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
13
|
+
import { managedProcessStatus } from "./managed-processes.js";
|
|
14
|
+
import { appendEvent } from "./run-controller-state.js";
|
|
15
|
+
import { recoverCommittedWorkStart } from "./work-registry.js";
|
|
16
|
+
import { observeCommandHook } from "./hooks.js";
|
|
17
|
+
import { runtimeHarness } from "./execution-policy.js";
|
|
18
|
+
import { normalizedPathAlias, resolveContextualPaths } from "./command-context.js";
|
|
19
|
+
import { isRegisteredRepair } from "./repair-intents.js";
|
|
20
|
+
import { stageAttempt } from "./stage-work-graph.js";
|
|
21
|
+
import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
|
|
22
|
+
import { canonicalPath } from "../storage/paths.js";
|
|
23
|
+
/** Auxiliary commands have no invocation capability, but may not escape their
|
|
24
|
+
* native daemon's project/RUN. Context aliases never grant mutation authority. */
|
|
25
|
+
export function assertAuxiliaryCommandScope(context, command, projectRoot) {
|
|
26
|
+
const auxiliary = continuationCommand(command);
|
|
27
|
+
const daemonId = context.env.DD_FLOW_DAEMON_ID;
|
|
28
|
+
if (!auxiliary || !daemonId)
|
|
29
|
+
return;
|
|
30
|
+
const event = observedLifecycleNativeEvent(context, command, daemonId);
|
|
31
|
+
if (!event)
|
|
32
|
+
throw new AppError("invocation_receipt_missing", "Auxiliary command has no exact native receipt", 1, { phase: "prepare", effect: "no_effect" });
|
|
33
|
+
const project = context.db.get("SELECT root FROM projects WHERE id = ?", [event.project_id]);
|
|
34
|
+
if (!project || !projectRoot || canonicalPath(projectRoot, false) !== project.root)
|
|
35
|
+
throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native project scope", 1, { phase: "prepare", effect: "no_effect" });
|
|
36
|
+
for (const record of managedProcessStatus(context)) {
|
|
37
|
+
if (record.project_id !== event.project_id || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
38
|
+
continue;
|
|
39
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
40
|
+
if (typeof metadata.state_dir !== "string")
|
|
41
|
+
continue;
|
|
42
|
+
let state;
|
|
43
|
+
try {
|
|
44
|
+
state = readNativeDaemonState(metadata.state_dir);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (state.daemon_id !== daemonId)
|
|
50
|
+
continue;
|
|
51
|
+
requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: event.project_id, runId: record.run_id });
|
|
52
|
+
const reference = auxiliary.args.options.get("run")?.[0] ?? auxiliary.args.positional[2];
|
|
53
|
+
const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [event.project_id, reference, reference]);
|
|
54
|
+
if (run?.id !== record.run_id)
|
|
55
|
+
throw new AppError("invocation_scope_mismatch", "Auxiliary command differs from its native RUN scope", 1, { phase: "prepare", effect: "no_effect" });
|
|
56
|
+
assertRunMutationAllowed(context, event.project_id, record.run_id, metadata.run_generation);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw new AppError("invocation_scope_unavailable", "Auxiliary command has no live managed daemon binding", 1, { phase: "prepare", effect: "no_effect" });
|
|
60
|
+
}
|
|
61
|
+
// Conclusive result/check rejections, never transport, ownership, registry or
|
|
62
|
+
// unknown-effect failures. These validators reject before accepting the result.
|
|
63
|
+
export function isRetryableLifecycleRejection(code, details = {}) {
|
|
64
|
+
if (code.startsWith("native_hook_") || code === "invocation_receipt_timeout")
|
|
65
|
+
return false;
|
|
66
|
+
if (code === "fresh_session_required" || details.recoverable === false || details.handoff_required === true || details.effect === "unknown" || details.effect === "committed")
|
|
67
|
+
return false;
|
|
68
|
+
if (code === "validation" || code === "schema_validation")
|
|
69
|
+
return details.phase === "prepare" && details.effect === "no_effect" && details.recoverable === true;
|
|
70
|
+
if (["invocation_command_mismatch", "invocation_argument_mismatch"].includes(code))
|
|
71
|
+
return details.recoverable !== false;
|
|
72
|
+
return details.recoverable === true || ["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed",
|
|
73
|
+
"review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
74
|
+
"review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
|
|
75
|
+
"evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
|
|
76
|
+
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
77
|
+
|| (code === "invalid_work_state" && details.retryable_no_effect === true);
|
|
78
|
+
}
|
|
79
|
+
/** A correctable rejection either publishes an exact replacement command or
|
|
80
|
+
* proves that the controller must hand the existing Work to another Session.
|
|
81
|
+
* The latter is deliberately not retryable in the caller that just failed. */
|
|
82
|
+
export function isCorrectableLifecycleRejection(code, details = {}) {
|
|
83
|
+
if (details.recoverable === false || details.effect === "unknown" || details.effect === "committed")
|
|
84
|
+
return false;
|
|
85
|
+
return isRetryableLifecycleRejection(code, details)
|
|
86
|
+
|| (code === "fresh_session_required" && details.handoff_required === true
|
|
87
|
+
&& details.recoverable === true);
|
|
88
|
+
}
|
|
89
|
+
function storage(context) {
|
|
90
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
|
|
91
|
+
throw new AppError("invocation_storage_unprepared", "Lifecycle invocation storage is not prepared; start dd-flow normally before issuing native work", 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function invocation(command) {
|
|
95
|
+
const parsed = parseLifecycleCommand(command);
|
|
96
|
+
if (parsed.kind !== "standalone" || parsed.invocation.wrapped) {
|
|
97
|
+
throw new AppError("invocation_command_invalid", "A lifecycle attempt requires one direct standalone command", 1);
|
|
98
|
+
}
|
|
99
|
+
return parsed.invocation;
|
|
100
|
+
}
|
|
101
|
+
// Input data may be supplied later (stdin/file contents); authority is bound to
|
|
102
|
+
// the exact operation and literal argv. Existing lifecycle validation owns data.
|
|
103
|
+
function fingerprint(parsed) {
|
|
104
|
+
const projected = publicInvocationArgs(parsed);
|
|
105
|
+
const publicParsed = invocation(`${quote([parsed.executable])} ${quoteModelFacingArgs(projected)}`);
|
|
106
|
+
const options = [...publicParsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl"].includes(key))
|
|
107
|
+
.map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
|
|
108
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
109
|
+
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, publicParsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
|
|
110
|
+
}
|
|
111
|
+
const runtimeOwnedOptions = {
|
|
112
|
+
stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
|
|
113
|
+
stage_finish: new Set(["project-root", "result-file", "decision-file", "verification-file"]),
|
|
114
|
+
stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root", "answer-file"]),
|
|
115
|
+
work_start: new Set(["project-root", "recovery-id"]),
|
|
116
|
+
work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
|
|
117
|
+
merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
|
|
118
|
+
recovery_accept: new Set(["project-root"])
|
|
119
|
+
};
|
|
120
|
+
function publicInvocationArgs(parsed) {
|
|
121
|
+
const hidden = runtimeOwnedOptions[parsed.operation] ?? new Set();
|
|
122
|
+
const args = [];
|
|
123
|
+
for (let index = 0; index < parsed.argv.length; index += 1) {
|
|
124
|
+
const value = parsed.argv[index];
|
|
125
|
+
if (!value.startsWith("--")) {
|
|
126
|
+
args.push(value);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const key = value.slice(2).split("=", 1)[0];
|
|
130
|
+
const inline = value.includes("=");
|
|
131
|
+
const next = parsed.argv[index + 1];
|
|
132
|
+
if (hidden.has(key) || key === "invocation-id" || key === "hook-event-id") {
|
|
133
|
+
if (!inline && next && !next.startsWith("--"))
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
args.push(value);
|
|
138
|
+
if (!inline && next && !next.startsWith("--"))
|
|
139
|
+
args.push(parsed.argv[++index]);
|
|
140
|
+
}
|
|
141
|
+
// A managed coordinator Session is already bound to its RUN. Work IDs stay
|
|
142
|
+
// public because sibling Work assignments can coexist under one native root.
|
|
143
|
+
const operationOffset = parsed.operation === "recovery_accept" ? 3 : 2;
|
|
144
|
+
if (["stage_start", "stage_finish", "recovery_accept"].includes(parsed.operation)
|
|
145
|
+
&& !parsed.args.options.has("bootstrap") && args[operationOffset] && !args[operationOffset].startsWith("--"))
|
|
146
|
+
args.splice(operationOffset, 1);
|
|
147
|
+
return args;
|
|
148
|
+
}
|
|
149
|
+
function normalizedInvocationValue(key, value) {
|
|
150
|
+
value = normalizedPathAlias(value);
|
|
151
|
+
if (key === "project-root" && (path.isAbsolute(value) || value === "@project"))
|
|
152
|
+
return "@project";
|
|
153
|
+
if (["result-file", "decision-file", "verification-file"].includes(key))
|
|
154
|
+
return "<validated-input-path>";
|
|
155
|
+
if (value.startsWith("@project") || value.startsWith("@workspace") || value.startsWith("@run"))
|
|
156
|
+
return value.replace(/\\/g, "/");
|
|
157
|
+
if (["result-file", "decision-file", "verification-file", "context-file"].includes(key) && path.isAbsolute(value)) {
|
|
158
|
+
const runRelative = value.replace(/\\/g, "/").match(/\/(?:runs\/)?RUN-[^/]+\/(.+)$/)?.[1];
|
|
159
|
+
if (runRelative)
|
|
160
|
+
return `@run/${runRelative}`;
|
|
161
|
+
}
|
|
162
|
+
if (["positional", "work", "run"].includes(key))
|
|
163
|
+
return shortEntityReference(value);
|
|
164
|
+
return key === "retry-check" ? /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value : value;
|
|
165
|
+
}
|
|
166
|
+
/** Shell/JSON builders sometimes preserve the shell escape before an alias.
|
|
167
|
+
* Accept it only for the small declared alias vocabulary. */
|
|
168
|
+
function quoteModelFacingArgs(argv) {
|
|
169
|
+
return argv.map(value => /^@(project|workspace|run)(?:\/.*)?$/.test(value)
|
|
170
|
+
? `'${value.replaceAll("'", `'\\''`)}'`
|
|
171
|
+
: quote([value])).join(" ");
|
|
172
|
+
}
|
|
173
|
+
function withoutInvocationId(command, aliases) {
|
|
174
|
+
const parsed = invocation(command);
|
|
175
|
+
const argv = publicInvocationArgs(parsed);
|
|
176
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
177
|
+
const option = argv[index - 1]?.replace(/^--/, "");
|
|
178
|
+
if (option === "project-root")
|
|
179
|
+
argv[index] = "@project";
|
|
180
|
+
else if (option === "work" || option === "run")
|
|
181
|
+
argv[index] = shortEntityReference(argv[index]);
|
|
182
|
+
else if (option && aliases && ["result-file", "decision-file", "verification-file", "context-file"].includes(option) && path.isAbsolute(argv[index])) {
|
|
183
|
+
for (const [name, root] of [["run", aliases.run], ["workspace", aliases.workspace], ["project", aliases.project]]) {
|
|
184
|
+
if (!root)
|
|
185
|
+
continue;
|
|
186
|
+
const relative = path.relative(root, argv[index]);
|
|
187
|
+
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
|
188
|
+
argv[index] = `@${name}${relative ? `/${relative.replaceAll(path.sep, "/")}` : ""}`;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (!argv[index - 1]?.startsWith("--") && !argv[index].startsWith("--"))
|
|
194
|
+
argv[index] = normalizedInvocationValue("positional", argv[index]);
|
|
195
|
+
}
|
|
196
|
+
const env = Object.fromEntries(Object.entries(parsed.env).filter(([key]) => !["DD_FLOW_INVOCATION_SCOPE", "DD_FLOW_HOME", "DD_FLOW_BIN"].includes(key)));
|
|
197
|
+
const executable = parsed.env.DD_FLOW_BIN || parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
|
|
198
|
+
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
199
|
+
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
200
|
+
return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
201
|
+
}
|
|
202
|
+
function publicInvocationCommand(context, row, command) {
|
|
203
|
+
if (!commandOption(invocation(command), "project-root"))
|
|
204
|
+
command = retainedCommandWithoutInvocationId(row);
|
|
205
|
+
const rendered = renderInvocationCommand(row, command);
|
|
206
|
+
const scope = JSON.parse(row.scope_json);
|
|
207
|
+
const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
208
|
+
return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
|
|
209
|
+
}
|
|
210
|
+
function retainedCommandWithoutInvocationId(row) {
|
|
211
|
+
const marker = ` --invocation-id ${row.id}`;
|
|
212
|
+
const at = row.command.indexOf(marker);
|
|
213
|
+
if (at < 0 || row.command.indexOf(marker, at + marker.length) >= 0)
|
|
214
|
+
throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
|
|
215
|
+
return `${row.command.slice(0, at)}${row.command.slice(at + marker.length)}`;
|
|
216
|
+
}
|
|
217
|
+
/** Resolve the small, declared alias vocabulary used by model-facing lifecycle commands.
|
|
218
|
+
* Both native-hook admission and CLI execution call this function so they cannot
|
|
219
|
+
* disagree about the command that an alias denotes. */
|
|
220
|
+
export function expandLifecycleInvocationArgs(context, args, scope, operation) {
|
|
221
|
+
const pathOptions = contextualPathOptionsForLifecycle(operation);
|
|
222
|
+
const run = scope.runId ? context.db.get("SELECT id, short_id, workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
|
|
223
|
+
const roots = { project: scope.projectRoot, workspace: run?.workspace_root, run: run?.run_root };
|
|
224
|
+
return resolveContextualPaths(args.map((value, index) => {
|
|
225
|
+
if (run && !args[index - 1]?.startsWith("--") && [run.short_id, normalizedInvocationValue("positional", run.id)].includes(value) && !operation.startsWith("work_"))
|
|
226
|
+
return run.id;
|
|
227
|
+
return value;
|
|
228
|
+
}), pathOptions, roots);
|
|
229
|
+
}
|
|
230
|
+
/** Validate alias spelling before native-receipt lookup, so a correctable
|
|
231
|
+
* command typo is never reported as missing infrastructure evidence. */
|
|
232
|
+
export function validateLifecyclePathAliases(args, operation) {
|
|
233
|
+
const pathOptions = contextualPathOptionsForLifecycle(operation);
|
|
234
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
235
|
+
const option = args[index - 1]?.replace(/^--/, "");
|
|
236
|
+
if (!option || !pathOptions.has(option))
|
|
237
|
+
continue;
|
|
238
|
+
const original = args[index];
|
|
239
|
+
const value = normalizedPathAlias(original);
|
|
240
|
+
if ((original.startsWith("\\") && original.replace(/^\\+/, "").startsWith("@") && value === original)
|
|
241
|
+
|| (value.startsWith("@") && !/^@(project|workspace|run)(?:\/.*)?$/.test(value))) {
|
|
242
|
+
throw new AppError("usage", `Unknown path alias: ${original}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function canonicalManagedCommand(context, command, scope) {
|
|
247
|
+
const parsed = invocation(command);
|
|
248
|
+
const argv = expandLifecycleInvocationArgs(context, parsed.argv, scope, parsed.operation);
|
|
249
|
+
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
250
|
+
const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
|
|
251
|
+
const executable = parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
|
|
252
|
+
const suffix = command.slice(shellSuffixOffset(command)).trimStart();
|
|
253
|
+
return `${prefix} ${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
|
|
254
|
+
}
|
|
255
|
+
function load(context, id) {
|
|
256
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
|
|
257
|
+
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" });
|
|
258
|
+
}
|
|
259
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
260
|
+
if (!row)
|
|
261
|
+
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" });
|
|
262
|
+
return row;
|
|
263
|
+
}
|
|
264
|
+
export function lifecycleInvocationScope(context, id) {
|
|
265
|
+
return JSON.parse(load(context, id).scope_json);
|
|
266
|
+
}
|
|
267
|
+
/** Read-only controller observation of a retained lifecycle outcome. */
|
|
268
|
+
export function lifecycleInvocationOutcome(context, id) {
|
|
269
|
+
const row = load(context, id);
|
|
270
|
+
return { status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null };
|
|
271
|
+
}
|
|
272
|
+
/** Current failures are read from runtime authority, never from model text. */
|
|
273
|
+
export function assertLifecycleOutcomes(context, scope) {
|
|
274
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
275
|
+
const diagnostics = lifecycleDiagnostics(context, project.id, scope.runId, scope.generation);
|
|
276
|
+
if (fs.existsSync(diagnostics)) {
|
|
277
|
+
for (const name of fs.readdirSync(diagnostics).filter(name => name.endsWith(".json"))) {
|
|
278
|
+
const record = JSON.parse(fs.readFileSync(path.join(diagnostics, name), "utf8"));
|
|
279
|
+
if (record.project_id !== project.id || record.run_id !== scope.runId || record.generation !== scope.generation)
|
|
280
|
+
throw new AppError("lifecycle_outcome_conflict", "Lifecycle diagnostic scope differs", 1);
|
|
281
|
+
throw new AppError(record.error?.code ?? "lifecycle_outcome_unknown", record.error?.message ?? "Lifecycle settlement could not be persisted", 1, { ...record.error?.details, lifecycle_outcome: record, diagnostic_artifact: path.join(diagnostics, name) });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const columns = context.db.all("PRAGMA table_info(hook_events)");
|
|
285
|
+
if (columns.some(column => column.name === "outcome_json")) {
|
|
286
|
+
for (const row of context.db.all("SELECT outcome_json FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ? ORDER BY id", [project.id, scope.runId, scope.generation])) {
|
|
287
|
+
const outcome = JSON.parse(row.outcome_json);
|
|
288
|
+
if (outcome.disposition === "retrying" && outcome.deadline <= Date.now())
|
|
289
|
+
throw new AppError("lifecycle_outcome_unknown", "Storage retry has no terminal receipt; reconcile its committed evidence", 1, { lifecycle_outcome: outcome, effect: "unknown" });
|
|
290
|
+
if (outcome.disposition === "fatal" && !isRegisteredRepair(context, outcome.error?.details?.continuation, { projectId: project.id, runId: scope.runId }))
|
|
291
|
+
throw new AppError(outcome.error?.code ?? "lifecycle_outcome_unknown", outcome.error?.message || "Mandatory lifecycle failed (historical receipt has no message)", 1, { ...outcome.error?.details, lifecycle_outcome: outcome });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
295
|
+
return;
|
|
296
|
+
const rows = context.db.all(`SELECT a.* FROM lifecycle_invocations a
|
|
297
|
+
WHERE json_extract(a.scope_json, '$.projectRoot') = ? AND json_extract(a.scope_json, '$.runId') = ?
|
|
298
|
+
AND json_extract(a.scope_json, '$.generation') = ? AND a.status = 'settled'
|
|
299
|
+
AND NOT EXISTS (SELECT 1 FROM lifecycle_invocations newer WHERE newer.scope_json = a.scope_json AND newer.fingerprint = a.fingerprint AND newer.rowid > a.rowid)
|
|
300
|
+
ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
|
|
301
|
+
for (const row of rows) {
|
|
302
|
+
const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
|
|
303
|
+
if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details) && !isRegisteredRepair(context, error.details?.continuation, { projectId: project.id, runId: scope.runId }))
|
|
304
|
+
throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** Hooks already own native identity. Retain command outcomes there, with an
|
|
308
|
+
* atomic controller notification; missing notifications reconcile from the row. */
|
|
309
|
+
export function observeLifecycleCommand(context, command, nativeEvent) {
|
|
310
|
+
const parsed = parseLifecycleCommand(command);
|
|
311
|
+
const auxiliary = continuationCommand(command);
|
|
312
|
+
if (parsed.kind !== "standalone" && !auxiliary)
|
|
313
|
+
return () => { };
|
|
314
|
+
let anchor;
|
|
315
|
+
context.observeLifecycleHook = (projectId, eventId) => {
|
|
316
|
+
if (anchor)
|
|
317
|
+
return;
|
|
318
|
+
const event = context.db.get("SELECT session_id, provider_session_id, parent_session_id, daemon_id, harness FROM hook_events WHERE id = ? AND project_id = ?", [eventId, projectId]);
|
|
319
|
+
if (!event)
|
|
320
|
+
return;
|
|
321
|
+
let runId = context.db.get("SELECT run_id FROM sessions WHERE project_id = ? AND harness = ? AND (session_id = ? OR session_id = ? OR provider_session_id = ?) AND run_id IS NOT NULL ORDER BY session_id = ? DESC LIMIT 1", [projectId, event.harness, event.session_id, event.parent_session_id, event.parent_session_id, event.session_id])?.run_id;
|
|
322
|
+
let generation;
|
|
323
|
+
let correlation = {};
|
|
324
|
+
// A first Stage command precedes logical Session binding. Its native root
|
|
325
|
+
// is already owned by the controller's retained create receipt.
|
|
326
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")) {
|
|
327
|
+
const owners = context.db.all("SELECT run_id, generation, state_json FROM run_controllers WHERE project_id = ? AND status NOT IN ('completed','cancelled','superseded')", [projectId]).filter(owner => {
|
|
328
|
+
const state = JSON.parse(owner.state_json);
|
|
329
|
+
return state.sessions?.some(session => [event.provider_session_id ?? event.session_id, event.parent_session_id].includes(session.id) && runtimeHarness(session.profile) === event.harness);
|
|
330
|
+
});
|
|
331
|
+
if (owners.length > 1)
|
|
332
|
+
return;
|
|
333
|
+
if (owners.length === 1) {
|
|
334
|
+
if (runId && runId !== owners[0].run_id)
|
|
335
|
+
return;
|
|
336
|
+
runId = owners[0].run_id;
|
|
337
|
+
generation = owners[0].generation;
|
|
338
|
+
const current = JSON.parse(owners[0].state_json);
|
|
339
|
+
correlation = { stage: current.stage, ...(current.stage ? { attempt: stageAttempt(context, projectId, runId, current.stage) } : {}), controller_turn: current.turns };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (event.daemon_id) {
|
|
343
|
+
for (const record of managedProcessStatus(context)) {
|
|
344
|
+
if (record.project_id !== projectId || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
345
|
+
continue;
|
|
346
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
347
|
+
if (typeof metadata.state_dir !== "string")
|
|
348
|
+
continue;
|
|
349
|
+
let daemonId;
|
|
350
|
+
try {
|
|
351
|
+
daemonId = readNativeDaemonState(metadata.state_dir).daemon_id;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
continue;
|
|
355
|
+
} // Discovery of unrelated historical daemons is not authority.
|
|
356
|
+
if (daemonId !== event.daemon_id)
|
|
357
|
+
continue;
|
|
358
|
+
const binding = requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId, runId: record.run_id });
|
|
359
|
+
if (binding.state.daemon_id !== event.daemon_id)
|
|
360
|
+
continue;
|
|
361
|
+
runId = record.run_id;
|
|
362
|
+
generation = metadata.run_generation;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (!runId)
|
|
367
|
+
return;
|
|
368
|
+
anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.kind === "standalone" ? parsed.invocation.operation : auxiliary.operation, ...correlation, ...(auxiliary ? { auxiliary: true } : {}) };
|
|
369
|
+
};
|
|
370
|
+
if (nativeEvent)
|
|
371
|
+
context.observeLifecycleHook(nativeEvent.project_id, nativeEvent.id);
|
|
372
|
+
else
|
|
373
|
+
observeCommandHook(context, command);
|
|
374
|
+
const invocationId = parsed.kind === "standalone" ? commandOption(parsed.invocation, "invocation-id") : undefined;
|
|
375
|
+
if (!anchor && invocationId && !nativeEvent) {
|
|
376
|
+
const attempt = load(context, invocationId);
|
|
377
|
+
checkCommand(attempt, command);
|
|
378
|
+
if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
|
|
379
|
+
const scope = JSON.parse(attempt.scope_json);
|
|
380
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
381
|
+
const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, attempt.event_key]);
|
|
382
|
+
if (event)
|
|
383
|
+
context.observeLifecycleHook(project.id, event.id);
|
|
384
|
+
}
|
|
385
|
+
if (!anchor)
|
|
386
|
+
throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { invocation_id: invocationId, effect: "no_effect" });
|
|
387
|
+
}
|
|
388
|
+
return outcome => {
|
|
389
|
+
delete context.observeLifecycleHook;
|
|
390
|
+
if (!anchor)
|
|
391
|
+
return;
|
|
392
|
+
const error = outcome.error ? errorRecord(outcome.error) : undefined;
|
|
393
|
+
const correctable = error && isCorrectableLifecycleRejection(error.code, error.details);
|
|
394
|
+
const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? auxiliary ? "pending_continuation" : correctable ? "correctable" : "fatal" : "completed" };
|
|
395
|
+
try {
|
|
396
|
+
context.db.writeTransaction(() => {
|
|
397
|
+
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
|
|
398
|
+
const previous = retained?.outcome_json ? JSON.parse(retained.outcome_json) : null;
|
|
399
|
+
if (previous?.storage_retry === 1)
|
|
400
|
+
Object.assign(record, { storage_retry: 1, work_id: previous.work_id });
|
|
401
|
+
const encoded = JSON.stringify(record);
|
|
402
|
+
if (retained?.outcome_json && previous?.disposition !== "retrying") {
|
|
403
|
+
if (retained.outcome_json !== encoded)
|
|
404
|
+
throw new AppError("lifecycle_outcome_conflict", "Hook outcome cannot change", 1, { hook_event_id: anchor.hook_event_id });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
context.db.run("UPDATE hook_events SET outcome_json = ? WHERE id = ?", [encoded, anchor.hook_event_id]);
|
|
408
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'"))
|
|
409
|
+
for (const controller of context.db.all("SELECT controller_id FROM run_controllers WHERE project_id = ? AND run_id = ? AND generation = ?", [anchor.project_id, anchor.run_id, anchor.generation]))
|
|
410
|
+
appendEvent(context, controller.controller_id, "lifecycle_outcome", record);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
catch (persistence) {
|
|
414
|
+
if (persistence instanceof AppError && persistence.code === "lifecycle_outcome_conflict")
|
|
415
|
+
throw persistence;
|
|
416
|
+
// Diagnostic evidence is not replay authority. If SQL is unavailable,
|
|
417
|
+
// the controller still sees this failure and fences instead of waiting
|
|
418
|
+
// for a model to report it or invent a new invocation.
|
|
419
|
+
const directory = lifecycleDiagnostics(context, anchor.project_id, anchor.run_id, anchor.generation);
|
|
420
|
+
try {
|
|
421
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
422
|
+
fs.writeFileSync(path.join(directory, `${anchor.hook_event_id}.json`), JSON.stringify({ ...record, disposition: "fatal", persistence_error: String(persistence) }), { flag: "wx", mode: 0o600, flush: true });
|
|
423
|
+
}
|
|
424
|
+
catch (diagnostic) {
|
|
425
|
+
if (diagnostic.code !== "EEXIST")
|
|
426
|
+
process.stderr.write(`${JSON.stringify({ primary: record.error ?? { effect: "committed" }, persistence: String(persistence), diagnostic: String(diagnostic) })}\n`);
|
|
427
|
+
}
|
|
428
|
+
throw persistence;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function lifecycleDiagnostics(context, projectId, runId, generation) {
|
|
433
|
+
return path.join(context.ddFlowHome, "diagnostics", "lifecycle", encodeURIComponent(projectId), encodeURIComponent(runId), String(generation));
|
|
434
|
+
}
|
|
435
|
+
/** Current, explicitly-issued replacements for commands from the resumed stage.
|
|
436
|
+
* Original prompts remain immutable evidence; callers may present this small
|
|
437
|
+
* overlay after a state transition made an earlier no-effect rejection retryable. */
|
|
438
|
+
export function lifecycleRetryCommands(context, stage) {
|
|
439
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
440
|
+
if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
441
|
+
return [];
|
|
442
|
+
return [...new Set(context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
|
|
443
|
+
.flatMap((row) => {
|
|
444
|
+
try {
|
|
445
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
446
|
+
const retry = outcome?.error?.details?.retry_command;
|
|
447
|
+
const parsed = invocation(row.command);
|
|
448
|
+
if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
|
|
449
|
+
return [];
|
|
450
|
+
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]);
|
|
451
|
+
return latest?.status === "issued" && (latest.deadline === null || latest.deadline > Date.now()) ? [retry] : [];
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
}))];
|
|
457
|
+
}
|
|
458
|
+
export function managedInvocationContext(context, input) {
|
|
459
|
+
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
460
|
+
if (input.harness !== "zcode-acp" && input.harness !== "codex-desktop")
|
|
461
|
+
return { ...context, env };
|
|
462
|
+
const project = requireProjectByRoot(context, input.projectRoot);
|
|
463
|
+
const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
|
|
464
|
+
if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
|
|
465
|
+
throw new AppError("invocation_generation_stale", "Managed daemon belongs to another RUN generation", 1);
|
|
466
|
+
const session = state.sessions?.find(session => typeof session === "string" ? session === input.sessionId : session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
|
|
467
|
+
// Codex owns a single root Session per controller and persists that root as
|
|
468
|
+
// a string. Child agent_id values arrive later through the native hook and
|
|
469
|
+
// are checked against this root scope; they are not controller Sessions.
|
|
470
|
+
const rootSessionId = input.harness === "codex-desktop"
|
|
471
|
+
? (typeof session === "string" ? session : session?.root_provider_session_id ?? session?.provider_session_id ?? null)
|
|
472
|
+
: (typeof session === "string" ? null : session?.root_provider_session_id ?? null);
|
|
473
|
+
if (!state.daemon_id || !rootSessionId)
|
|
474
|
+
throw new AppError("invocation_scope_unproven", "Managed native Session has no persisted native root binding", 1);
|
|
475
|
+
const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId, runId: input.runId, generation: input.generation };
|
|
476
|
+
env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
|
|
477
|
+
return { ...context, env };
|
|
478
|
+
}
|
|
479
|
+
/** Generated commands retain their attempt across repeated rendering. Execution
|
|
480
|
+
* can issue a retry; accepted HITL intent can replace an unobserved placeholder
|
|
481
|
+
* or issue the next pause's assignment after successful settlement. */
|
|
482
|
+
export function managedLifecycleCommand(context, command, options = {}) {
|
|
483
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
484
|
+
if (!configured)
|
|
485
|
+
return command;
|
|
486
|
+
const scope = JSON.parse(configured);
|
|
487
|
+
command = canonicalManagedCommand(context, command, scope);
|
|
488
|
+
const parsed = invocation(command);
|
|
489
|
+
const suppliedId = commandOption(parsed, "invocation-id");
|
|
490
|
+
// An invocation id is authority, never decorative text. In particular,
|
|
491
|
+
// callers that add arguments after rendering must not be able to reuse an
|
|
492
|
+
// issued id for a different command.
|
|
493
|
+
if (suppliedId) {
|
|
494
|
+
const retained = load(context, suppliedId);
|
|
495
|
+
if (retained.scope_json !== JSON.stringify(scope)) {
|
|
496
|
+
throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
|
|
497
|
+
}
|
|
498
|
+
checkCommand(retained, command);
|
|
499
|
+
assertRenderableInvocation(retained);
|
|
500
|
+
return publicInvocationCommand(context, retained, command);
|
|
501
|
+
}
|
|
502
|
+
if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
|
|
503
|
+
const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
|
|
504
|
+
? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
|
|
505
|
+
if (!retained)
|
|
506
|
+
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);
|
|
507
|
+
return publicInvocationCommand(context, retained, command);
|
|
508
|
+
}
|
|
509
|
+
storage(context);
|
|
510
|
+
return context.db.writeTransaction(() => {
|
|
511
|
+
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)]);
|
|
512
|
+
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
513
|
+
const replacesAnswer = options.replaceUnobservedAnswer && parsed.operation === "stage_resume"
|
|
514
|
+
&& (prior.status === "settled" || commandOption(invocation(prior.command), "answer-file") !== commandOption(parsed, "answer-file"));
|
|
515
|
+
if (!replacesAnswer)
|
|
516
|
+
return publicInvocationCommand(context, prior, command);
|
|
517
|
+
// The pause packet can prepare a placeholder before the controller has
|
|
518
|
+
// accepted an answer. Replace only an unobserved assignment; never change
|
|
519
|
+
// the authority of a native call already in flight.
|
|
520
|
+
const completed = prior.status === "settled" && prior.outcome_json && !JSON.parse(prior.outcome_json).error;
|
|
521
|
+
if (prior.status !== "issued" && !completed)
|
|
522
|
+
throw new AppError("invocation_answer_conflict", "Cannot replace an answer while its lifecycle assignment is in flight or failed", 1, { invocation_id: prior.id });
|
|
523
|
+
if (prior.status === "issued")
|
|
524
|
+
context.db.run("UPDATE lifecycle_invocations SET status = 'expired' WHERE id = ?", [prior.id]);
|
|
525
|
+
}
|
|
526
|
+
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
527
|
+
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
528
|
+
const issued = issueLifecycleInvocation(context, command, scope);
|
|
529
|
+
return publicInvocationCommand(context, load(context, issued.id), command);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
export function retryLifecycleInvocationCommand(context, id) {
|
|
533
|
+
const prior = load(context, id);
|
|
534
|
+
if (prior.status !== "executing")
|
|
535
|
+
throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
|
|
536
|
+
return successorLifecycleInvocationCommand(context, prior);
|
|
537
|
+
}
|
|
538
|
+
/** A predecessor may publish exactly one successor only while the caller has
|
|
539
|
+
* proved that no lifecycle effect was admitted. Shared by CLI argument
|
|
540
|
+
* validation and conclusive lifecycle/result rejection, never by hooks. */
|
|
541
|
+
function successorLifecycleInvocationCommand(context, prior) {
|
|
542
|
+
const parsed = invocation(prior.command);
|
|
543
|
+
const argv = [...parsed.argv];
|
|
544
|
+
const at = argv.indexOf("--invocation-id");
|
|
545
|
+
if (at < 0 || argv[at + 1] !== prior.id)
|
|
546
|
+
throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
|
|
547
|
+
// The parser proves the marker belongs to argv. Remove that one rendered
|
|
548
|
+
// token from the retained text so response-file/stdin/heredoc presentation
|
|
549
|
+
// remains byte-for-byte stable across a retry.
|
|
550
|
+
const command = retainedCommandWithoutInvocationId(prior);
|
|
551
|
+
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
|
|
552
|
+
}
|
|
553
|
+
// Caller holds the write transaction and has checked authority. Issued (or
|
|
554
|
+
// legacy expired) attempts have never admitted an effect; observed/executing
|
|
555
|
+
// attempts must never receive an automatic replacement.
|
|
556
|
+
function settleUnadmittedInvocation(context, prior, error) {
|
|
557
|
+
if (!["issued", "expired"].includes(prior.status))
|
|
558
|
+
throw new AppError("invocation_rejection_conflict", "Attempt already admitted native execution", 1);
|
|
559
|
+
const details = { ...error.details, effect: "no_effect", recoverable: false };
|
|
560
|
+
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) {
|
|
561
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle rejection could not be committed", 1, { invocation_id: prior.id });
|
|
562
|
+
}
|
|
563
|
+
return details;
|
|
564
|
+
}
|
|
565
|
+
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 } });
|
|
566
|
+
/** Settle a conclusive rejection and publish its only legal successor atomically.
|
|
567
|
+
* A crash must leave either the old attempt executing, or both the settled
|
|
568
|
+
* outcome and its successor; never a usable successor beside an unknown effect. */
|
|
569
|
+
export function settleLifecycleRejection(context, id, error) {
|
|
570
|
+
storage(context);
|
|
571
|
+
const details = { ...error.details };
|
|
572
|
+
context.db.beginWriteTransaction();
|
|
573
|
+
try {
|
|
574
|
+
const prior = load(context, id);
|
|
575
|
+
if (prior.status !== "executing")
|
|
576
|
+
throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
|
|
577
|
+
const storageRetry = error.code === "storage_write_failed" && error.details.effect === "no_effect"
|
|
578
|
+
&& !context.db.get("SELECT 1 FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND json_extract(outcome_json, '$.error.details.storage_retry') = 1 LIMIT 1", [prior.scope_json, prior.fingerprint]);
|
|
579
|
+
if (isRetryableLifecycleRejection(error.code, error.details) || storageRetry) {
|
|
580
|
+
const retry = retryLifecycleInvocationCommand(context, id);
|
|
581
|
+
details.retry_command = retry;
|
|
582
|
+
details.recoverable = true;
|
|
583
|
+
if (storageRetry)
|
|
584
|
+
details.storage_retry = 1;
|
|
585
|
+
details.retry_instruction = isRegisteredRepair(context, details.continuation)
|
|
586
|
+
? "The repair is already registered. End this coordinator turn so the controller can launch its fresh child. Use retry_command only after that repair completes, with fresh semantic verification."
|
|
587
|
+
: "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
588
|
+
}
|
|
589
|
+
else if (isCorrectableLifecycleRejection(error.code, error.details)) {
|
|
590
|
+
details.effect = "no_effect";
|
|
591
|
+
details.recoverable = true;
|
|
592
|
+
details.handoff_required = true;
|
|
593
|
+
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.";
|
|
594
|
+
}
|
|
595
|
+
settleLifecycleInvocation(context, id, { error: { ...error, details } });
|
|
596
|
+
context.db.exec("COMMIT");
|
|
597
|
+
Object.assign(error.details, details);
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
context.db.exec("ROLLBACK");
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** Recovery preserves the original packet as evidence. Only its retained,
|
|
605
|
+
* never-executed commands receive new authority; unknown effects never replay. */
|
|
606
|
+
export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
607
|
+
const ids = [...new Set([...originalPrompt.matchAll(/--invocation-id ([0-9a-f-]{36})\b/g)].map(match => match[1]))];
|
|
608
|
+
const recovered = [];
|
|
609
|
+
for (const id of ids) {
|
|
610
|
+
let prior = load(context, id);
|
|
611
|
+
const oldScope = JSON.parse(prior.scope_json);
|
|
612
|
+
if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
|
|
613
|
+
|| oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
|
|
614
|
+
throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
|
|
615
|
+
}
|
|
616
|
+
// The immutable prompt can predate several recoveries. Follow the newest
|
|
617
|
+
// retained attempt, including an unknown successor, rather than reviving
|
|
618
|
+
// an older unexecuted command after the successor already produced effects.
|
|
619
|
+
prior = context.db.get(`SELECT * FROM lifecycle_invocations WHERE fingerprint = ?
|
|
620
|
+
AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') IS ?
|
|
621
|
+
AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ?
|
|
622
|
+
ORDER BY json_extract(scope_json, '$.generation') DESC, rowid DESC LIMIT 1`, [prior.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation]);
|
|
623
|
+
if (prior.status === "executing" || prior.status === "settled") {
|
|
624
|
+
const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
|
|
625
|
+
recovered.push({ invocation_id: id, status: prior.status, outcome,
|
|
626
|
+
instruction: prior.status === "executing" ? "Outcome unknown: reconcile retained operation evidence; do not reissue this command." : "Read the retained outcome; do not repeat completed effects." });
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const parsed = invocation(prior.command);
|
|
630
|
+
const argv = [...parsed.argv];
|
|
631
|
+
const at = argv.indexOf("--invocation-id");
|
|
632
|
+
if (at < 0 || argv[at + 1] !== prior.id)
|
|
633
|
+
throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
|
|
634
|
+
argv.splice(at, 2);
|
|
635
|
+
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
636
|
+
const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${quote([parsed.executable, ...argv])} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
|
|
637
|
+
const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
|
|
638
|
+
recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
|
|
639
|
+
}
|
|
640
|
+
return recovered;
|
|
641
|
+
}
|
|
642
|
+
export function assertLifecycleInvocationCurrent(context, scope, command) {
|
|
643
|
+
const parsed = invocation(command);
|
|
644
|
+
if (!scope.runId) {
|
|
645
|
+
if ((parsed.operation === "stage_start" && parsed.args.options.has("bootstrap")) || parsed.operation === "session_register")
|
|
646
|
+
return;
|
|
647
|
+
throw new AppError("invocation_scope_invalid", "This lifecycle operation requires an issued RUN scope", 1);
|
|
648
|
+
}
|
|
649
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
650
|
+
let workId;
|
|
651
|
+
if (parsed.operation.startsWith("work_")) {
|
|
652
|
+
const target = parsed.args.positional[0] ?? "";
|
|
653
|
+
try {
|
|
654
|
+
workId = resolveWorkReference(context, target, { projectId: project.id, runId: scope.runId }).work_id;
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
|
|
658
|
+
throw error;
|
|
659
|
+
throw new AppError("invocation_scope_mismatch", "Work does not belong unambiguously to the issued RUN scope", 1);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
else if (parsed.operation.startsWith("merge_")) {
|
|
663
|
+
const request = context.db.get("SELECT project_id, run_id FROM merge_requests WHERE merge_request_id = ?", [parsed.args.positional[0] ?? ""]);
|
|
664
|
+
if (!request || request.project_id !== project.id || request.run_id !== scope.runId)
|
|
665
|
+
throw new AppError("invocation_scope_mismatch", "MERGE request does not belong to the issued RUN scope", 1);
|
|
666
|
+
}
|
|
667
|
+
else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
|
|
668
|
+
const target = parsed.args.positional[0];
|
|
669
|
+
const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
|
|
670
|
+
if (run?.id !== scope.runId)
|
|
671
|
+
throw new AppError("invocation_scope_mismatch", "Command does not belong to the issued RUN scope", 1);
|
|
672
|
+
}
|
|
673
|
+
const guard = recoveryGuard(context, project.id, scope.runId);
|
|
674
|
+
// A cooperative pause advances the fence but explicitly admits settlement
|
|
675
|
+
// of the captured WorkSession/Stage attempt. Reuse that exact authority;
|
|
676
|
+
// never turn the invocation layer into a stricter, divergent pause policy.
|
|
677
|
+
if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
|
|
678
|
+
if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
|
|
679
|
+
return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
|
|
680
|
+
if (parsed.operation === "stage_finish")
|
|
681
|
+
return assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
|
|
682
|
+
}
|
|
683
|
+
if ((guard?.generation ?? 0) !== scope.generation)
|
|
684
|
+
throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
|
|
685
|
+
if (parsed.operation !== "recovery_accept")
|
|
686
|
+
assertRunMutationAllowed(context, project.id, scope.runId);
|
|
687
|
+
}
|
|
688
|
+
function checkCommand(row, command) {
|
|
689
|
+
const parsed = invocation(command);
|
|
690
|
+
const scope = JSON.parse(row.scope_json);
|
|
691
|
+
if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
|
|
692
|
+
|| path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
|
|
693
|
+
throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/** Explicit issuance; callers retain this command. Read-only show/status must
|
|
697
|
+
* return that retained value, not issue a replacement on every observation. */
|
|
698
|
+
export function prepareLifecycleIssuance(command, value) {
|
|
699
|
+
if (typeof command !== "string" || !command.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
700
|
+
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires command and managed scope", 1);
|
|
701
|
+
}
|
|
702
|
+
const scope = value;
|
|
703
|
+
const parsed = invocation(command);
|
|
704
|
+
if (typeof scope.daemonId !== "string" || !scope.daemonId.trim() || typeof scope.rootSessionId !== "string" || !scope.rootSessionId.trim() || !Number.isSafeInteger(scope.generation) || scope.generation < 0
|
|
705
|
+
|| (scope.runId !== null && (typeof scope.runId !== "string" || !scope.runId.trim()))
|
|
706
|
+
|| typeof scope.projectRoot !== "string" || !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
|
|
707
|
+
|| commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
|
|
708
|
+
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
|
|
709
|
+
}
|
|
710
|
+
return { command, scope, parsed };
|
|
711
|
+
}
|
|
712
|
+
export function issueLifecycleInvocation(context, command, scope) {
|
|
713
|
+
const { parsed } = prepareLifecycleIssuance(command, scope);
|
|
714
|
+
storage(context);
|
|
715
|
+
const id = crypto.randomUUID();
|
|
716
|
+
const prepared = commandWithInvocationId(command, id);
|
|
717
|
+
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
718
|
+
const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
719
|
+
checkCommand(row, prepared);
|
|
720
|
+
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()]);
|
|
721
|
+
return { id, command: prepared };
|
|
722
|
+
}
|
|
723
|
+
function commandWithInvocationId(command, id) {
|
|
724
|
+
const at = shellSuffixOffset(command);
|
|
725
|
+
return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
|
|
726
|
+
}
|
|
727
|
+
/** The attempt owns authority; the caller owns presentation-only shell input.
|
|
728
|
+
* Preserve current stdin/heredoc/response-file text while validating that it
|
|
729
|
+
* still names the exact persisted operation and project. */
|
|
730
|
+
function assertRenderableInvocation(row) {
|
|
731
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
732
|
+
if (row.status === "settled" && outcome?.error)
|
|
733
|
+
throw new AppError("invocation_command_blocked", "Failed lifecycle authority cannot be advertised as an executable command", 1, { invocation_id: row.id, outcome });
|
|
734
|
+
if (row.status === "expired" || row.status === "issued" && row.deadline !== null && row.deadline <= Date.now()) {
|
|
735
|
+
throw new AppError("invocation_expired", "Retained lifecycle attempt has expired; inspection cannot grant a new attempt", 1, { invocation_id: row.id, status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null });
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function renderInvocationCommand(row, command) {
|
|
739
|
+
assertRenderableInvocation(row);
|
|
740
|
+
const parsed = invocation(command);
|
|
741
|
+
if (commandOption(parsed, "invocation-id"))
|
|
742
|
+
throw new AppError("invocation_command_invalid", "Rendered lifecycle command already has an invocation id", 1);
|
|
743
|
+
const rendered = commandWithInvocationId(command, row.id);
|
|
744
|
+
checkCommand(row, rendered);
|
|
745
|
+
return rendered;
|
|
746
|
+
}
|
|
747
|
+
/** Native observer and receipt writer commit together. Never await I/O inside
|
|
748
|
+
* this transaction; the callback must write to this same SQLite connection. */
|
|
749
|
+
function checkNativeIdentity(scope, identity, id) {
|
|
750
|
+
if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
|
|
751
|
+
|| !identity.sessionId || !identity.toolCallId
|
|
752
|
+
|| (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
|
|
753
|
+
throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: id });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
export function observeLifecycleInvocation(context, input) {
|
|
757
|
+
storage(context);
|
|
758
|
+
const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
|
|
759
|
+
const analysis = parseLifecycleCommand(input.command);
|
|
760
|
+
if (analysis.kind !== "standalone" || analysis.invocation.wrapped) {
|
|
761
|
+
const eventKey = input.recordReceipt();
|
|
762
|
+
if (analysis.kind !== "none") {
|
|
763
|
+
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
|
|
764
|
+
let outcome = {};
|
|
765
|
+
try {
|
|
766
|
+
outcome = retained?.outcome_json ? JSON.parse(retained.outcome_json) : {};
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
outcome = {};
|
|
770
|
+
}
|
|
771
|
+
context.db.run("UPDATE hook_events SET outcome_json = ? WHERE event_key = ?", [JSON.stringify({
|
|
772
|
+
...outcome,
|
|
773
|
+
lifecycle_observation: {
|
|
774
|
+
code: "lifecycle_shell_syntax_invalid",
|
|
775
|
+
reason: analysis.kind === "compound" ? analysis.reason : "wrapped_lifecycle_command",
|
|
776
|
+
command_fingerprint: fingerprint(analysis.invocation),
|
|
777
|
+
effect: "no_effect",
|
|
778
|
+
recoverable: true
|
|
779
|
+
}
|
|
780
|
+
}), eventKey]);
|
|
781
|
+
}
|
|
782
|
+
return { eventKey, duplicate: false, invocationId: null };
|
|
783
|
+
}
|
|
784
|
+
const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed') ORDER BY rowid DESC")
|
|
785
|
+
.filter(row => {
|
|
786
|
+
const scope = JSON.parse(row.scope_json);
|
|
787
|
+
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
788
|
+
return false;
|
|
789
|
+
try {
|
|
790
|
+
return fingerprint(invocation(canonicalManagedCommand(context, input.command, scope))) === row.fingerprint;
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
// A known explicit ID remains a compatibility path. A missing or mistyped
|
|
797
|
+
// public ID is corrected only by an exact operation/target match inside the
|
|
798
|
+
// trusted native root. Never fuzzy-match UUID text or select a global latest.
|
|
799
|
+
const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
800
|
+
if (!initial) {
|
|
801
|
+
const eventKey = input.recordReceipt();
|
|
802
|
+
if (candidates.length > 1)
|
|
803
|
+
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) });
|
|
804
|
+
return { eventKey, duplicate: false, invocationId: null };
|
|
805
|
+
}
|
|
806
|
+
const resolved = input.id === initial.id ? {} : { invocationId: initial.id };
|
|
807
|
+
// A completed invocation is a read, even from a new native tool call after
|
|
808
|
+
// reattach. Do not manufacture a new receipt or reject that read as a second
|
|
809
|
+
// executor; the CLI returns the immutable stored outcome without dispatch.
|
|
810
|
+
if (initial.status === "settled")
|
|
811
|
+
return { eventKey: initial.event_key, duplicate: true, ...resolved };
|
|
812
|
+
const scope = JSON.parse(initial.scope_json);
|
|
813
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
814
|
+
const identity = input.identity;
|
|
815
|
+
checkNativeIdentity(scope, identity, initial.id);
|
|
816
|
+
const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
|
|
817
|
+
context.db.beginWriteTransaction();
|
|
818
|
+
try {
|
|
819
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
820
|
+
if (row.status === "settled") {
|
|
821
|
+
context.db.exec("COMMIT");
|
|
822
|
+
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
823
|
+
}
|
|
824
|
+
// Observation records native facts only. CLI owns deadline, argument and
|
|
825
|
+
// RUN-state validation, including decisions about retrying an invocation.
|
|
826
|
+
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
|
|
827
|
+
context.db.exec("COMMIT");
|
|
828
|
+
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
829
|
+
}
|
|
830
|
+
if (row.identity_json === identityJson) {
|
|
831
|
+
context.db.exec("COMMIT");
|
|
832
|
+
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
833
|
+
}
|
|
834
|
+
const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
|
|
835
|
+
// A native hook may execute from a provider worktree while the issued
|
|
836
|
+
// lifecycle scope is anchored to the stable project root. Project
|
|
837
|
+
// identity is the canonical boundary; comparing raw cwd would reject a
|
|
838
|
+
// valid receipt before the CLI can run (Codex does this in particular).
|
|
839
|
+
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]);
|
|
840
|
+
if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
|
|
841
|
+
throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
|
|
842
|
+
}
|
|
843
|
+
// The hook observes intent; it does not own execution. A native shell may
|
|
844
|
+
// reject a tool call before the CLI process exists (for example because
|
|
845
|
+
// its workdir is invalid), after which repeating the same issued command
|
|
846
|
+
// is safe. Keep the newest receipt until the CLI claims the attempt. The
|
|
847
|
+
// observed -> executing compare-and-swap remains the exactly-once boundary.
|
|
848
|
+
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]);
|
|
849
|
+
context.db.exec("COMMIT");
|
|
850
|
+
// Once execution has been claimed, later PreToolUse observations are
|
|
851
|
+
// retained as hook evidence but cannot replace the executor's receipt.
|
|
852
|
+
return { eventKey: updated.changes === 1 ? eventKey : row.event_key, duplicate: updated.changes !== 1, ...resolved };
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
context.db.exec("ROLLBACK");
|
|
856
|
+
throw error;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/** Correlate exact native argv for diagnostics, independently of assignment
|
|
860
|
+
* matching. A hook-added event key must name the same persisted native call. */
|
|
861
|
+
export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
862
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
|
|
863
|
+
return null;
|
|
864
|
+
const signature = (value) => {
|
|
865
|
+
const parsed = parseLifecycleCommand(value);
|
|
866
|
+
const candidate = parsed.kind === "standalone" && !parsed.invocation.wrapped ? parsed.invocation : continuationCommand(value);
|
|
867
|
+
if (!candidate)
|
|
868
|
+
return null;
|
|
869
|
+
return JSON.stringify([candidate.operation, candidate.args.positional,
|
|
870
|
+
[...candidate.args.options].filter(([key]) => !["json", "progress-jsonl", "hook-event-id"].includes(key)).sort(([a], [b]) => a.localeCompare(b))]);
|
|
871
|
+
};
|
|
872
|
+
const expected = signature(command);
|
|
873
|
+
if (!expected)
|
|
874
|
+
return null;
|
|
875
|
+
const parsed = parseLifecycleCommand(command);
|
|
876
|
+
const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
|
|
877
|
+
const matches = context.db.all("SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL", [daemonId]).filter(row => {
|
|
878
|
+
if (eventKey && row.event_key !== eventKey)
|
|
879
|
+
return false;
|
|
880
|
+
try {
|
|
881
|
+
const observed = lifecycleCommandFromPayload(JSON.parse(row.sanitized_summary));
|
|
882
|
+
return typeof observed === "string" && signature(observed) === expected;
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
// Diagnostic correlation only: never use this as execution authority, and
|
|
889
|
+
// never choose the latest of several identical native calls.
|
|
890
|
+
return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
|
|
891
|
+
}
|
|
892
|
+
/** Keep shell diagnostics distinct from genuinely absent native delivery. */
|
|
893
|
+
export function observedLifecycleDiagnostic(context, command, daemonId) {
|
|
894
|
+
const analysis = parseLifecycleCommand(command);
|
|
895
|
+
if (analysis.kind !== "standalone")
|
|
896
|
+
return null;
|
|
897
|
+
const commandFingerprint = fingerprint(analysis.invocation);
|
|
898
|
+
const rows = context.db.all("SELECT event_key, outcome_json FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NOT NULL ORDER BY id DESC LIMIT 20", [daemonId]).flatMap(row => {
|
|
899
|
+
try {
|
|
900
|
+
const outcome = JSON.parse(row.outcome_json);
|
|
901
|
+
return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key }] : [];
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
return [];
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
if (!rows.length)
|
|
908
|
+
return null;
|
|
909
|
+
const reasons = new Set(rows.map(row => `${String(row.code)}\0${String(row.reason)}`));
|
|
910
|
+
if (reasons.size !== 1)
|
|
911
|
+
return null;
|
|
912
|
+
return { ...rows[0], matching_events: rows.map(row => row.event_key) };
|
|
913
|
+
}
|
|
914
|
+
/** Resolve only a command that a native hook has already bound to an issued
|
|
915
|
+
* attempt. This is the CLI-side half of admission for model-facing commands
|
|
916
|
+
* that intentionally omit the internal UUID. */
|
|
917
|
+
export function observedLifecycleInvocation(context, command, daemonId) {
|
|
918
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
919
|
+
return null;
|
|
920
|
+
const analysis = parseLifecycleCommand(command);
|
|
921
|
+
if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
|
|
922
|
+
return null;
|
|
923
|
+
const matches = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('observed','executing') ORDER BY rowid DESC")
|
|
924
|
+
.filter(row => {
|
|
925
|
+
try {
|
|
926
|
+
const scope = JSON.parse(row.scope_json);
|
|
927
|
+
if (daemonId && scope.daemonId !== daemonId)
|
|
928
|
+
return false;
|
|
929
|
+
return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
if (!matches.length)
|
|
936
|
+
return null;
|
|
937
|
+
if (matches.length !== 1)
|
|
938
|
+
throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
|
|
939
|
+
return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
|
|
940
|
+
}
|
|
941
|
+
/** Replace presentation argv with the exact retained machine assignment.
|
|
942
|
+
* Public semantic arguments are already covered by the projected fingerprint;
|
|
943
|
+
* any explicitly repeated private value must agree instead of being ignored. */
|
|
944
|
+
export function resolveObservedLifecycleArgs(context, id, actualArgs) {
|
|
945
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
946
|
+
if (!row)
|
|
947
|
+
throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
|
|
948
|
+
const retained = invocation(row.command), actual = invocation(`${quote([retained.executable])} ${quoteModelFacingArgs(actualArgs)}`);
|
|
949
|
+
if (fingerprint(actual) !== row.fingerprint)
|
|
950
|
+
throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
951
|
+
for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
|
|
952
|
+
const supplied = commandOption(actual, key), expected = commandOption(retained, key);
|
|
953
|
+
if (supplied !== undefined && normalizedInvocationValue(key, supplied) !== normalizedInvocationValue(key, expected ?? "")) {
|
|
954
|
+
throw new AppError("invocation_argument_mismatch", `--${key} is owned by the managed assignment and does not match it`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: key });
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return [...retained.argv];
|
|
958
|
+
}
|
|
959
|
+
export function settleLifecyclePreparationRejection(context, id, error) {
|
|
960
|
+
return context.db.writeTransaction(() => {
|
|
961
|
+
const row = load(context, id);
|
|
962
|
+
if (row.status === "settled") {
|
|
963
|
+
const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
|
|
964
|
+
return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
|
|
965
|
+
}
|
|
966
|
+
if (!["issued", "observed"].includes(row.status))
|
|
967
|
+
return { effect: "unknown", recoverable: false };
|
|
968
|
+
const scope = JSON.parse(row.scope_json);
|
|
969
|
+
if (scope.runId)
|
|
970
|
+
assertLifecycleInvocationCurrent(context, scope, row.command);
|
|
971
|
+
const signature = preparationRejectionSignature(error);
|
|
972
|
+
const repeated = context.db.all("SELECT outcome_json FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND id <> ? AND status = 'settled'", [row.scope_json, row.fingerprint, row.id])
|
|
973
|
+
.some(prior => { try {
|
|
974
|
+
return JSON.parse(prior.outcome_json ?? "null")?.error?.details?.rejection_signature === signature;
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
return false;
|
|
978
|
+
} });
|
|
979
|
+
const retryable = !repeated && isRetryableLifecycleRejection(error.code, error.details);
|
|
980
|
+
const details = { ...error.details, effect: "no_effect", recoverable: retryable, rejection_signature: signature,
|
|
981
|
+
...(repeated ? { repeated_without_progress: true } : {}),
|
|
982
|
+
...(retryable ? {
|
|
983
|
+
retry_command: successorLifecycleInvocationCommand(context, row),
|
|
984
|
+
retry_instruction: "Correct the reported input, then execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state."
|
|
985
|
+
} : {}) };
|
|
986
|
+
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]);
|
|
987
|
+
return details;
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
function preparationRejectionSignature(error) {
|
|
991
|
+
const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature"]);
|
|
992
|
+
const normalize = (value) => {
|
|
993
|
+
if (Array.isArray(value))
|
|
994
|
+
return value.map(normalize);
|
|
995
|
+
if (!value || typeof value !== "object")
|
|
996
|
+
return value;
|
|
997
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !omitted.has(key)).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, normalize(item)]));
|
|
998
|
+
};
|
|
999
|
+
return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
|
|
1000
|
+
}
|
|
1001
|
+
export function currentLifecycleFailure(context, input) {
|
|
1002
|
+
const attempts = context.db.all("SELECT * FROM lifecycle_invocations ORDER BY updated_at DESC, rowid DESC");
|
|
1003
|
+
const scoped = attempts.filter(row => {
|
|
1004
|
+
try {
|
|
1005
|
+
const scope = JSON.parse(row.scope_json);
|
|
1006
|
+
return scope.runId === input.runId && scope.generation === input.generation;
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
const fingerprints = [...new Set(scoped.map(row => row.fingerprint))];
|
|
1013
|
+
for (const fingerprint of fingerprints) {
|
|
1014
|
+
const rows = scoped.filter(row => row.fingerprint === fingerprint);
|
|
1015
|
+
if (rows.some(row => ["issued", "observed", "executing"].includes(row.status)))
|
|
1016
|
+
continue;
|
|
1017
|
+
const row = rows.find(candidate => candidate.status === "settled");
|
|
1018
|
+
if (!row)
|
|
1019
|
+
continue;
|
|
1020
|
+
let outcome;
|
|
1021
|
+
try {
|
|
1022
|
+
outcome = JSON.parse(row.outcome_json ?? "null") ?? {};
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (!outcome.error?.code || !outcome.error.message)
|
|
1028
|
+
continue;
|
|
1029
|
+
let parsed;
|
|
1030
|
+
try {
|
|
1031
|
+
parsed = invocation(row.command);
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const stage = parsed.operation.startsWith("merge_") ? "merge" : commandOption(parsed, "stage");
|
|
1037
|
+
if (stage !== input.stage)
|
|
1038
|
+
continue;
|
|
1039
|
+
return { code: outcome.error.code, message: outcome.error.message, details: outcome.error.details ?? {}, invocation_id: row.id };
|
|
1040
|
+
}
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
/** One caller owns execution; a lost process leaves an explicit unknown
|
|
1044
|
+
* outcome, never an automatic replay. Existing recovery decides the next step. */
|
|
1045
|
+
export async function awaitLifecycleInvocation(context, input) {
|
|
1046
|
+
const initial = load(context, input.id);
|
|
1047
|
+
try {
|
|
1048
|
+
if (initial.status === "settled") {
|
|
1049
|
+
checkCommand(initial, input.command);
|
|
1050
|
+
return { outcome: JSON.parse(initial.outcome_json), replay: true };
|
|
1051
|
+
}
|
|
1052
|
+
input.validateArgs?.();
|
|
1053
|
+
checkCommand(initial, input.command);
|
|
1054
|
+
// A terminal command is immutable evidence. Do not require a now-gone
|
|
1055
|
+
// caller payload merely to replay its recorded result.
|
|
1056
|
+
if (["issued", "observed"].includes(initial.status))
|
|
1057
|
+
await input.prepare?.();
|
|
1058
|
+
}
|
|
1059
|
+
catch (error) {
|
|
1060
|
+
if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
|
|
1061
|
+
throw error;
|
|
1062
|
+
Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
const timeoutMs = input.timeoutMs ?? 30000;
|
|
1066
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
1067
|
+
throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
|
|
1068
|
+
context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
|
|
1069
|
+
for (;;) {
|
|
1070
|
+
const row = load(context, input.id);
|
|
1071
|
+
if (row.status === "settled")
|
|
1072
|
+
return { outcome: JSON.parse(row.outcome_json), replay: true };
|
|
1073
|
+
if (row.status === "executing") {
|
|
1074
|
+
const scope = JSON.parse(row.scope_json);
|
|
1075
|
+
if (invocation(row.command).operation === "work_start" && row.event_key) {
|
|
1076
|
+
input.assertCurrent(scope);
|
|
1077
|
+
const result = recoverCommittedWorkStart(context, row.id, row.event_key, scope.generation);
|
|
1078
|
+
if (result) {
|
|
1079
|
+
const outcome = { result, effect: "committed" };
|
|
1080
|
+
settleLifecycleInvocation(context, row.id, outcome);
|
|
1081
|
+
return { outcome, replay: true };
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id, effect: "unknown" });
|
|
1085
|
+
}
|
|
1086
|
+
input.assertCurrent(JSON.parse(row.scope_json));
|
|
1087
|
+
if (row.status === "observed") {
|
|
1088
|
+
const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
|
|
1089
|
+
if (claimed.changes === 1)
|
|
1090
|
+
return { eventKey: row.event_key, replay: false };
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
|
|
1094
|
+
context.db.beginWriteTransaction();
|
|
1095
|
+
try {
|
|
1096
|
+
const current = load(context, input.id);
|
|
1097
|
+
if (["issued", "expired"].includes(current.status)) {
|
|
1098
|
+
input.assertCurrent(JSON.parse(current.scope_json));
|
|
1099
|
+
settleUnadmittedInvocation(context, current, receiptTimeout(input.id));
|
|
1100
|
+
}
|
|
1101
|
+
context.db.exec("COMMIT");
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
context.db.exec("ROLLBACK");
|
|
1105
|
+
throw error;
|
|
1106
|
+
}
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
export function settleLifecycleInvocation(context, id, outcome) {
|
|
1113
|
+
const encoded = JSON.stringify(outcome);
|
|
1114
|
+
if (encoded === undefined)
|
|
1115
|
+
throw new AppError("invocation_outcome_invalid", "Invocation outcome must be JSON", 1);
|
|
1116
|
+
const changed = context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]);
|
|
1117
|
+
if (changed.changes !== 1) {
|
|
1118
|
+
const row = load(context, id);
|
|
1119
|
+
if (row.status !== "settled" || row.outcome_json !== encoded)
|
|
1120
|
+
throw new AppError("invocation_settlement_conflict", "Attempt cannot be settled with a different outcome", 1, { invocation_id: id });
|
|
1121
|
+
}
|
|
1122
|
+
}
|