@deksden-com/dd-flow-cli 0.9.0-beta.62 → 0.9.0-beta.64
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 +19 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/run-cli.js +45 -4
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-zcode.mjs +2 -1
- package/dist/harness-runtime/lib/native-hook-command.mjs +1 -0
- package/dist/harness-runtime/lib/process-json.mjs +15 -6
- package/dist/services/cleanup.js +1 -1
- package/dist/services/code-checks.js +1 -1
- package/dist/services/eval-snapshots.js +2 -2
- package/dist/services/harness-adapter.js +61 -9
- package/dist/services/hooks.js +26 -0
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-invocations.js +194 -22
- package/dist/services/managed-processes.js +2 -2
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +1 -1
- package/dist/services/migrations.js +1 -1
- package/dist/services/projects.js +1 -1
- package/dist/services/protocols.js +2 -2
- package/dist/services/run-control-worker.js +3 -3
- package/dist/services/run-control.js +24 -2
- package/dist/services/run-controller-adapter.js +8 -3
- package/dist/services/run-controller-capture.js +2 -2
- package/dist/services/run-controller-process.js +2 -2
- package/dist/services/run-controller-recovery.js +3 -3
- package/dist/services/run-controller.js +297 -180
- package/dist/services/run-recovery-runtime.js +1 -1
- package/dist/services/run-recovery.js +3 -3
- package/dist/services/runs.js +13 -14
- package/dist/services/runtime-budget.js +4 -4
- package/dist/services/runtime-scope-control.js +5 -5
- package/dist/services/runtime-scope-resume.js +7 -7
- package/dist/services/runtime-scope-worker.js +2 -2
- package/dist/services/stage-pause.js +2 -2
- package/dist/services/vnext-code-review.js +9 -4
- package/dist/services/vnext-code.js +10 -3
- package/dist/services/vnext-merge.js +16 -21
- package/dist/services/vnext-plan-review.js +4 -2
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/work-registry.js +198 -121
- package/dist/storage/database.js +112 -20
- package/dist/storage/writer-contract.js +2 -1
- package/dist/storage/writer-migration.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.64
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Reserve SQLite writes explicitly, retain trusted lifecycle outcomes across harnesses,
|
|
8
|
+
continuously fence failed managed runs, and recover committed Work start packets and
|
|
9
|
+
replies without repeating effects. Revalidate stage inputs before acceptance and keep
|
|
10
|
+
the causal failure when adapter, publication or cleanup also fails. Writer contract 2
|
|
11
|
+
requires fresh campaign and resource homes; historical contract-1 stores are not
|
|
12
|
+
implicitly upgraded. Recovery-generation Work starts keep separate immutable packets
|
|
13
|
+
per WorkSession, preserving the interrupted attempt's evidence.
|
|
14
|
+
|
|
15
|
+
## 0.9.0-beta.63
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Accept the qualified retained-subagent ZCode bridge and settle a control only
|
|
20
|
+
when a durable startup rejection proves that it created no native resources.
|
|
21
|
+
|
|
3
22
|
## 0.9.0-beta.62
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.64",
|
|
4
|
+
"cli_commit": "53046a13b2a4370531d64b380974abbd6bf2383b",
|
|
5
|
+
"built_at": "2026-09-14T16:28:38.823Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
|
-
"repo_root": "/
|
|
11
|
-
"memorybank_root": "/
|
|
12
|
-
"flow_root": "/
|
|
10
|
+
"repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
|
|
11
|
+
"memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
|
|
12
|
+
"flow_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank/dd-flow",
|
|
13
13
|
"layout": "dot_memory_bank"
|
|
14
14
|
}
|
|
15
15
|
}
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { quote } from "shell-quote";
|
|
5
|
+
import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
|
|
4
6
|
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation, lifecycleInvocationScope, settleLifecycleInvocation, settleLifecycleRejection } from "../services/lifecycle-invocations.js";
|
|
5
7
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
6
8
|
import { migrateStoreWriter } from "../storage/writer-migration.js";
|
|
@@ -84,6 +86,8 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
84
86
|
const progress = progressForCommand(output.args);
|
|
85
87
|
let heartbeat;
|
|
86
88
|
let invocationSettlement;
|
|
89
|
+
let settleHook;
|
|
90
|
+
let diagnosticHome;
|
|
87
91
|
try {
|
|
88
92
|
if (output.args.length === 1 && output.args[0] === "--version") {
|
|
89
93
|
const payload = getCliVersionReport();
|
|
@@ -130,17 +134,22 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
130
134
|
const dispatchContext = routed?.inProcess
|
|
131
135
|
? createContext({ ...env, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_ENGINE_HOME: "in-process", DD_FLOW_ROUTED_FROM: getCliVersionReport().cli.version }, databaseModeForCommand(output.args, classification))
|
|
132
136
|
: createContext({ ...env }, databaseModeForCommand(output.args, classification));
|
|
137
|
+
diagnosticHome = dispatchContext.ddFlowHome;
|
|
133
138
|
preflightCliCompatibility(dispatchContext, output.args, env, scopeProjectRoot);
|
|
134
139
|
dispatchContext.env.DD_FLOW_INVOCATION_READONLY = classification.mode === "read_only_diagnostics" ? "1" : undefined;
|
|
135
140
|
let dispatchArgs = output.args;
|
|
141
|
+
settleHook = observeLifecycleCommand(dispatchContext, quote(["dd-flow", ...dispatchArgs]));
|
|
136
142
|
let retainedOutcome;
|
|
137
143
|
const invocationId = optionalOption(parseArgs(output.args.slice(2)), "invocation-id");
|
|
138
144
|
if (invocationId) {
|
|
139
145
|
const command = quote(["dd-flow", ...output.args]);
|
|
140
146
|
const admission = await awaitLifecycleInvocation(dispatchContext, { id: invocationId, command,
|
|
141
147
|
assertCurrent: scope => assertLifecycleInvocationCurrent(dispatchContext, scope, command) });
|
|
142
|
-
if (admission.replay)
|
|
148
|
+
if (admission.replay) {
|
|
143
149
|
retainedOutcome = admission.outcome;
|
|
150
|
+
settleHook = undefined;
|
|
151
|
+
delete dispatchContext.observeLifecycleHook;
|
|
152
|
+
}
|
|
144
153
|
else {
|
|
145
154
|
invocationSettlement = { context: dispatchContext, id: invocationId };
|
|
146
155
|
dispatchArgs = [...output.args, "--hook-event-id", admission.eventKey];
|
|
@@ -152,7 +161,12 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
152
161
|
const error = retainedOutcome.error;
|
|
153
162
|
throw new AppError(error.code, error.message, error.exitCode, error.details);
|
|
154
163
|
}
|
|
164
|
+
if (!retainedOutcome)
|
|
165
|
+
settleHook = observeLifecycleCommand(dispatchContext, quote(["dd-flow", ...dispatchArgs]));
|
|
155
166
|
const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output);
|
|
167
|
+
const completedHook = settleHook;
|
|
168
|
+
settleHook = undefined;
|
|
169
|
+
completedHook?.({ result });
|
|
156
170
|
if (invocationSettlement) {
|
|
157
171
|
const completed = invocationSettlement;
|
|
158
172
|
invocationSettlement = undefined;
|
|
@@ -175,10 +189,23 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
175
189
|
io.stdout.write(renderHumanResult(output.args, payload));
|
|
176
190
|
return completedCommandExitCode(payload);
|
|
177
191
|
}
|
|
178
|
-
catch (
|
|
192
|
+
catch (caught) {
|
|
193
|
+
const error = isAppError(caught) ? caught : new AppError("unexpected", caught instanceof Error ? caught.message : String(caught), 1, { effect: "unknown", cause: { code: caught?.code ?? null, sqlite_extended_code: caught?.errcode ?? null } });
|
|
194
|
+
if (diagnosticHome && (!isAppError(caught) || error.code === "storage_write_failed")) {
|
|
195
|
+
try {
|
|
196
|
+
const directory = path.join(diagnosticHome, "diagnostics", "cli");
|
|
197
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
198
|
+
const artifact = path.join(directory, `${randomUUID()}.json`);
|
|
199
|
+
fs.writeFileSync(artifact, JSON.stringify({ code: error.code, message: error.message, details: error.details, stack: caught instanceof Error ? caught.stack : null }), { flag: "wx", mode: 0o600 });
|
|
200
|
+
error.details.diagnostic_artifact = artifact;
|
|
201
|
+
}
|
|
202
|
+
catch (secondary) {
|
|
203
|
+
io.stderr.write(`CLI diagnostic persistence failed: ${String(secondary)}\n`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
179
206
|
if (heartbeat)
|
|
180
207
|
clearInterval(heartbeat);
|
|
181
|
-
if (invocationSettlement) {
|
|
208
|
+
if (invocationSettlement && error.details.effect !== "committed") {
|
|
182
209
|
try {
|
|
183
210
|
if (isAppError(error))
|
|
184
211
|
settleLifecycleRejection(invocationSettlement.context, invocationSettlement.id, { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details });
|
|
@@ -189,6 +216,14 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
189
216
|
io.stderr.write(`Invocation settlement persistence failed: ${String(persistenceError)}\n`);
|
|
190
217
|
}
|
|
191
218
|
}
|
|
219
|
+
if (settleHook) {
|
|
220
|
+
try {
|
|
221
|
+
settleHook({ error: isAppError(error) ? error : { code: "unexpected", message: String(error), details: {} } });
|
|
222
|
+
}
|
|
223
|
+
catch (persistence) {
|
|
224
|
+
io.stderr.write(`${JSON.stringify({ primary: String(error), lifecycle_persistence_error: String(persistence) })}\n`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
192
227
|
const payload = isAppError(error)
|
|
193
228
|
? { ok: false, error: { code: error.code, message: error.message, details: error.details } }
|
|
194
229
|
: { ok: false, error: { code: "unexpected", message: String(error) } };
|
|
@@ -196,7 +231,13 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
196
231
|
if (output.progressJsonl)
|
|
197
232
|
writeProgressJsonl(io, "failed", payload.error.message, { code: payload.error.code });
|
|
198
233
|
const errorPayload = attachProgress(payload, progress, output);
|
|
199
|
-
|
|
234
|
+
let visible = errorPayload;
|
|
235
|
+
try {
|
|
236
|
+
visible = writeResponseFile(errorPayload, output);
|
|
237
|
+
}
|
|
238
|
+
catch (publication) {
|
|
239
|
+
error.details.response_publication_error = { code: publication?.code ?? "response_publication_failed", message: publication instanceof Error ? publication.message : String(publication) };
|
|
240
|
+
}
|
|
200
241
|
if (output.json && output.progressJsonl)
|
|
201
242
|
writeJson(io.stdout, errorPayload);
|
|
202
243
|
else if (output.json)
|
|
@@ -154,7 +154,7 @@ export async function startDaemon(options) {
|
|
|
154
154
|
await removeSocket(paths.socket, paths.dir);
|
|
155
155
|
const versions = await doctor({ bin: config.bin, zcodePath: config.zcodePath });
|
|
156
156
|
if (hasFlowContext && versions.compatible !== true) {
|
|
157
|
-
throw new DaemonError("zcode_lifecycle_unqualified", "ZCode native event identity is not qualified for managed CLI admission; inspect lifecycle_qualification before starting E2E.", false, { observed_runtime: versions.observed_runtime, lifecycle_qualification: versions.lifecycle_qualification });
|
|
157
|
+
throw new DaemonError("zcode_lifecycle_unqualified", "ZCode native event identity is not qualified for managed CLI admission; inspect lifecycle_qualification before starting E2E.", false, { startup_resources: "not_created", observed_runtime: versions.observed_runtime, lifecycle_qualification: versions.lifecycle_qualification });
|
|
158
158
|
}
|
|
159
159
|
const resourceProcess = await registerDaemonProcess(config, {
|
|
160
160
|
kind: "zcode-daemon", owner: `zcode:${path.basename(paths.dir)}`, operation: `zcode-daemon:${path.basename(paths.dir)}`,
|
|
@@ -681,8 +681,9 @@ async function forwardUsage(options, providerSessionId, usage, toolCalls) {
|
|
|
681
681
|
export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContract) {
|
|
682
682
|
// Behavioural evidence: dd-eval production-invocations probe, 2026-09-13.
|
|
683
683
|
// Pin both providers of native event identity, not a string in the bundle.
|
|
684
|
+
// 60af0d3 adds retainedSubagents without changing native event forwarding.
|
|
684
685
|
const qualified = sha256 === "e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8"
|
|
685
|
-
&&
|
|
686
|
+
&& ["e0600fe3c46e215695f257e65aa6f674ced998d8", "60af0d31e13076a313d9770f10aa70f7c94742cf"].includes(bridgeCommit) && harnessContract === ZCODE_HARNESS_CONTRACT;
|
|
686
687
|
return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
|
|
687
688
|
capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
|
|
688
689
|
native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
|
|
@@ -28,6 +28,7 @@ export async function invokeNativeHook({ bin, args, payload, home, timeoutMs = 1
|
|
|
28
28
|
});
|
|
29
29
|
let result;
|
|
30
30
|
try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
|
|
31
|
+
if (result?.ok === false && typeof result.error?.code === "string") throw Object.assign(new Error(result.error.message ?? "Native hook failed"), { code: result.error.code, details: result.error.details });
|
|
31
32
|
if (!result || typeof result !== "object" || Array.isArray(result) || result.ok === false) throw Object.assign(new Error("Native hook did not return a successful protocol object"), { code: "native_hook_response_invalid" });
|
|
32
33
|
return result;
|
|
33
34
|
}
|
|
@@ -30,22 +30,31 @@ export async function commandJson(bin, args, { cwd, env = {}, input = null, onPr
|
|
|
30
30
|
const executable = bin === "dd-flow" && typeof env.DD_FLOW_BIN === "string" ? env.DD_FLOW_BIN : bin;
|
|
31
31
|
const target = processTarget(executable, [...args, "--json"]);
|
|
32
32
|
const child = spawn(target.command, target.args, { cwd, env: { ...process.env, ...env }, stdio: [input === null ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
33
|
-
let stdout = ""; let stderr = ""; let pendingStderr = "";
|
|
33
|
+
let stdout = ""; let stderr = ""; let pendingStderr = ""; let settled = false; let progressError = null;
|
|
34
|
+
const finish = (error, value) => { if (settled) return; settled = true; error ? reject(error) : resolve(value); };
|
|
35
|
+
const deliverProgress = (line) => {
|
|
36
|
+
let value; try { value = JSON.parse(line); } catch { return; }
|
|
37
|
+
if (typeof onProgress !== "function" || progressError) return;
|
|
38
|
+
try { const returned = onProgress(value); if (returned && typeof returned.then === "function") { Promise.resolve(returned).catch(() => {}); throw failure("commandJson onProgress must be synchronous", "progress_callback_async"); } }
|
|
39
|
+
catch (error) { progressError = error; child.kill("SIGKILL"); }
|
|
40
|
+
};
|
|
34
41
|
child.stdout.setEncoding("utf8").on("data", (data) => { stdout += data; });
|
|
35
42
|
child.stderr.setEncoding("utf8").on("data", (data) => {
|
|
36
43
|
stderr += data; pendingStderr += data;
|
|
37
44
|
for (;;) {
|
|
38
45
|
const end = pendingStderr.indexOf("\n"); if (end < 0) break;
|
|
39
46
|
const line = pendingStderr.slice(0, end); pendingStderr = pendingStderr.slice(end + 1);
|
|
40
|
-
|
|
47
|
+
deliverProgress(line);
|
|
41
48
|
}
|
|
42
49
|
});
|
|
43
|
-
child.
|
|
50
|
+
child.once("error", (error) => finish(progressError ?? error));
|
|
44
51
|
if (input !== null) child.stdin.end(input);
|
|
45
52
|
child.on("close", (status) => {
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
if (pendingStderr) deliverProgress(pendingStderr);
|
|
54
|
+
if (progressError) return finish(progressError);
|
|
55
|
+
if (status !== 0) return finish(reportedFailure(stdout, stderr, `${executable} exited ${status}`));
|
|
56
|
+
try { const value = JSON.parse(stdout.trim()); if (value?.ok === false) { const error = value.error && typeof value.error === "object" ? value.error : value; if (typeof error.code === "string") return finish(reportedError(error, `${bin} returned an error envelope`)); } finish(null, value); }
|
|
57
|
+
catch (error) { finish(failure(`${bin} returned invalid JSON: ${error.message}`, "flow_reconciliation_failed")); }
|
|
49
58
|
});
|
|
50
59
|
});
|
|
51
60
|
}
|
package/dist/services/cleanup.js
CHANGED
|
@@ -184,7 +184,7 @@ export function cleanupApply(context, input) {
|
|
|
184
184
|
validateNoDestructiveSurprises(context, plan, input.force);
|
|
185
185
|
const now = context.now();
|
|
186
186
|
const results = [];
|
|
187
|
-
context.db.
|
|
187
|
+
context.db.beginWriteTransaction();
|
|
188
188
|
try {
|
|
189
189
|
for (const action of plan.actions) {
|
|
190
190
|
results.push(applyAction(context, action, reason, input.force, now));
|
|
@@ -291,7 +291,7 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
291
291
|
return reconciled;
|
|
292
292
|
}
|
|
293
293
|
function reserveOrReuseReceipt(context, input) {
|
|
294
|
-
context.db.
|
|
294
|
+
context.db.beginWriteTransaction();
|
|
295
295
|
try {
|
|
296
296
|
assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
|
|
297
297
|
const prior = latestApplicableReceipt(context, input);
|
|
@@ -405,7 +405,7 @@ function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
|
|
|
405
405
|
fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/snapshot-runtime-lineage@1", source_snapshot: snapshot, source_manifest_sha256: fileHash(path.join(snapshot, "snapshot.json")), source_runtime: manifest.dd_flow_home, restored_runtime: context.ddFlowHome, run_id: manifest.run_id, run_tables: runTables, resource_tables: resourceTables }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
406
406
|
syncPath(file);
|
|
407
407
|
syncPath(directory);
|
|
408
|
-
context.db.
|
|
408
|
+
context.db.beginWriteTransaction();
|
|
409
409
|
try {
|
|
410
410
|
context.db.run("UPDATE sessions SET metadata_json = json_set(metadata_json, '$.snapshot_import', json(?))", [JSON.stringify({ lineage_file: file, lineage_sha256: fileHash(file) })]);
|
|
411
411
|
for (const table of detachedTables)
|
|
@@ -439,7 +439,7 @@ function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
|
|
|
439
439
|
}
|
|
440
440
|
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
|
|
441
441
|
context.db.exec("PRAGMA foreign_keys = OFF");
|
|
442
|
-
context.db.
|
|
442
|
+
context.db.beginWriteTransaction();
|
|
443
443
|
try {
|
|
444
444
|
context.db.run("UPDATE projects SET root = ?", [newRoot]);
|
|
445
445
|
context.db.run("UPDATE runs SET project_root = ?, workspace_root = ?", [newRoot, newWorkspace]);
|
|
@@ -53,13 +53,19 @@ export function runHarnessAdapter(input) {
|
|
|
53
53
|
env.DD_FLOW_RUNTIME_OWNER = JSON.stringify({ ...input.runtimeOwner, adapter_executable: input.executable });
|
|
54
54
|
}
|
|
55
55
|
return new Promise((resolve, reject) => {
|
|
56
|
+
if (input.signal?.aborted)
|
|
57
|
+
return reject(new AppError("harness_adapter_aborted", "Harness adapter call was cancelled before dispatch", 1));
|
|
56
58
|
const detached = process.platform !== "win32";
|
|
57
59
|
const child = spawn(input.executable, input.args, { env, ...(input.cwd ? { cwd: input.cwd } : {}), detached, stdio: ["ignore", "pipe", "pipe"] });
|
|
58
60
|
let stdout = "";
|
|
59
61
|
let stderr = "";
|
|
60
62
|
let outputTooLarge = false;
|
|
61
63
|
let timedOut = false;
|
|
64
|
+
let aborted = false;
|
|
62
65
|
let killTimer;
|
|
66
|
+
let settled = false;
|
|
67
|
+
let callbackError;
|
|
68
|
+
let spawnError;
|
|
63
69
|
const terminate = (signal) => { try {
|
|
64
70
|
if (detached && child.pid)
|
|
65
71
|
process.kill(-child.pid, signal);
|
|
@@ -67,39 +73,73 @@ export function runHarnessAdapter(input) {
|
|
|
67
73
|
child.kill(signal);
|
|
68
74
|
}
|
|
69
75
|
catch { /* The process already settled. */ } };
|
|
70
|
-
const
|
|
71
|
-
const
|
|
76
|
+
const stopTransport = () => { terminate("SIGTERM"); killTimer ??= setTimeout(() => terminate("SIGKILL"), 5_000); };
|
|
77
|
+
const deliver = (callback) => {
|
|
78
|
+
try {
|
|
79
|
+
const result = callback();
|
|
80
|
+
if (result && typeof result.then === "function") {
|
|
81
|
+
void Promise.resolve(result).catch(() => { });
|
|
82
|
+
throw new AppError("progress_callback_async", "Harness callbacks must be synchronous", 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
callbackError ??= error;
|
|
87
|
+
if (!settled)
|
|
88
|
+
stopTransport();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const heartbeat = input.progress && input.progressMessage ? setInterval(() => deliver(() => input.progress?.(`${input.progressMessage}; waiting for adapter completion`)), 15_000) : undefined;
|
|
92
|
+
const timer = setTimeout(() => { timedOut = true; stopTransport(); }, timeoutMs);
|
|
93
|
+
const abort = () => { aborted = true; stopTransport(); };
|
|
94
|
+
if (input.signal?.aborted)
|
|
95
|
+
abort();
|
|
96
|
+
else
|
|
97
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
72
98
|
const evidence = (value) => input.onEvidence?.({ operation_id: input.operationId, executable: input.executable, args: input.args, ...value, at: new Date().toISOString() });
|
|
73
99
|
const append = (current, chunk) => {
|
|
74
100
|
if (Buffer.byteLength(current) + Buffer.byteLength(chunk) > maxAdapterOutputBytes) {
|
|
75
101
|
outputTooLarge = true;
|
|
76
|
-
|
|
102
|
+
stopTransport();
|
|
77
103
|
return current;
|
|
78
104
|
}
|
|
79
105
|
return current + chunk;
|
|
80
106
|
};
|
|
81
107
|
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout = append(stdout, chunk); });
|
|
82
108
|
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr = append(stderr, chunk); });
|
|
83
|
-
child.once("error", (error) => {
|
|
84
|
-
clearInterval(heartbeat); if (killTimer)
|
|
85
|
-
clearTimeout(killTimer); evidence({ status: null, error: error.message }); reject(new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), error: error.message })); });
|
|
109
|
+
child.once("error", (error) => { spawnError = error; });
|
|
86
110
|
child.once("close", (status, signal) => {
|
|
111
|
+
if (settled)
|
|
112
|
+
return;
|
|
113
|
+
settled = true;
|
|
87
114
|
clearTimeout(timer);
|
|
115
|
+
input.signal?.removeEventListener("abort", abort);
|
|
88
116
|
if (heartbeat)
|
|
89
117
|
clearInterval(heartbeat);
|
|
90
|
-
if (killTimer)
|
|
118
|
+
if (killTimer) {
|
|
91
119
|
clearTimeout(killTimer);
|
|
92
|
-
|
|
120
|
+
terminate("SIGKILL");
|
|
121
|
+
}
|
|
122
|
+
const progressFailure = callbackError;
|
|
123
|
+
deliver(() => evidence({ status, signal, timed_out: timedOut, aborted, stdout, stderr, ...(spawnError ? { error: spawnError.message } : {}) }));
|
|
124
|
+
if (spawnError)
|
|
125
|
+
return reject(new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { cause: adapterCause(spawnError.message), ...(callbackError ? { evidence_error: String(callbackError) } : {}) }));
|
|
93
126
|
if (outputTooLarge)
|
|
94
127
|
return reject(new AppError("harness_adapter_output_limit", "Harness adapter exceeded the bounded reply limit", 1, { executable: input.executable, args: input.args.slice(0, 3), max_output_bytes: maxAdapterOutputBytes }));
|
|
95
128
|
if (timedOut)
|
|
96
129
|
return reject(new AppError("harness_adapter_timeout", "Harness adapter call timed out", 1, { executable: input.executable, args: input.args.slice(0, 3), timeout_ms: timeoutMs }));
|
|
130
|
+
if (aborted)
|
|
131
|
+
return reject(new AppError("harness_adapter_aborted", "Harness adapter call was cancelled", 1, { executable: input.executable, args: input.args.slice(0, 3) }));
|
|
97
132
|
if (status !== 0)
|
|
98
|
-
return reject(new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr }));
|
|
133
|
+
return reject(progressFailure ?? new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr, cause: adapterCause(stdout, stderr), ...(callbackError ? { evidence_error: String(callbackError) } : {}) }));
|
|
134
|
+
if (callbackError)
|
|
135
|
+
return reject(callbackError);
|
|
99
136
|
try {
|
|
100
137
|
const reply = JSON.parse(stdout);
|
|
101
138
|
if (!reply || typeof reply !== "object" || Array.isArray(reply))
|
|
102
139
|
throw new Error("not object");
|
|
140
|
+
const value = reply;
|
|
141
|
+
if (value.ok === false && typeof value.error?.code === "string")
|
|
142
|
+
return reject(new AppError("harness_adapter_failed", "Harness adapter reported a runtime failure", 1, { cause: adapterCause(stdout) }));
|
|
103
143
|
resolve(reply);
|
|
104
144
|
}
|
|
105
145
|
catch {
|
|
@@ -108,6 +148,18 @@ export function runHarnessAdapter(input) {
|
|
|
108
148
|
});
|
|
109
149
|
});
|
|
110
150
|
}
|
|
151
|
+
function adapterCause(...outputs) {
|
|
152
|
+
for (const output of outputs)
|
|
153
|
+
for (const line of [output.trim(), ...output.trim().split("\n").reverse()]) {
|
|
154
|
+
try {
|
|
155
|
+
const value = JSON.parse(line);
|
|
156
|
+
if (typeof value.error?.code === "string")
|
|
157
|
+
return { ...value.error, message: typeof value.error.message === "string" ? value.error.message : value.error.code };
|
|
158
|
+
}
|
|
159
|
+
catch { /* Adapter diagnostics need not be JSON. */ }
|
|
160
|
+
}
|
|
161
|
+
return { message: outputs.map(output => output.trim()).filter(Boolean).join("\n") || "adapter process failed" };
|
|
162
|
+
}
|
|
111
163
|
export function adapterSessionId(value) {
|
|
112
164
|
if (!value || typeof value !== "object")
|
|
113
165
|
return null;
|
package/dist/services/hooks.js
CHANGED
|
@@ -879,6 +879,31 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
879
879
|
return workLifecycleMatchKey("work_fail", workId, projectRoot);
|
|
880
880
|
return null;
|
|
881
881
|
}
|
|
882
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
883
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
884
|
+
export function observeCommandHook(context, command) {
|
|
885
|
+
const facts = lifecycleFacts(command);
|
|
886
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
887
|
+
return;
|
|
888
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
889
|
+
if (!project)
|
|
890
|
+
return; // Bootstrap has not registered a project yet.
|
|
891
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
892
|
+
if (!matchKey)
|
|
893
|
+
return;
|
|
894
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
895
|
+
if (!key) {
|
|
896
|
+
try {
|
|
897
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
return;
|
|
901
|
+
} // Dispatch still owns its mandatory admission error.
|
|
902
|
+
}
|
|
903
|
+
const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ? AND match_key = ? AND status = 'observed'", [project.id, key, matchKey]);
|
|
904
|
+
if (event)
|
|
905
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
906
|
+
}
|
|
882
907
|
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
883
908
|
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
884
909
|
}
|
|
@@ -935,6 +960,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
935
960
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
936
961
|
if (!event?.session_id)
|
|
937
962
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
963
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
938
964
|
return {
|
|
939
965
|
hookEventId: event.id,
|
|
940
966
|
harness: event.harness,
|
package/dist/services/lanes.js
CHANGED
|
@@ -515,7 +515,7 @@ function ensureQueuedLaneWaiter(context, input) {
|
|
|
515
515
|
function tryAcquireQueuedLaneLock(context, input) {
|
|
516
516
|
expireStaleLocks(context, input.projectId);
|
|
517
517
|
expireStaleWaiters(context, input.projectId);
|
|
518
|
-
context.db.
|
|
518
|
+
context.db.beginWriteTransaction();
|
|
519
519
|
try {
|
|
520
520
|
const existingLock = activeLaneLock(context, input.projectId, input.lane);
|
|
521
521
|
if (existingLock?.worker_id === input.workerId) {
|