@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.74
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 +396 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- 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.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +470 -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/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.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/runtime/context.js +3 -1
- 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/harness-config.schema.json +23 -0
- 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/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +120 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -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 +211 -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 +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -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 +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +223 -215
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +592 -154
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,637 @@
|
|
|
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 { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
|
|
8
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
9
|
+
import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
10
|
+
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
11
|
+
import { managedProcessStatus } from "./managed-processes.js";
|
|
12
|
+
import { appendEvent } from "./run-controller-state.js";
|
|
13
|
+
import { recoverCommittedWorkStart } from "./work-registry.js";
|
|
14
|
+
import { observeCommandHook } from "./hooks.js";
|
|
15
|
+
import { runtimeHarness } from "./execution-policy.js";
|
|
16
|
+
// Conclusive result/check rejections, never transport, ownership, registry or
|
|
17
|
+
// unknown-effect failures. These validators reject before accepting the result.
|
|
18
|
+
export function isRetryableLifecycleRejection(code, details = {}) {
|
|
19
|
+
if (details.recoverable === false || details.effect === "unknown" || details.effect === "committed")
|
|
20
|
+
return false;
|
|
21
|
+
return details.recoverable === true || ["validation", "schema_validation", "work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed",
|
|
22
|
+
"review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
23
|
+
"review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
|
|
24
|
+
"evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
|
|
25
|
+
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
26
|
+
|| (code === "invalid_work_state" && details.retryable_no_effect === true);
|
|
27
|
+
}
|
|
28
|
+
function storage(context) {
|
|
29
|
+
context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
|
|
30
|
+
id TEXT PRIMARY KEY, scope_json TEXT NOT NULL, command TEXT NOT NULL,
|
|
31
|
+
fingerprint TEXT NOT NULL, status TEXT NOT NULL,
|
|
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
|
+
)`);
|
|
35
|
+
}
|
|
36
|
+
function invocation(command) {
|
|
37
|
+
const parsed = parseLifecycleCommand(command);
|
|
38
|
+
if (parsed.kind !== "standalone" || parsed.invocation.wrapped) {
|
|
39
|
+
throw new AppError("invocation_command_invalid", "A lifecycle attempt requires one direct standalone command", 1);
|
|
40
|
+
}
|
|
41
|
+
return parsed.invocation;
|
|
42
|
+
}
|
|
43
|
+
// Input data may be supplied later (stdin/file contents); authority is bound to
|
|
44
|
+
// the exact operation and literal argv. Existing lifecycle validation owns data.
|
|
45
|
+
function fingerprint(parsed) {
|
|
46
|
+
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])
|
|
48
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
49
|
+
return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional, options])).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
function load(context, id) {
|
|
52
|
+
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 attempts", 1, { invocation_id: id });
|
|
54
|
+
}
|
|
55
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
|
|
56
|
+
if (!row)
|
|
57
|
+
throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime", 1, { invocation_id: id });
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
60
|
+
export function lifecycleInvocationScope(context, id) {
|
|
61
|
+
return JSON.parse(load(context, id).scope_json);
|
|
62
|
+
}
|
|
63
|
+
/** Read-only controller observation of a retained lifecycle outcome. */
|
|
64
|
+
export function lifecycleInvocationOutcome(context, id) {
|
|
65
|
+
const row = load(context, id);
|
|
66
|
+
return { status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null };
|
|
67
|
+
}
|
|
68
|
+
/** Current failures are read from runtime authority, never from model text. */
|
|
69
|
+
export function assertLifecycleOutcomes(context, scope) {
|
|
70
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
71
|
+
const diagnostics = lifecycleDiagnostics(context, project.id, scope.runId, scope.generation);
|
|
72
|
+
if (fs.existsSync(diagnostics)) {
|
|
73
|
+
for (const name of fs.readdirSync(diagnostics).filter(name => name.endsWith(".json"))) {
|
|
74
|
+
const record = JSON.parse(fs.readFileSync(path.join(diagnostics, name), "utf8"));
|
|
75
|
+
if (record.project_id !== project.id || record.run_id !== scope.runId || record.generation !== scope.generation)
|
|
76
|
+
throw new AppError("lifecycle_outcome_conflict", "Lifecycle diagnostic scope differs", 1);
|
|
77
|
+
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) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const columns = context.db.all("PRAGMA table_info(hook_events)");
|
|
81
|
+
if (columns.some(column => column.name === "outcome_json")) {
|
|
82
|
+
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])) {
|
|
83
|
+
const outcome = JSON.parse(row.outcome_json);
|
|
84
|
+
if (outcome.disposition === "retrying" && outcome.deadline <= Date.now())
|
|
85
|
+
throw new AppError("lifecycle_outcome_unknown", "Storage retry has no terminal receipt; reconcile its committed evidence", 1, { lifecycle_outcome: outcome, effect: "unknown" });
|
|
86
|
+
if (outcome.disposition === "fatal")
|
|
87
|
+
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 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
91
|
+
return;
|
|
92
|
+
const rows = context.db.all(`SELECT a.* FROM lifecycle_invocations a
|
|
93
|
+
WHERE json_extract(a.scope_json, '$.projectRoot') = ? AND json_extract(a.scope_json, '$.runId') = ?
|
|
94
|
+
AND json_extract(a.scope_json, '$.generation') = ? AND a.status = 'settled'
|
|
95
|
+
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)
|
|
96
|
+
ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
|
|
99
|
+
if (typeof error?.code === "string" && !isRetryableLifecycleRejection(error.code, error.details))
|
|
100
|
+
throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Hooks already own native identity. Retain command outcomes there, with an
|
|
104
|
+
* atomic controller notification; missing notifications reconcile from the row. */
|
|
105
|
+
export function observeLifecycleCommand(context, command) {
|
|
106
|
+
const parsed = parseLifecycleCommand(command);
|
|
107
|
+
if (parsed.kind !== "standalone")
|
|
108
|
+
return () => { };
|
|
109
|
+
let anchor;
|
|
110
|
+
context.observeLifecycleHook = (projectId, eventId) => {
|
|
111
|
+
if (anchor)
|
|
112
|
+
return;
|
|
113
|
+
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]);
|
|
114
|
+
if (!event)
|
|
115
|
+
return;
|
|
116
|
+
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;
|
|
117
|
+
let generation;
|
|
118
|
+
// A first Stage command precedes logical Session binding. Its native root
|
|
119
|
+
// is already owned by the controller's retained create receipt.
|
|
120
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")) {
|
|
121
|
+
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 => {
|
|
122
|
+
const state = JSON.parse(owner.state_json);
|
|
123
|
+
return state.sessions?.some(session => [event.provider_session_id ?? event.session_id, event.parent_session_id].includes(session.id) && runtimeHarness(session.profile) === event.harness);
|
|
124
|
+
});
|
|
125
|
+
if (owners.length > 1)
|
|
126
|
+
return;
|
|
127
|
+
if (owners.length === 1) {
|
|
128
|
+
if (runId && runId !== owners[0].run_id)
|
|
129
|
+
return;
|
|
130
|
+
runId = owners[0].run_id;
|
|
131
|
+
generation = owners[0].generation;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (event.daemon_id) {
|
|
135
|
+
for (const record of managedProcessStatus(context)) {
|
|
136
|
+
if (record.project_id !== projectId || !record.run_id || !record.kind.endsWith("-daemon"))
|
|
137
|
+
continue;
|
|
138
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
139
|
+
if (typeof metadata.state_dir !== "string")
|
|
140
|
+
continue;
|
|
141
|
+
let daemonId;
|
|
142
|
+
try {
|
|
143
|
+
daemonId = readNativeDaemonState(metadata.state_dir).daemon_id;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
continue;
|
|
147
|
+
} // Discovery of unrelated historical daemons is not authority.
|
|
148
|
+
if (daemonId !== event.daemon_id)
|
|
149
|
+
continue;
|
|
150
|
+
const binding = requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId, runId: record.run_id });
|
|
151
|
+
if (binding.state.daemon_id !== event.daemon_id)
|
|
152
|
+
continue;
|
|
153
|
+
runId = record.run_id;
|
|
154
|
+
generation = metadata.run_generation;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!runId)
|
|
159
|
+
return;
|
|
160
|
+
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.invocation.operation };
|
|
161
|
+
};
|
|
162
|
+
observeCommandHook(context, command);
|
|
163
|
+
const invocationId = commandOption(parsed.invocation, "invocation-id");
|
|
164
|
+
if (!anchor && invocationId) {
|
|
165
|
+
const attempt = load(context, invocationId);
|
|
166
|
+
checkCommand(attempt, command);
|
|
167
|
+
if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
|
|
168
|
+
const scope = JSON.parse(attempt.scope_json);
|
|
169
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
170
|
+
const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, attempt.event_key]);
|
|
171
|
+
if (event)
|
|
172
|
+
context.observeLifecycleHook(project.id, event.id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return outcome => {
|
|
176
|
+
delete context.observeLifecycleHook;
|
|
177
|
+
if (!anchor)
|
|
178
|
+
return;
|
|
179
|
+
const error = outcome.error ? errorRecord(outcome.error) : undefined;
|
|
180
|
+
const retryable = error && isRetryableLifecycleRejection(error.code, error.details);
|
|
181
|
+
const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (retryable ? "no_effect" : "unknown") : "committed", disposition: error ? retryable ? "correctable" : "fatal" : "completed" };
|
|
182
|
+
try {
|
|
183
|
+
context.db.writeTransaction(() => {
|
|
184
|
+
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
|
|
185
|
+
const previous = retained?.outcome_json ? JSON.parse(retained.outcome_json) : null;
|
|
186
|
+
if (previous?.storage_retry === 1)
|
|
187
|
+
Object.assign(record, { storage_retry: 1, work_id: previous.work_id });
|
|
188
|
+
const encoded = JSON.stringify(record);
|
|
189
|
+
if (retained?.outcome_json && previous?.disposition !== "retrying") {
|
|
190
|
+
if (retained.outcome_json !== encoded)
|
|
191
|
+
throw new AppError("lifecycle_outcome_conflict", "Hook outcome cannot change", 1, { hook_event_id: anchor.hook_event_id });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
context.db.run("UPDATE hook_events SET outcome_json = ? WHERE id = ?", [encoded, anchor.hook_event_id]);
|
|
195
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'"))
|
|
196
|
+
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]))
|
|
197
|
+
appendEvent(context, controller.controller_id, "lifecycle_outcome", record);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (persistence) {
|
|
201
|
+
if (persistence instanceof AppError && persistence.code === "lifecycle_outcome_conflict")
|
|
202
|
+
throw persistence;
|
|
203
|
+
// Diagnostic evidence is not replay authority. If SQL is unavailable,
|
|
204
|
+
// the controller still sees this failure and fences instead of waiting
|
|
205
|
+
// for a model to report it or invent a new invocation.
|
|
206
|
+
const directory = lifecycleDiagnostics(context, anchor.project_id, anchor.run_id, anchor.generation);
|
|
207
|
+
try {
|
|
208
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
209
|
+
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 });
|
|
210
|
+
}
|
|
211
|
+
catch (diagnostic) {
|
|
212
|
+
if (diagnostic.code !== "EEXIST")
|
|
213
|
+
process.stderr.write(`${JSON.stringify({ primary: record.error ?? { effect: "committed" }, persistence: String(persistence), diagnostic: String(diagnostic) })}\n`);
|
|
214
|
+
}
|
|
215
|
+
throw persistence;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function lifecycleDiagnostics(context, projectId, runId, generation) {
|
|
220
|
+
return path.join(context.ddFlowHome, "diagnostics", "lifecycle", encodeURIComponent(projectId), encodeURIComponent(runId), String(generation));
|
|
221
|
+
}
|
|
222
|
+
/** Current, explicitly-issued replacements for commands from the resumed stage.
|
|
223
|
+
* Original prompts remain immutable evidence; callers may present this small
|
|
224
|
+
* overlay after a state transition made an earlier no-effect rejection retryable. */
|
|
225
|
+
export function lifecycleRetryCommands(context, stage) {
|
|
226
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
227
|
+
if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
228
|
+
return [];
|
|
229
|
+
return context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
|
|
230
|
+
.flatMap((row) => {
|
|
231
|
+
try {
|
|
232
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
233
|
+
const retry = outcome?.error?.details?.retry_command;
|
|
234
|
+
const parsed = invocation(row.command);
|
|
235
|
+
if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
|
|
236
|
+
return [];
|
|
237
|
+
const successor = load(context, commandOption(invocation(retry), "invocation-id") ?? "");
|
|
238
|
+
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]);
|
|
239
|
+
return successor.id === latest?.id && successor.status === "issued" && (successor.deadline === null || successor.deadline > Date.now()) ? [retry] : [];
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
export function managedInvocationContext(context, input) {
|
|
247
|
+
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
248
|
+
if (input.harness !== "zcode-acp")
|
|
249
|
+
return { ...context, env };
|
|
250
|
+
const project = requireProjectByRoot(context, input.projectRoot);
|
|
251
|
+
const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
|
|
252
|
+
if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
|
|
253
|
+
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
|
+
if (!state.daemon_id || !session?.root_provider_session_id)
|
|
256
|
+
throw new AppError("invocation_scope_unproven", "Managed ZCode Session has no persisted native root binding", 1);
|
|
257
|
+
const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId: session.root_provider_session_id, runId: input.runId, generation: input.generation };
|
|
258
|
+
env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
|
|
259
|
+
return { ...context, env };
|
|
260
|
+
}
|
|
261
|
+
/** Generated commands retain their attempt across repeated rendering. Only a
|
|
262
|
+
* currently executing command can emit its own successor with the same argv. */
|
|
263
|
+
export function managedLifecycleCommand(context, command) {
|
|
264
|
+
const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
265
|
+
if (!configured)
|
|
266
|
+
return command;
|
|
267
|
+
const scope = JSON.parse(configured);
|
|
268
|
+
const parsed = invocation(command);
|
|
269
|
+
const suppliedId = commandOption(parsed, "invocation-id");
|
|
270
|
+
// An invocation id is authority, never decorative text. In particular,
|
|
271
|
+
// callers that add arguments after rendering must not be able to reuse an
|
|
272
|
+
// issued id for a different command.
|
|
273
|
+
if (suppliedId) {
|
|
274
|
+
const retained = load(context, suppliedId);
|
|
275
|
+
if (retained.scope_json !== JSON.stringify(scope)) {
|
|
276
|
+
throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
|
|
277
|
+
}
|
|
278
|
+
checkCommand(retained, command);
|
|
279
|
+
assertRenderableInvocation(retained);
|
|
280
|
+
return command;
|
|
281
|
+
}
|
|
282
|
+
if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
|
|
283
|
+
const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
|
|
284
|
+
? 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
|
+
if (!retained)
|
|
286
|
+
throw new AppError("invocation_command_unprepared", "The controller has not issued this lifecycle command yet. End this Turn and await its next assignment with the exact command; do not generate or reuse an invocation ID.", 1);
|
|
287
|
+
return renderInvocationCommand(retained, command);
|
|
288
|
+
}
|
|
289
|
+
storage(context);
|
|
290
|
+
return context.db.writeTransaction(() => {
|
|
291
|
+
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
|
+
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
293
|
+
return renderInvocationCommand(prior, command);
|
|
294
|
+
}
|
|
295
|
+
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
296
|
+
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
297
|
+
return issueLifecycleInvocation(context, command, scope).command;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
export function retryLifecycleInvocationCommand(context, id) {
|
|
301
|
+
const prior = load(context, id);
|
|
302
|
+
if (prior.status !== "executing")
|
|
303
|
+
throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
|
|
304
|
+
return successorLifecycleInvocationCommand(context, prior);
|
|
305
|
+
}
|
|
306
|
+
/** A predecessor may publish exactly one successor only while the caller has
|
|
307
|
+
* proved that no lifecycle effect was admitted. This is shared by ordinary
|
|
308
|
+
* command validation and the native pre-execution rejection path. */
|
|
309
|
+
function successorLifecycleInvocationCommand(context, prior) {
|
|
310
|
+
const parsed = invocation(prior.command);
|
|
311
|
+
const argv = [...parsed.argv];
|
|
312
|
+
const at = argv.indexOf("--invocation-id");
|
|
313
|
+
if (at < 0 || argv[at + 1] !== prior.id)
|
|
314
|
+
throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
|
|
315
|
+
// The parser proves the marker belongs to argv. Remove that one rendered
|
|
316
|
+
// token from the retained text so response-file/stdin/heredoc presentation
|
|
317
|
+
// remains byte-for-byte stable across a retry.
|
|
318
|
+
const marker = ` --invocation-id ${prior.id}`;
|
|
319
|
+
const markerAt = prior.command.indexOf(marker);
|
|
320
|
+
if (markerAt < 0 || prior.command.indexOf(marker, markerAt + marker.length) >= 0)
|
|
321
|
+
throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
|
|
322
|
+
const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
|
|
323
|
+
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
|
|
324
|
+
}
|
|
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
|
+
// Caller holds the write transaction and has checked authority. Issued (or
|
|
353
|
+
// legacy expired) attempts have never admitted an effect; observed/executing
|
|
354
|
+
// attempts must never receive an automatic replacement.
|
|
355
|
+
function settleUnadmittedInvocation(context, prior, error) {
|
|
356
|
+
if (!["issued", "expired"].includes(prior.status))
|
|
357
|
+
throw new AppError("invocation_rejection_conflict", "Attempt already admitted native execution", 1);
|
|
358
|
+
const details = { ...error.details, effect: "no_effect", recoverable: true, retry_command: successorLifecycleInvocationCommand(context, prior),
|
|
359
|
+
retry_instruction: "Use retry_command as one standalone lifecycle command. The rejected command did not change lifecycle state." };
|
|
360
|
+
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
|
+
throw new AppError("invocation_settlement_conflict", "Lifecycle rejection could not be committed", 1, { invocation_id: prior.id });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
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
|
+
/** Settle a conclusive rejection and publish its only legal successor atomically.
|
|
366
|
+
* A crash must leave either the old attempt executing, or both the settled
|
|
367
|
+
* outcome and its successor; never a usable successor beside an unknown effect. */
|
|
368
|
+
export function settleLifecycleRejection(context, id, error) {
|
|
369
|
+
storage(context);
|
|
370
|
+
const details = { ...error.details };
|
|
371
|
+
context.db.beginWriteTransaction();
|
|
372
|
+
try {
|
|
373
|
+
const prior = load(context, id);
|
|
374
|
+
if (prior.status !== "executing")
|
|
375
|
+
throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
|
|
376
|
+
const storageRetry = error.code === "storage_write_failed" && error.details.effect === "no_effect"
|
|
377
|
+
&& !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]);
|
|
378
|
+
if (isRetryableLifecycleRejection(error.code, error.details) || storageRetry) {
|
|
379
|
+
const retry = retryLifecycleInvocationCommand(context, id);
|
|
380
|
+
details.retry_command = retry;
|
|
381
|
+
details.recoverable = true;
|
|
382
|
+
if (storageRetry)
|
|
383
|
+
details.storage_retry = 1;
|
|
384
|
+
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
|
+
}
|
|
386
|
+
settleLifecycleInvocation(context, id, { error: { ...error, details } });
|
|
387
|
+
context.db.exec("COMMIT");
|
|
388
|
+
Object.assign(error.details, details);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
context.db.exec("ROLLBACK");
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Recovery preserves the original packet as evidence. Only its retained,
|
|
396
|
+
* never-executed commands receive new authority; unknown effects never replay. */
|
|
397
|
+
export function recoverLifecycleCommands(context, originalPrompt, scope) {
|
|
398
|
+
const ids = [...new Set([...originalPrompt.matchAll(/--invocation-id ([0-9a-f-]{36})\b/g)].map(match => match[1]))];
|
|
399
|
+
const recovered = [];
|
|
400
|
+
for (const id of ids) {
|
|
401
|
+
let prior = load(context, id);
|
|
402
|
+
const oldScope = JSON.parse(prior.scope_json);
|
|
403
|
+
if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
|
|
404
|
+
|| oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
|
|
405
|
+
throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
|
|
406
|
+
}
|
|
407
|
+
// The immutable prompt can predate several recoveries. Follow the newest
|
|
408
|
+
// retained attempt, including an unknown successor, rather than reviving
|
|
409
|
+
// an older unexecuted command after the successor already produced effects.
|
|
410
|
+
prior = context.db.get(`SELECT * FROM lifecycle_invocations WHERE fingerprint = ?
|
|
411
|
+
AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') IS ?
|
|
412
|
+
AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ?
|
|
413
|
+
ORDER BY json_extract(scope_json, '$.generation') DESC, rowid DESC LIMIT 1`, [prior.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation]);
|
|
414
|
+
if (prior.status === "executing" || prior.status === "settled") {
|
|
415
|
+
const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
|
|
416
|
+
recovered.push({ invocation_id: id, status: prior.status, outcome,
|
|
417
|
+
instruction: prior.status === "executing" ? "Outcome unknown: reconcile retained operation evidence; do not reissue this command." : "Read the retained outcome; do not repeat completed effects." });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const parsed = invocation(prior.command);
|
|
421
|
+
const argv = [...parsed.argv];
|
|
422
|
+
const at = argv.indexOf("--invocation-id");
|
|
423
|
+
if (at < 0 || argv[at + 1] !== prior.id)
|
|
424
|
+
throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
|
|
425
|
+
argv.splice(at, 2);
|
|
426
|
+
const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
|
|
427
|
+
const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${quote([parsed.executable, ...argv])} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
|
|
428
|
+
const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
|
|
429
|
+
recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
|
|
430
|
+
}
|
|
431
|
+
return recovered;
|
|
432
|
+
}
|
|
433
|
+
export function assertLifecycleInvocationCurrent(context, scope, command) {
|
|
434
|
+
const parsed = invocation(command);
|
|
435
|
+
if (!scope.runId) {
|
|
436
|
+
if ((parsed.operation === "stage_start" && parsed.args.options.has("bootstrap")) || parsed.operation === "session_register")
|
|
437
|
+
return;
|
|
438
|
+
throw new AppError("invocation_scope_invalid", "This lifecycle operation requires an issued RUN scope", 1);
|
|
439
|
+
}
|
|
440
|
+
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
441
|
+
let workId;
|
|
442
|
+
if (parsed.operation.startsWith("work_")) {
|
|
443
|
+
const target = parsed.args.positional[0] ?? "";
|
|
444
|
+
const works = context.db.all("SELECT work_id, run_id FROM works WHERE project_id = ? AND (work_id = ? OR (? = 1 AND work_id LIKE ?))", [project.id, target, /^WRK-\d{3,}$/.test(target) ? 1 : 0, `${target}-%`]);
|
|
445
|
+
if (works.length !== 1 || works[0].run_id !== scope.runId)
|
|
446
|
+
throw new AppError("invocation_scope_mismatch", "Work does not belong to the issued RUN scope", 1);
|
|
447
|
+
workId = works[0].work_id;
|
|
448
|
+
}
|
|
449
|
+
else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
|
|
450
|
+
const target = parsed.args.positional[0];
|
|
451
|
+
const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
|
|
452
|
+
if (run?.id !== scope.runId)
|
|
453
|
+
throw new AppError("invocation_scope_mismatch", "Command does not belong to the issued RUN scope", 1);
|
|
454
|
+
}
|
|
455
|
+
const guard = recoveryGuard(context, project.id, scope.runId);
|
|
456
|
+
// A cooperative pause advances the fence but explicitly admits settlement
|
|
457
|
+
// of the captured WorkSession/Stage attempt. Reuse that exact authority;
|
|
458
|
+
// never turn the invocation layer into a stricter, divergent pause policy.
|
|
459
|
+
if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
|
|
460
|
+
if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
|
|
461
|
+
return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
|
|
462
|
+
if (parsed.operation === "stage_finish")
|
|
463
|
+
return assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
|
|
464
|
+
}
|
|
465
|
+
if ((guard?.generation ?? 0) !== scope.generation)
|
|
466
|
+
throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
|
|
467
|
+
if (parsed.operation !== "recovery_accept")
|
|
468
|
+
assertRunMutationAllowed(context, project.id, scope.runId);
|
|
469
|
+
}
|
|
470
|
+
function checkCommand(row, command) {
|
|
471
|
+
const parsed = invocation(command);
|
|
472
|
+
const scope = JSON.parse(row.scope_json);
|
|
473
|
+
if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
|
|
474
|
+
|| path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
|
|
475
|
+
throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/** Explicit issuance; callers retain this command. Read-only show/status must
|
|
479
|
+
* return that retained value, not issue a replacement on every observation. */
|
|
480
|
+
export function issueLifecycleInvocation(context, command, scope) {
|
|
481
|
+
storage(context);
|
|
482
|
+
const parsed = invocation(command);
|
|
483
|
+
if (!scope.daemonId || !scope.rootSessionId || !Number.isInteger(scope.generation) || scope.generation < 0
|
|
484
|
+
|| !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
|
|
485
|
+
|| commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
|
|
486
|
+
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
|
|
487
|
+
}
|
|
488
|
+
const id = crypto.randomUUID();
|
|
489
|
+
const prepared = commandWithInvocationId(command, id);
|
|
490
|
+
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
491
|
+
const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
492
|
+
checkCommand(row, prepared);
|
|
493
|
+
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()]);
|
|
494
|
+
return { id, command: prepared };
|
|
495
|
+
}
|
|
496
|
+
function commandWithInvocationId(command, id) {
|
|
497
|
+
const at = shellSuffixOffset(command);
|
|
498
|
+
return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
|
|
499
|
+
}
|
|
500
|
+
/** The attempt owns authority; the caller owns presentation-only shell input.
|
|
501
|
+
* Preserve current stdin/heredoc/response-file text while validating that it
|
|
502
|
+
* still names the exact persisted operation and project. */
|
|
503
|
+
function assertRenderableInvocation(row) {
|
|
504
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
505
|
+
if (row.status === "settled" && outcome?.error)
|
|
506
|
+
throw new AppError("invocation_command_blocked", "Failed lifecycle authority cannot be advertised as an executable command", 1, { invocation_id: row.id, outcome });
|
|
507
|
+
if (row.status === "expired" || row.status === "issued" && row.deadline !== null && row.deadline <= Date.now()) {
|
|
508
|
+
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 });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function renderInvocationCommand(row, command) {
|
|
512
|
+
assertRenderableInvocation(row);
|
|
513
|
+
const parsed = invocation(command);
|
|
514
|
+
if (commandOption(parsed, "invocation-id"))
|
|
515
|
+
throw new AppError("invocation_command_invalid", "Rendered lifecycle command already has an invocation id", 1);
|
|
516
|
+
const rendered = commandWithInvocationId(command, row.id);
|
|
517
|
+
checkCommand(row, rendered);
|
|
518
|
+
return rendered;
|
|
519
|
+
}
|
|
520
|
+
/** Native observer and receipt writer commit together. Never await I/O inside
|
|
521
|
+
* this transaction; the callback must write to this same SQLite connection. */
|
|
522
|
+
function checkNativeIdentity(scope, identity, id) {
|
|
523
|
+
if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
|
|
524
|
+
|| !identity.sessionId || !identity.toolCallId
|
|
525
|
+
|| (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
|
|
526
|
+
throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: id });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
export function observeLifecycleInvocation(context, input) {
|
|
530
|
+
const initial = load(context, input.id);
|
|
531
|
+
checkCommand(initial, input.command);
|
|
532
|
+
// A completed invocation is a read, even from a new native tool call after
|
|
533
|
+
// reattach. Do not manufacture a new receipt or reject that read as a second
|
|
534
|
+
// executor; the CLI returns the immutable stored outcome without dispatch.
|
|
535
|
+
if (initial.status === "settled")
|
|
536
|
+
return { eventKey: initial.event_key, duplicate: true };
|
|
537
|
+
const scope = JSON.parse(initial.scope_json);
|
|
538
|
+
const identity = input.identity;
|
|
539
|
+
checkNativeIdentity(scope, identity, input.id);
|
|
540
|
+
const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
|
|
541
|
+
context.db.beginWriteTransaction();
|
|
542
|
+
try {
|
|
543
|
+
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]);
|
|
544
|
+
if (row.status === "settled") {
|
|
545
|
+
context.db.exec("COMMIT");
|
|
546
|
+
return { eventKey: row.event_key, duplicate: true };
|
|
547
|
+
}
|
|
548
|
+
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
|
+
context.db.exec("COMMIT");
|
|
553
|
+
return { eventKey: row.event_key, duplicate: true };
|
|
554
|
+
}
|
|
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 });
|
|
558
|
+
context.db.exec("COMMIT");
|
|
559
|
+
return { eventKey: row.event_key, duplicate: true };
|
|
560
|
+
}
|
|
561
|
+
const eventKey = input.recordReceipt();
|
|
562
|
+
const receipt = eventKey && context.db.get("SELECT provider_session_id, daemon_id, status FROM hook_events WHERE event_key = ? AND harness = 'zcode-acp' AND cwd = ?", [eventKey, scope.projectRoot]);
|
|
563
|
+
if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
|
|
564
|
+
throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
|
|
565
|
+
}
|
|
566
|
+
context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status = 'issued'", [identityJson, eventKey, context.now(), input.id]);
|
|
567
|
+
context.db.exec("COMMIT");
|
|
568
|
+
return { eventKey, duplicate: false };
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
context.db.exec("ROLLBACK");
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** One caller owns execution; a lost process leaves an explicit unknown
|
|
576
|
+
* outcome, never an automatic replay. Existing recovery decides the next step. */
|
|
577
|
+
export async function awaitLifecycleInvocation(context, input) {
|
|
578
|
+
const initial = load(context, input.id);
|
|
579
|
+
checkCommand(initial, input.command);
|
|
580
|
+
const timeoutMs = input.timeoutMs ?? 30000;
|
|
581
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
582
|
+
throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
|
|
583
|
+
context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
|
|
584
|
+
for (;;) {
|
|
585
|
+
const row = load(context, input.id);
|
|
586
|
+
if (row.status === "settled")
|
|
587
|
+
return { outcome: JSON.parse(row.outcome_json), replay: true };
|
|
588
|
+
if (row.status === "executing") {
|
|
589
|
+
const scope = JSON.parse(row.scope_json);
|
|
590
|
+
if (invocation(row.command).operation === "work_start" && row.event_key) {
|
|
591
|
+
input.assertCurrent(scope);
|
|
592
|
+
const result = recoverCommittedWorkStart(context, row.id, row.event_key, scope.generation);
|
|
593
|
+
if (result) {
|
|
594
|
+
const outcome = { result, effect: "committed" };
|
|
595
|
+
settleLifecycleInvocation(context, row.id, outcome);
|
|
596
|
+
return { outcome, replay: true };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id, effect: "unknown" });
|
|
600
|
+
}
|
|
601
|
+
input.assertCurrent(JSON.parse(row.scope_json));
|
|
602
|
+
if (row.status === "observed") {
|
|
603
|
+
const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
|
|
604
|
+
if (claimed.changes === 1)
|
|
605
|
+
return { eventKey: row.event_key, replay: false };
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
|
|
609
|
+
context.db.beginWriteTransaction();
|
|
610
|
+
try {
|
|
611
|
+
const current = load(context, input.id);
|
|
612
|
+
if (["issued", "expired"].includes(current.status)) {
|
|
613
|
+
input.assertCurrent(JSON.parse(current.scope_json));
|
|
614
|
+
settleUnadmittedInvocation(context, current, receiptTimeout(input.id));
|
|
615
|
+
}
|
|
616
|
+
context.db.exec("COMMIT");
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
context.db.exec("ROLLBACK");
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
export function settleLifecycleInvocation(context, id, outcome) {
|
|
628
|
+
const encoded = JSON.stringify(outcome);
|
|
629
|
+
if (encoded === undefined)
|
|
630
|
+
throw new AppError("invocation_outcome_invalid", "Invocation outcome must be JSON", 1);
|
|
631
|
+
const changed = context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]);
|
|
632
|
+
if (changed.changes !== 1) {
|
|
633
|
+
const row = load(context, id);
|
|
634
|
+
if (row.status !== "settled" || row.outcome_json !== encoded)
|
|
635
|
+
throw new AppError("invocation_settlement_conflict", "Attempt cannot be settled with a different outcome", 1, { invocation_id: id });
|
|
636
|
+
}
|
|
637
|
+
}
|