@deksden-com/dd-flow-cli 0.9.0-beta.106 → 0.9.0-beta.108
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 +12 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/native-hook-ingress.js +48 -0
- package/dist/cli/run-cli.js +7 -0
- package/dist/cli.js +4 -2
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +7 -9
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-zcode.mjs +4 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +28 -6
- package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -0
- package/dist/services/lifecycle-invocations.js +16 -2
- package/dist/services/vnext-plan-review.js +7 -7
- package/dist/services/vnext-plan.js +2 -2
- package/dist/services/vnext-protocolize.js +12 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.108
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Route native tool hooks through the prepared hook store and skip storage entirely for unrelated tools. Give ZCode daemon readiness a bounded 30-second window and clean up the exact registered provider process if any managed daemon fails to start, preserving the primary startup error.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.107
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Give a corrective successor only for proven no-effect PROTOCOLIZE, PLAN, PLAN-REVIEW, and Work input errors, including invalid corrected PLAN schemas and missing evidence references; keep broken runtime artifacts fatal. Tell PLAN reviewers the exact workspace-relative PLAN path. Apply ZCode's profile as one unarmed configuration phase and verify the final native profile before productive work, so transient native mode notifications cannot masquerade as drift.
|
|
14
|
+
|
|
3
15
|
## 0.9.0-beta.106
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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.108",
|
|
4
|
+
"cli_commit": "0df3cd1e7201f27baeeb07f1c4f2b69ed6e2399b",
|
|
5
|
+
"built_at": "2026-09-27T04:24:32.044Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
2
|
+
import { parseCommandArgs } from "../services/lifecycle-command.js";
|
|
3
|
+
/** These ingress commands consume a prepared store; they never prepare it. */
|
|
4
|
+
export function isNativeHookIngress(args) {
|
|
5
|
+
return args[2] === "handle" && ((args[1] === "event" && ["agy", "grok", "droid", "opencode", "zcode"].includes(args[0] ?? ""))
|
|
6
|
+
|| args[0] === "zcode" && args[1] === "hook");
|
|
7
|
+
}
|
|
8
|
+
export async function dispatchNativeHook(args, stdin, env) {
|
|
9
|
+
const parsed = parseCommandArgs(args.slice(3));
|
|
10
|
+
const projectRoot = parsed.options.get("project-root")?.[0];
|
|
11
|
+
if (!isNativeHookIngress(args) || parsed.positional.length || !projectRoot || projectRoot === "__dd_flow_flag__")
|
|
12
|
+
throw new AppError("usage", "Native hook requires --project-root", 2);
|
|
13
|
+
for (const [name, values] of parsed.options)
|
|
14
|
+
if (!["project-root", "json"].includes(name) || values.length !== 1 || name === "json" && values[0] !== "__dd_flow_flag__")
|
|
15
|
+
throw new AppError("usage", `Invalid native hook option: ${name}`, 2);
|
|
16
|
+
const [hooks, { createContext }, { resolveDdFlowHome }] = await Promise.all([
|
|
17
|
+
import("../services/hooks.js"), import("../runtime/context.js"), import("../storage/paths.js")
|
|
18
|
+
]);
|
|
19
|
+
let opened;
|
|
20
|
+
// Reuse the actual envelope validators and shell parser. They return for
|
|
21
|
+
// unrelated tools before their first DB access, without duplicating policy.
|
|
22
|
+
const context = {
|
|
23
|
+
env, ddFlowHome: resolveDdFlowHome(env), now: () => new Date().toISOString(),
|
|
24
|
+
get db() { return (opened ??= createContext(env, "hook")).db; }
|
|
25
|
+
};
|
|
26
|
+
const handler = args[0] === "agy" ? hooks.handleAgyEvent : args[0] === "grok" ? hooks.handleGrokEvent
|
|
27
|
+
: args[0] === "droid" ? hooks.handleDroidEvent : args[0] === "opencode" ? hooks.handleOpenCodeEvent
|
|
28
|
+
: args[1] === "hook" ? hooks.handleZcodeHook : hooks.handleZcodeEvent;
|
|
29
|
+
try {
|
|
30
|
+
return await handler(context, { projectRoot, stdin });
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
opened?.db.close?.();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function runNativeHookIngress(args) {
|
|
37
|
+
try {
|
|
38
|
+
const { readNativeHookInput, hookBudget } = await import("../harness-runtime/lib/native-hook-command.mjs");
|
|
39
|
+
const stdin = await readNativeHookInput(process.stdin, hookBudget());
|
|
40
|
+
process.stdout.write(`${JSON.stringify(await dispatchNativeHook(args, stdin, process.env))}\n`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const record = errorRecord(error);
|
|
45
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: record })}\n`);
|
|
46
|
+
return record.exitCode;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../servic
|
|
|
8
8
|
import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
|
|
9
9
|
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, bindLifecyclePublishedPacket, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, retainObservedLifecycleSemantics, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
|
|
10
10
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
11
|
+
import { dispatchNativeHook, isNativeHookIngress } from "./native-hook-ingress.js";
|
|
11
12
|
import { migrateStoreWriter } from "../storage/writer-migration.js";
|
|
12
13
|
import { helpForArgs } from "./help.js";
|
|
13
14
|
import { AppError, isAppError } from "../shared/errors.js";
|
|
@@ -129,6 +130,10 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
129
130
|
return 0;
|
|
130
131
|
}
|
|
131
132
|
const suppliedInvocationId = optionalOption(parseArgs(output.args.slice(2)), "invocation-id");
|
|
133
|
+
if (isNativeHookIngress(output.args)) {
|
|
134
|
+
writeJson(io.stdout, await dispatchNativeHook(output.args, await readStdin(io.stdin), env));
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
132
137
|
if (output.args[0] === "run" && ["drive", "control"].includes(output.args[1] ?? "")) {
|
|
133
138
|
const controlArgs = parseArgs(output.args.slice(2));
|
|
134
139
|
requiredOption(controlArgs, "run");
|
|
@@ -411,6 +416,8 @@ function resolveProjectRootBeforeRouting(args, env) {
|
|
|
411
416
|
}
|
|
412
417
|
}
|
|
413
418
|
function databaseModeForCommand(args, classification) {
|
|
419
|
+
if (isNativeHookIngress(args))
|
|
420
|
+
return "hook";
|
|
414
421
|
const [family, command] = args;
|
|
415
422
|
// Managed attempts are issued only from a prepared store. Opening that
|
|
416
423
|
// existing writer must not run migrations before their input is admitted.
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { isNativeHookIngress } from "./cli/native-hook-ingress.js";
|
|
2
3
|
const args = process.argv.slice(2);
|
|
3
4
|
const managedHook = Boolean(process.env.DD_FLOW_CODEX_STATE_DIR) && args.slice(0, 3).join(" ") === "codex hook handle" && !args.includes("--help") && !args.includes("-h");
|
|
4
5
|
const exitCode = managedHook
|
|
5
6
|
? await (await import("./cli/hook-ingress.js")).runHookIngress(args)
|
|
6
|
-
:
|
|
7
|
+
: isNativeHookIngress(args) && !args.includes("--help") && !args.includes("-h")
|
|
8
|
+
? await (await import("./cli/native-hook-ingress.js")).runNativeHookIngress(args)
|
|
9
|
+
: await (await import("./cli/run-cli.js")).runCli(args);
|
|
7
10
|
process.exitCode = exitCode;
|
|
8
|
-
export {};
|
|
@@ -12,7 +12,7 @@ import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { observeModel } from "./model-observations.mjs";
|
|
14
14
|
import { AgyError, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
|
|
15
|
-
import { prepareRuntimeOwner, cleanupFailedStart,
|
|
15
|
+
import { prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
16
16
|
|
|
17
17
|
const REQUEST_SCHEMA = "dd-agy/daemon-request@1", RESPONSE_SCHEMA = "dd-agy/daemon-response@1", STATE_SCHEMA = "dd-agy/daemon-state@1";
|
|
18
18
|
class DaemonError extends AgyError {}
|
|
@@ -206,12 +206,7 @@ export async function startDaemon(options) {
|
|
|
206
206
|
}
|
|
207
207
|
throw new DaemonError("daemon_start_failed", "Antigravity daemon did not become ready", true);
|
|
208
208
|
} catch (error) {
|
|
209
|
-
await
|
|
210
|
-
const persisted = await readState(paths.dir).catch(() => null);
|
|
211
|
-
if (persisted?.provider_process) {
|
|
212
|
-
try { await runtimeProcess(config, "stop", { id: persisted.provider_process.id, "lease-token": persisted.provider_process.lease_token }); }
|
|
213
|
-
catch (cleanup) { error.details = { ...error.details, provider_cleanup_failed: errorPayload(cleanup), provider_process_id: persisted.provider_process.id }; }
|
|
214
|
-
}
|
|
209
|
+
await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), config.daemonId);
|
|
215
210
|
throw error;
|
|
216
211
|
}
|
|
217
212
|
}
|
|
@@ -251,7 +246,10 @@ export class Runtime {
|
|
|
251
246
|
void this.readyPromise.catch(() => {});
|
|
252
247
|
this.providerProcess = await registerDaemonProcess(this.config, { kind: "agy-provider", owner: `agy:${path.basename(this.paths.dir)}`, operation: `agy-provider:${path.basename(this.paths.dir)}`, stdout: this.paths.log, stderr: this.paths.log });
|
|
253
248
|
const providerProcess = this.providerProcess;
|
|
254
|
-
|
|
249
|
+
try { await this.persist({ provider_process: providerProcess }); }
|
|
250
|
+
catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
|
|
251
|
+
try { this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] }); }
|
|
252
|
+
catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
|
|
255
253
|
const providerChild = this.child;
|
|
256
254
|
let allowProviderFinalization;
|
|
257
255
|
const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
|
|
@@ -283,7 +281,7 @@ export class Runtime {
|
|
|
283
281
|
});
|
|
284
282
|
});
|
|
285
283
|
void this.providerFinalization.catch(() => {});
|
|
286
|
-
try { await confirmDaemonProcess(this.config, providerProcess, this.child); await
|
|
284
|
+
try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_pid: this.child.pid }); await heartbeatDaemonProcess(this.config, providerProcess); }
|
|
287
285
|
catch (error) { await cleanupFailedStart(this.config, providerProcess, this.child, error); throw error; }
|
|
288
286
|
finally { allowProviderFinalization(); }
|
|
289
287
|
let readyTimer;
|
|
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
|
|
|
9
9
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
10
10
|
import { errorRecord } from "./operation-errors.mjs";
|
|
11
11
|
import { CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
|
|
12
|
-
import { prepareRuntimeOwner,
|
|
12
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
13
13
|
|
|
14
14
|
const requestSchema = "dd-codex/daemon-request@1";
|
|
15
15
|
const responseSchema = "dd-codex/daemon-response@1";
|
|
@@ -90,7 +90,7 @@ export async function startDaemon(options) {
|
|
|
90
90
|
await removeSocket(paths.socket); const resourceProcess = await registerDaemonProcess(config, { kind: "codex-daemon", owner: `codex:${path.basename(paths.dir)}`, operation: `codex-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log }); const state = { schema_id: stateSchema, daemon_id: randomUUID(), pid: null, cwd: config.cwd, journal: config.journal, config, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", sessions: previous?.sessions ?? [], resource_process: resourceProcess }; await writeState(paths.state, state);
|
|
91
91
|
const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [options.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
92
92
|
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 60_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 5_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed" }); }
|
|
93
|
-
catch (error) { await
|
|
93
|
+
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.state), state.daemon_id); throw error; }
|
|
94
94
|
}
|
|
95
95
|
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
|
|
96
96
|
|
|
@@ -12,7 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { AcpBridge } from "./dd-zcode.mjs";
|
|
14
14
|
import { cancelSessionWithBridge, createSessionWithBridge, doctor, forkSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge } from "./dd-grok.mjs";
|
|
15
|
-
import { prepareRuntimeOwner,
|
|
15
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
16
16
|
|
|
17
17
|
const REQUEST_SCHEMA = "dd-grok/daemon-request@1";
|
|
18
18
|
const RESPONSE_SCHEMA = "dd-grok/daemon-response@1";
|
|
@@ -171,7 +171,7 @@ export async function startDaemon(options) {
|
|
|
171
171
|
const state = { descendants: previous?.descendants ?? [], schema_id: STATE_SCHEMA, daemon_id: daemonId, pid: null, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", active_tree: false, recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start", auth_status: copiedAuth ? "copied" : "absent", ...(importedSession ? { imported_session: importedSession } : {}), versions: versions.versions, config_isolation: configIsolation, config: { ...config, daemonId }, sessions: importedSession ? [{ provider_session_id: importedSession.provider_session_id, adapter_session_id: importedSession.provider_session_id, parent_provider_session_id: null, root_provider_session_id: importedSession.provider_session_id, cwd: importedSession.source_cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess };
|
|
172
172
|
await writeState(paths.state, state); const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [absolute(options.entryPath, "dd-grok entry"), "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(state.config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
173
173
|
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (status.ready) return status; } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
|
|
174
|
-
catch (error) { await
|
|
174
|
+
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), daemonId); throw error; }
|
|
175
175
|
}
|
|
176
176
|
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
|
|
177
177
|
|
|
@@ -5,7 +5,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "
|
|
|
5
5
|
import net from "node:net";
|
|
6
6
|
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { prepareRuntimeOwner,
|
|
8
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AcpBridge,
|
|
@@ -197,7 +197,7 @@ export async function startDaemon(options) {
|
|
|
197
197
|
await log.close();
|
|
198
198
|
try {
|
|
199
199
|
await confirmDaemonProcess(config, resourceProcess, child);
|
|
200
|
-
const deadline = Date.now() +
|
|
200
|
+
const deadline = Date.now() + 30_000;
|
|
201
201
|
while (Date.now() < deadline) {
|
|
202
202
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
203
203
|
try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
|
|
@@ -205,7 +205,7 @@ export async function startDaemon(options) {
|
|
|
205
205
|
}
|
|
206
206
|
throw new DaemonError("daemon_start_failed", "daemon did not become ready", true);
|
|
207
207
|
} catch (error) {
|
|
208
|
-
await
|
|
208
|
+
await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), state.daemon_id);
|
|
209
209
|
throw error;
|
|
210
210
|
}
|
|
211
211
|
}
|
|
@@ -666,6 +666,8 @@ async function controlledIdentity(bridge, options) {
|
|
|
666
666
|
}
|
|
667
667
|
|
|
668
668
|
async function applyProfile(bridge, sessionId, requested, assertDispatch = () => {}) {
|
|
669
|
+
const identity = bridge.modelSessions?.get(sessionId);
|
|
670
|
+
if (identity) identity.armed = false;
|
|
669
671
|
assertDispatch();
|
|
670
672
|
if (requested.mode) await bridge.request("session/set_mode", { sessionId, modeId: requested.mode });
|
|
671
673
|
assertDispatch();
|
|
@@ -674,10 +676,11 @@ async function applyProfile(bridge, sessionId, requested, assertDispatch = () =>
|
|
|
674
676
|
if (requested.model) await bridge.request("session/setModel", { sessionId, modelId: requested.provider ? `${requested.provider}\\${requested.model}` : requested.model });
|
|
675
677
|
assertDispatch();
|
|
676
678
|
const read = await bridge.request("zcode/session/read", { sessionId });
|
|
679
|
+
await bridge.notifications;
|
|
677
680
|
const observed = observedProfile(read);
|
|
678
681
|
await bridge.recordModel?.(sessionId, observed, "zcode/session/read.applied");
|
|
679
682
|
const receipt = assertProfile(requested, observed);
|
|
680
|
-
|
|
683
|
+
if (identity) identity.armed = true;
|
|
681
684
|
return receipt;
|
|
682
685
|
}
|
|
683
686
|
|
|
@@ -196,14 +196,17 @@ export async function startManagedBridge({ config, state, bridge, kind, publish
|
|
|
196
196
|
state.pid = process.pid;
|
|
197
197
|
await publish(state);
|
|
198
198
|
const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
199
|
-
|
|
200
|
-
await confirmDaemonProcess(config, provider, child);
|
|
201
|
-
await heartbeatDaemonProcess(config, provider);
|
|
202
|
-
state.provider_pid = child.pid;
|
|
199
|
+
try {
|
|
203
200
|
state.provider_process = provider;
|
|
204
201
|
await publish(state);
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
bridge.options.onSpawn = async child => {
|
|
203
|
+
await confirmDaemonProcess(config, provider, child);
|
|
204
|
+
state.provider_pid = child.pid;
|
|
205
|
+
await publish(state);
|
|
206
|
+
await heartbeatDaemonProcess(config, provider);
|
|
207
|
+
};
|
|
208
|
+
return await bridge.start();
|
|
209
|
+
}
|
|
207
210
|
catch (error) { await cleanupFailedStart(config, provider, bridge.child, error); throw error; }
|
|
208
211
|
}
|
|
209
212
|
|
|
@@ -260,6 +263,25 @@ export async function cleanupFailedStart(config, record, child, original) {
|
|
|
260
263
|
}
|
|
261
264
|
}
|
|
262
265
|
|
|
266
|
+
/** The provider has its own process group. A failed daemon start must settle
|
|
267
|
+
* that exact retained process as well as the daemon; PID-only cleanup is unsafe. */
|
|
268
|
+
export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild, original, readState, daemonId) {
|
|
269
|
+
await cleanupFailedStart(config, daemonRecord, daemonChild, original);
|
|
270
|
+
if (!daemonRecord) return;
|
|
271
|
+
try {
|
|
272
|
+
const state = await readState();
|
|
273
|
+
const owner = config.runtime_owner;
|
|
274
|
+
if (!state || state.daemon_id !== daemonId || state.resource_process?.id !== daemonRecord.id || state.resource_process?.lease_token !== daemonRecord.lease_token
|
|
275
|
+
|| (owner && (state.config?.runtime_owner?.owner_id !== owner.owner_id || path.resolve(state.config?.runtime_owner?.state_dir ?? "") !== path.resolve(owner.state_dir)))) {
|
|
276
|
+
throw Object.assign(new Error("Failed daemon state no longer matches the startup owner"), { code: "daemon_cleanup_identity_mismatch" });
|
|
277
|
+
}
|
|
278
|
+
const provider = state.provider_process;
|
|
279
|
+
if (provider) await runtimeProcess(config, "stop", { id: provider.id, "lease-token": provider.lease_token });
|
|
280
|
+
} catch (cleanup) {
|
|
281
|
+
original.details = { ...original.details, provider_cleanup_failed: errorRecord(cleanup) };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
263
285
|
export async function stopProcessGroup(child, graceMs = 1_000) {
|
|
264
286
|
if (!child?.pid) return;
|
|
265
287
|
// Harness bridges are detached on Unix, so their own process group is the
|
|
@@ -1 +1,3 @@
|
|
|
1
1
|
export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
|
|
2
|
+
export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { remaining(phase: string): number };
|
|
3
|
+
export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
|
|
@@ -1302,8 +1302,6 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
1302
1302
|
checkCommand(initial, input.command);
|
|
1303
1303
|
// A terminal command is immutable evidence. Do not require a now-gone
|
|
1304
1304
|
// caller payload merely to replay its recorded result.
|
|
1305
|
-
if (["issued", "observed"].includes(initial.status))
|
|
1306
|
-
await input.prepare?.();
|
|
1307
1305
|
}
|
|
1308
1306
|
catch (error) {
|
|
1309
1307
|
if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
|
|
@@ -1311,6 +1309,22 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
1311
1309
|
Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
|
|
1312
1310
|
throw error;
|
|
1313
1311
|
}
|
|
1312
|
+
if (["issued", "observed"].includes(initial.status)) {
|
|
1313
|
+
try {
|
|
1314
|
+
await input.prepare?.();
|
|
1315
|
+
}
|
|
1316
|
+
catch (error) {
|
|
1317
|
+
if (!(error instanceof AppError) || (error.details.effect !== undefined && error.details.effect !== "no_effect"))
|
|
1318
|
+
throw error;
|
|
1319
|
+
const details = { ...error.details, phase: "prepare", effect: "no_effect" };
|
|
1320
|
+
const recoverable = error.exitCode === 2 && ["validation", "schema_validation"].includes(error.code)
|
|
1321
|
+
? error.details.recoverable !== false
|
|
1322
|
+
: isRetryableLifecycleRejection(error.code, details);
|
|
1323
|
+
Object.assign(error.details, { phase: "prepare", effect: "no_effect", recoverable });
|
|
1324
|
+
Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
|
|
1325
|
+
throw error;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1314
1328
|
const timeoutMs = input.timeoutMs ?? 30000;
|
|
1315
1329
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
1316
1330
|
throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
|
|
@@ -265,7 +265,7 @@ function finishPlanReviewOwned(context, input) {
|
|
|
265
265
|
if (evidenceFailures.length)
|
|
266
266
|
throw new AppError("review_evidence_invalid", "PLAN-REVIEW reviewer evidence is incomplete, stale, or not isolated", 2, { errors: evidenceFailures });
|
|
267
267
|
if (decision.outcome !== "accepted")
|
|
268
|
-
throw new AppError("validation", "Unsupported PLAN-REVIEW decision outcome", 2, { outcome: decision.outcome });
|
|
268
|
+
throw new AppError("validation", "Unsupported PLAN-REVIEW decision outcome", 2, { outcome: decision.outcome, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
269
269
|
// A completed reviewer can legitimately report `blocked` when an upstream
|
|
270
270
|
// plan uncertainty prevents it from approving its aspect. That is evidence
|
|
271
271
|
// for the coordinator to classify and fix in this one review pass, not an
|
|
@@ -283,21 +283,21 @@ function finishPlanReviewOwned(context, input) {
|
|
|
283
283
|
throw new AppError("review_evidence_invalid", "Reviewer findings and coordinator decisions must use unique known canonical references, with every material finding decided", 2, { material_finding_ids: materialFindingIds, decided_finding_ids: decisionFindingIds, unknown_decisions: unknownDecisions });
|
|
284
284
|
if (needsCorrection) {
|
|
285
285
|
if (decision.correction.status !== "applied")
|
|
286
|
-
throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2);
|
|
286
|
+
throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
287
287
|
if (decision.correction.previous_plan_revision !== reviewedPlanRevision || planRevision <= reviewedPlanRevision || decision.correction.changed_paths.length === 0)
|
|
288
|
-
throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision });
|
|
288
|
+
throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
289
289
|
}
|
|
290
290
|
else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision || currentPlanChecksum !== contextFile.system?.plan_checksum) {
|
|
291
|
-
throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
|
|
291
|
+
throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
292
292
|
}
|
|
293
293
|
const prepared = prepareVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
|
|
294
294
|
if (prepared.failures.length)
|
|
295
|
-
throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: prepared.failures });
|
|
295
|
+
throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: prepared.failures, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
296
296
|
if (needsCorrection) {
|
|
297
297
|
// Regeneration above is authoritative. A valid PLAN correction need not
|
|
298
298
|
// change the derived CODE tasks (for example, a clarified explanation).
|
|
299
299
|
if (decision.correction.changed_paths.some((item) => item.endsWith("code-work-batch.json")))
|
|
300
|
-
throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
|
|
300
|
+
throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
301
301
|
}
|
|
302
302
|
const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
|
|
303
303
|
const finalBatchChecksum = prepared.batchChecksum;
|
|
@@ -358,7 +358,7 @@ function reviewGroups(home, workspaceRoot) {
|
|
|
358
358
|
if (dependencyGroup && dependencyGroup !== key)
|
|
359
359
|
dependencies.add(dependencyGroup);
|
|
360
360
|
}
|
|
361
|
-
groups.push({ key, protocol_id: protocolId, aspect_ids: group.aspect_ids, depends_on: [...dependencies].sort(), task: `Review PLAN group ${key}: ${group.aspect_ids.join(", ")}. Read run://${path.basename(home)}/03-plan/${protocolId}/aspect-map.json and the
|
|
361
|
+
groups.push({ key, protocol_id: protocolId, aspect_ids: group.aspect_ids, depends_on: [...dependencies].sort(), task: `Review PLAN group ${key}: ${group.aspect_ids.join(", ")}. Read run://${path.basename(home)}/03-plan/${protocolId}/aspect-map.json and .memory-bank/protocol/${protocolId}/plan.json under workspace_root. Cite the PLAN using that workspace-relative path, not a run:// ref. Try to falsify ambiguous behavior and missing verification. For every selected check that starts fixture/reset, service, or client processes, verify that they share the intended data/configuration world and that any runtime entrypoint requiring change is explicit in a Work task and verification and ordered before its consumer. planned_write_areas are coordination hints, not ownership. Do not edit plan or product files. Return one aspect entry for every assigned aspect, with exact evidence references and concise findings. Finish with the JSON contract supplied in your work prompt.` });
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
return groups;
|
|
@@ -126,7 +126,7 @@ export function prepareVnextPlanFinish(context, input) {
|
|
|
126
126
|
assertProtocolWorkspace(run.workspace_root, protocols);
|
|
127
127
|
const prepared = prepareVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols });
|
|
128
128
|
if (prepared.failures.length)
|
|
129
|
-
throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures });
|
|
129
|
+
throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
130
130
|
return prepared;
|
|
131
131
|
}
|
|
132
132
|
export function finishVnextPlan(context, input) {
|
|
@@ -148,7 +148,7 @@ function finishVnextPlanOwned(context, input) {
|
|
|
148
148
|
const batch = path.join(root, "code-work-batch.json");
|
|
149
149
|
const prepared = input.prepared ?? prepareVnextPlanFinish(context, input);
|
|
150
150
|
if (prepared.failures.length)
|
|
151
|
-
throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures });
|
|
151
|
+
throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
152
152
|
const work = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND task = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [project.id, run.id, planTask]);
|
|
153
153
|
if (!work)
|
|
154
154
|
throw new AppError("invalid_work_state", "PLAN has no running Work", 1);
|
|
@@ -183,11 +183,19 @@ export function finishVnextProtocolize(context, input) {
|
|
|
183
183
|
const resultFile = prepareProtocolizeResultPath(requireRunHome(run), input.resultFile);
|
|
184
184
|
const result = input.result ?? readResult(resultFile, { projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
|
|
185
185
|
validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, data: result, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
|
|
186
|
-
if (result.outcome !== "protocolized")
|
|
187
|
-
throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
|
|
188
186
|
const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
let obligations;
|
|
188
|
+
try {
|
|
189
|
+
if (result.outcome !== "protocolized")
|
|
190
|
+
throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
|
|
191
|
+
obligations = effectiveObligations(result, acceptedObligations);
|
|
192
|
+
validateDelivery(projectRoot, result, obligations);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (!(error instanceof AppError) || error.code !== "validation" || error.exitCode !== 2)
|
|
196
|
+
throw error;
|
|
197
|
+
throw new AppError(error.code, error.message, error.exitCode, { ...error.details, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
198
|
+
}
|
|
191
199
|
const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
|
|
192
200
|
const receipt = readWorkspaceRouteReceipt(root);
|
|
193
201
|
const workspaceRoot = run.workspace_root;
|