@deksden-com/dd-flow-cli 0.9.0-beta.135 → 0.9.0-beta.137
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 +14 -0
- package/README.md +2 -0
- package/dist/build-info.json +7 -7
- package/dist/cli/command-inputs.js +1 -0
- package/dist/cli/run-cli.js +5 -0
- package/dist/harness-runtime/DD-ZCODE.md +13 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +14 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +36 -1
- package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
- package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -1
- package/dist/services/cli-operation-classifier.js +4 -0
- package/dist/services/controller-child-observer.js +99 -0
- package/dist/services/controller-fanout.js +9 -2
- package/dist/services/engines.js +1 -1
- package/dist/services/managed-processes.js +30 -0
- package/dist/services/run-controller.js +84 -8
- package/dist/services/run-health.js +149 -0
- package/dist/services/runs.js +61 -13
- package/dist/services/stage-lifecycle.js +2 -1
- package/dist/services/stage-report-renderer.js +13 -0
- package/dist/services/vnext-fanout.js +66 -1
- package/dist/services/vnext-protocolize.js +60 -2
- package/dist/storage/database.js +18 -3
- package/dist/storage/writer-contract.js +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.137
|
|
4
|
+
|
|
5
|
+
- Preserve shared reasoning/mode options in OpenCode v2 and support non-generative doctor before daemon startup.
|
|
6
|
+
|
|
7
|
+
### Patch Changes
|
|
8
|
+
|
|
9
|
+
- e65a615: Retain scoped native child failures through read-only ZCode observation and durable dispatch fences. Bind failure provenance to current Work/Session/controller identities. Publish materialized PROTOCOLIZE evidence separately from authored input, and replay completed stages without revalidating an archived worktree or regressing later stages.
|
|
10
|
+
|
|
11
|
+
## 0.9.0-beta.136
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 8c75ca6: Reconcile retained RUN health using physical ownership and controller settlement without dispatch or lease takeover. Persist target-stage pauses, preserve unknown historical timing, and leave future pending stages untouched after a failure.
|
|
16
|
+
|
|
3
17
|
## 0.9.0-beta.135
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -540,3 +540,5 @@ It does not claim physical settlement or alter its receipts. `--cancel-abandoned
|
|
|
540
540
|
explicitly cancels an abandoned fixture only when the project directory is absent
|
|
541
541
|
and it has no Work, Session, controller or recovery history. Historical experiment
|
|
542
542
|
engine snapshots remain pinned; rebuild/release the engine for future executions.
|
|
543
|
+
|
|
544
|
+
`dd-flow run reconcile [RUN] --json` проверяет сохранённые статусы без dispatch; `--apply` сохраняет согласованную health-проекцию. Возраст записи даёт предупреждение, а не доказательство ошибки. Ожидания и потеря владельца различаются; обслуживание не сигналит процессам и не перехватывает leases.
|
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-10-
|
|
3
|
+
"cli_version": "0.9.0-beta.137",
|
|
4
|
+
"cli_commit": "3a1e4cb4a0a24e388228ebb85591350fa4f5886b",
|
|
5
|
+
"built_at": "2026-10-14T05:52:24.329Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.2",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
|
-
"repo_root": "/Users/deksden/Documents/_Projects/
|
|
11
|
-
"memorybank_root": "/Users/deksden/Documents/_Projects/
|
|
12
|
-
"flow_root": "/Users/deksden/Documents/_Projects/
|
|
10
|
+
"repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
|
|
11
|
+
"memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
|
|
12
|
+
"flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
|
|
13
13
|
"layout": "dot_memory_bank"
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -80,6 +80,7 @@ export const commandInputs = {
|
|
|
80
80
|
"run start": route(0, `${project} flow-kind subject-type subject-id slug`, "workspace-root next-action preset task-profile-file execution-routing-file protocol-override-file flag", "", "flag"),
|
|
81
81
|
"run prepare-vnext-specify": route(0, `${project} slug`, "execution-routing-file"),
|
|
82
82
|
"run status": route(1, project),
|
|
83
|
+
"run reconcile": route([0, 1], "", "project-root no-progress-ms", "apply"),
|
|
83
84
|
"run list": route(0, project),
|
|
84
85
|
"run timeline": route(1, project, "hide", "", "hide"),
|
|
85
86
|
"run config status": route(1, project),
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -70,6 +70,7 @@ import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
|
70
70
|
import { prepareCommandRoute } from "./command-inputs.js";
|
|
71
71
|
import { parseLifecycleCommand } from "../services/lifecycle-command.js";
|
|
72
72
|
import { dispatchRuntimeProcess, prepareRuntimeProcess } from "./runtime-process.js";
|
|
73
|
+
import { reconcileFlowRunHealth } from "../services/run-health.js";
|
|
73
74
|
import { fenceRuntimeBudget, runtimeBudgetStatus, prepareRuntimeScopeControl, prepareRuntimeScopeFence } from "../services/runtime-budget.js";
|
|
74
75
|
import { stopRuntimeScope } from "../services/runtime-scope-stop.js";
|
|
75
76
|
import { launchRuntimeScopeControl, launchRuntimeScopeResume, prepareRuntimeScopeReconciliation, prepareRuntimeScopeServing, reconcileRuntimeScopeControl, serveRuntimeScopeControl } from "../services/runtime-scope-worker.js";
|
|
@@ -505,6 +506,8 @@ function resolveProjectRootBeforeRouting(args, env, deadlineAt) {
|
|
|
505
506
|
function databaseModeForCommand(args, classification) {
|
|
506
507
|
if (isNativeHookIngress(args))
|
|
507
508
|
return "hook";
|
|
509
|
+
if (args[0] === "run" && args[1] === "reconcile")
|
|
510
|
+
return args.includes("--apply") ? "reconcile" : "read_existing";
|
|
508
511
|
if (isRuntimeMaintenance(args))
|
|
509
512
|
return "read_existing";
|
|
510
513
|
const [family, command] = args;
|
|
@@ -2474,6 +2477,8 @@ function recoveryResumeInput(parsed) {
|
|
|
2474
2477
|
return { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 1, "run-id"), recoveryId: requiredOption(parsed, "recovery-id"), daemonId: requiredOption(parsed, "daemon-id"), harness: requiredOption(parsed, "harness"), nativeSessionId: requiredOption(parsed, "native-session-id") };
|
|
2475
2478
|
}
|
|
2476
2479
|
function dispatchRun(context, command, parsed, prepared = {}) {
|
|
2480
|
+
if (command === "reconcile")
|
|
2481
|
+
return reconcileFlowRunHealth(context, { runId: parsed.positional[0], projectRoot: optionalOption(parsed, "project-root"), apply: hasOption(parsed, "apply"), noProgressMs: optionalNumber(parsed, "no-progress-ms") });
|
|
2477
2482
|
if (command === "recovery") {
|
|
2478
2483
|
const action = requiredPosition(parsed, 0, "recovery action");
|
|
2479
2484
|
const projectRoot = requiredOption(parsed, "project-root");
|
|
@@ -18,6 +18,19 @@ closed transport is not proof that its native descendants stopped. The adapter
|
|
|
18
18
|
must retain causal errors, missing observations and usage completeness rather
|
|
19
19
|
than turn them into successful/zero-valued results.
|
|
20
20
|
|
|
21
|
+
## Retained child observations
|
|
22
|
+
|
|
23
|
+
Daemon capability `retained_child_observation` enables `session observe-children`
|
|
24
|
+
for its registered root. It reads retained topology and exact terminal metadata,
|
|
25
|
+
never loads/resumes a child or performs semantic work. During fanout the controller
|
|
26
|
+
records failed child facts before dispatching anything else; healthy in-flight
|
|
27
|
+
siblings may settle normally. Observation polling is not productive model activity.
|
|
28
|
+
Ordinary `session inspect` is not a substitute for this read-only operation.
|
|
29
|
+
|
|
30
|
+
Native attribution is a reported fact, not proof that an HTTP request reached a
|
|
31
|
+
provider. If the native store discarded the causal exception, retain unknown;
|
|
32
|
+
do not scrape ambient logs or infer overload/retry authority from an error message.
|
|
33
|
+
|
|
21
34
|
The bridge marks heuristic ACP completion with
|
|
22
35
|
`_meta.zcodeCompletionEvidence: "inferred"` and exhausted failed turns with
|
|
23
36
|
`"failed"`. dd-zcode rejects these before any post-turn materializing read:
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
import { adapterTimeoutOptions } from "../lib/adapter-timeouts.mjs";
|
|
3
3
|
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
|
-
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-opencode-daemon.mjs";
|
|
5
|
+
import { callDaemon, doctor, openCodeProfileOptions, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-opencode-daemon.mjs";
|
|
6
6
|
|
|
7
7
|
function parse(argv) { const positional = [], options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json","cancel-tree","no-flow"].includes(key)) options[key] = true; else { if (argv[i + 1] === undefined) throw new Error(`--${key} requires a value`); options[key] = argv[++i]; } } return { positional, options }; }
|
|
8
8
|
const prompt = async options => options["prompt-file"] ? await readFile(options["prompt-file"], "utf8") : options.prompt;
|
|
9
9
|
try {
|
|
10
|
-
const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model,
|
|
10
|
+
const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model, ...openCodeProfileOptions(options), authPath: options["auth-path"], configPath: options["config-path"], projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, sessionArchive: options["session-archive"], ...adapterTimeoutOptions(options, family === "session" ? command : null) }; let result;
|
|
11
11
|
if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...common, entryPath: process.argv[1] }, startDaemon);
|
|
12
12
|
else if (family === "daemon" && command === "serve") { await serveDaemon(common.stateDir); process.exit(0); }
|
|
13
13
|
else if (family === "daemon" && command === "status") result = await callDaemon(common.stateDir, "daemon.status");
|
|
14
14
|
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(common.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
15
15
|
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(common.stateDir, options["operation-id"]);
|
|
16
16
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(common.stateDir, options["operation-id"]);
|
|
17
|
-
else if (family === "doctor") result = await
|
|
17
|
+
else if (family === "doctor") result = await doctor(common);
|
|
18
18
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: common.stateDir, cancelTree: options["cancel-tree"], timeoutMs: common.timeoutMs });
|
|
19
19
|
else if (family === "session") { const operation = command === "status" ? "inspect" : command; const params = { livenessTimeoutMs: common.livenessTimeoutMs, sessionId: options["session-id"], title: options.title, messageId: options["message-id"], archiveFile: options["archive-file"], ...(command === "prompt" ? { prompt: await prompt(options) } : {}) }; result = await callDaemon(common.stateDir, `session.${operation}`, params, common.timeoutMs ?? 1_800_000); }
|
|
20
20
|
else throw new Error("usage: dd-opencode daemon start|status|stop | session create|prompt|inspect|fork|cancel|archive [options] --json");
|
|
@@ -22,6 +22,19 @@ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmD
|
|
|
22
22
|
const REQUEST_SCHEMA = "dd-opencode/daemon-request@1", RESPONSE_SCHEMA = "dd-opencode/daemon-response@1", STATE_SCHEMA = "dd-opencode/daemon-state@2", ARCHIVE_SCHEMA = "dd-opencode/session-archive@2";
|
|
23
23
|
class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
|
|
24
24
|
const absolute = (value, label) => { if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`); return path.resolve(value); };
|
|
25
|
+
export function openCodeProfileOptions(options) {
|
|
26
|
+
for (const [native, shared] of [["variant", "reasoning"], ["agent", "mode"]]) {
|
|
27
|
+
if (options[native] !== undefined && options[shared] !== undefined && options[native] !== options[shared]) throw new DaemonError("profile_conflict", `--${native} and --${shared} disagree`);
|
|
28
|
+
}
|
|
29
|
+
return { variant: options.variant ?? options.reasoning, agent: options.agent ?? options.mode };
|
|
30
|
+
}
|
|
31
|
+
export async function doctor(options) {
|
|
32
|
+
if (options.stateDir) return await callDaemon(options.stateDir, "daemon.doctor");
|
|
33
|
+
const output = (await run(options.bin ?? process.env.DD_OPENCODE_BIN ?? "opencode", ["--version"], { cwd: options.cwd ?? process.cwd() })).trim();
|
|
34
|
+
const version = output.match(/(?:^|\s)v?(2\.\d+\.\d+(?:[-+][\w.-]+)?)(?:\s|$)/)?.[1];
|
|
35
|
+
if (!version) throw new DaemonError("unsupported_version", "dd-opencode supports OpenCode v2 only");
|
|
36
|
+
return { compatible: true, native_protocol: 2, observed_runtime: { opencode: version }, productive_hooks: false, profile_observation: "pending_native_session" };
|
|
37
|
+
}
|
|
25
38
|
function locations(stateDir) { const dir = absolute(stateDir, "--state-dir"), local = path.join(dir, "daemon.sock"); return { dir, socket: Buffer.byteLength(local) < 100 ? local : `/tmp/dd-opencode-${createHash("sha256").update(dir).digest("hex").slice(0, 20)}.sock`, state: path.join(dir, "daemon.json"), journal: path.join(dir, "events.jsonl"), log: path.join(dir, "daemon.log"), data: path.join(dir, "xdg-data"), config: path.join(dir, "xdg-config"), cache: path.join(dir, "xdg-cache"), archive: path.join(dir, "import.json"), db: path.join(dir, "native.db"), xdgState: path.join(dir, "xdg-state") }; }
|
|
26
39
|
const writeJson = writeJsonAtomic;
|
|
27
40
|
async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
|
|
@@ -272,7 +285,7 @@ export class Runtime {
|
|
|
272
285
|
if (info.pid !== this.child?.pid || info.version !== this.state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode endpoint is not the owned process");
|
|
273
286
|
const pluginID = this.state.config.noFlow ? "dd-flow-security" : "dd-flow";
|
|
274
287
|
if (!plugins.data.some(plugin => plugin.id === pluginID && plugin.state?.status === "active")) throw new DaemonError("opencode_plugin_missing", "Owned v2 plugin is not active");
|
|
275
|
-
return { compatible: true, native_protocol: 2, productive_hooks: !this.state.config.noFlow, versions: this.state.versions, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status };
|
|
288
|
+
return { compatible: true, native_protocol: 2, observed_runtime: this.state.versions, productive_hooks: !this.state.config.noFlow, versions: this.state.versions, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status };
|
|
276
289
|
}
|
|
277
290
|
if (operation === "session.create") return this.productive(operation, async () => {
|
|
278
291
|
const info = await this.client.create(params.title, this.state.config);
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
promptSessionWithBridge,
|
|
25
25
|
zcodeChildSessionIds,
|
|
26
26
|
observeClosedZcodeTree,
|
|
27
|
+
readZcodeSubagents,
|
|
27
28
|
} from "./dd-zcode.mjs";
|
|
28
29
|
|
|
29
30
|
const REQUEST_SCHEMA = "dd-zcode/daemon-request@1";
|
|
@@ -203,6 +204,7 @@ export async function stopDaemon(options) {
|
|
|
203
204
|
export class DaemonRuntime {
|
|
204
205
|
constructor(paths, state, bridge, initialized) {
|
|
205
206
|
this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized;
|
|
207
|
+
this.state.capabilities = { ...(this.state.capabilities ?? {}), retained_child_observation: true };
|
|
206
208
|
this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item]));
|
|
207
209
|
this.liveAdapters = new Set();
|
|
208
210
|
this.activeProductive = null; this.server = null; this.persisting = Promise.resolve();
|
|
@@ -439,13 +441,46 @@ export class DaemonRuntime {
|
|
|
439
441
|
|
|
440
442
|
async dispatch(operation, params, operationId = null) {
|
|
441
443
|
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
444
|
+
if (operation === "session.observe-children") {
|
|
445
|
+
assertDaemonDispatch(this.paths.dir);
|
|
446
|
+
const root = this.sessions.get(params.sessionId);
|
|
447
|
+
if (!root || root.close_receipt) throw new DaemonError("session_identity_mismatch", "Retained observation requires a registered open root Session");
|
|
448
|
+
const generation = this.controlGeneration ?? 0;
|
|
449
|
+
const active = this.state.active_request;
|
|
450
|
+
const activeIdentity = value => JSON.stringify([value?.operation_id ?? null, value?.session_id ?? null, value?.adapter_session_id ?? null, value?.generation ?? null]);
|
|
451
|
+
const capturedIdentity = activeIdentity(active);
|
|
452
|
+
if (active && active.session_id !== params.sessionId) throw new DaemonError("session_identity_mismatch", "Retained observation has a foreign active root");
|
|
453
|
+
const cacheScope = `${params.sessionId}:${generation}`;
|
|
454
|
+
if (this.childObservationScope !== cacheScope) { this.childObservationScope = cacheScope; this.childTerminalCache = new Map(); this.childObservationKeys = new Set(); }
|
|
455
|
+
const subagents = await readZcodeSubagents(this.bridge, root.adapter_session_id, 30_000, true, this.childTerminalCache);
|
|
456
|
+
if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "Observation generation was retired");
|
|
457
|
+
if (activeIdentity(this.state.active_request) !== capturedIdentity) throw new DaemonError("operation_cancelled", "Observation belongs to a retired active Turn");
|
|
458
|
+
assertDaemonDispatch(this.paths.dir);
|
|
459
|
+
const receipt = { schema_id: "dd-zcode/child-observation@1", daemon_id: this.state.daemon_id, provider_session_id: params.sessionId,
|
|
460
|
+
generation, active_operation_id: active?.operation_id ?? null, subagents };
|
|
461
|
+
const failed = (subagents.ended?.items ?? subagents.completed ?? []).filter(child => child.status === "failed" && child.terminal_turn_id);
|
|
462
|
+
const fresh = failed.filter(child => !this.childObservationKeys.has(`${child.childSessionId}:${child.terminal_turn_id}`));
|
|
463
|
+
if (fresh.length) {
|
|
464
|
+
await this.bridge.journal.write("native_child_observation", { ...receipt, subagents: { running: [], ended: { total: fresh.length, items: fresh } } });
|
|
465
|
+
await this.bridge.journal.flush();
|
|
466
|
+
if (this.state.config?.journal) {
|
|
467
|
+
const journal = await open(this.state.config.journal, "a");
|
|
468
|
+
try { await journal.sync(); } finally { await journal.close(); }
|
|
469
|
+
}
|
|
470
|
+
for (const child of fresh) this.childObservationKeys.add(`${child.childSessionId}:${child.terminal_turn_id}`);
|
|
471
|
+
}
|
|
472
|
+
if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "Observation generation was retired before ACK");
|
|
473
|
+
if (activeIdentity(this.state.active_request) !== capturedIdentity) throw new DaemonError("operation_cancelled", "Observation Turn changed before ACK");
|
|
474
|
+
assertDaemonDispatch(this.paths.dir);
|
|
475
|
+
return receipt;
|
|
476
|
+
}
|
|
442
477
|
if (operation === "daemon.status") return {
|
|
443
478
|
daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
|
|
444
479
|
cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
|
|
445
480
|
started_at: this.state.started_at, updated_at: this.state.updated_at,
|
|
446
481
|
shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status,
|
|
447
482
|
active_tree: this.state.active_tree, active_operation: this.activeProductive, active_request: this.state.active_request ?? null,
|
|
448
|
-
sessions: [...this.sessions.values()], config: this.state.config,
|
|
483
|
+
sessions: [...this.sessions.values()], config: this.state.config, capabilities: { retained_child_observation: true },
|
|
449
484
|
};
|
|
450
485
|
if (operation === "hook.resolve") {
|
|
451
486
|
const sessionId = String(params.sessionId ?? "");
|
|
@@ -544,7 +544,7 @@ async function resume(bridge, sessionId, cwd) {
|
|
|
544
544
|
|
|
545
545
|
/** Ended children are paginated by the native API (20 by default). A partial
|
|
546
546
|
* directory is never settlement evidence, including during teardown. */
|
|
547
|
-
export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false) {
|
|
547
|
+
export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false, terminalCache = new Map()) {
|
|
548
548
|
const clock = new ObservationClock({ timeoutMs });
|
|
549
549
|
const cursors = new Set(), ended = new Map();
|
|
550
550
|
let first, cursor;
|
|
@@ -575,6 +575,8 @@ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000,
|
|
|
575
575
|
if (child.status !== "failed" || !Number.isSafeInteger(child.startedAt) || child.startedAt <= 0
|
|
576
576
|
|| !Number.isSafeInteger(child.endedAt) || child.endedAt < child.startedAt || child.endedAt > 8.64e15) { items.push(child); continue; }
|
|
577
577
|
// Closed children must never be loaded merely to explain their failure.
|
|
578
|
+
const cacheKey = JSON.stringify([child.childSessionId, child.startedAt, child.endedAt]);
|
|
579
|
+
if (terminalCache.has(cacheKey)) { items.push(terminalCache.get(cacheKey)); continue; }
|
|
578
580
|
let terminal;
|
|
579
581
|
try { terminal = await bridge.request("zcode/session/retainedTerminal", { sessionId: child.childSessionId, startedAt: child.startedAt, endedAt: child.endedAt }, timeoutMs); }
|
|
580
582
|
catch (error) { items.push({ ...child, terminal_metadata_unavailable: error.code ?? "terminal_metadata_unavailable" }); continue; }
|
|
@@ -584,8 +586,10 @@ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000,
|
|
|
584
586
|
|| terminal.startedAt !== child.startedAt || terminal.endedAt !== child.endedAt
|
|
585
587
|
|| !Number.isFinite(observedAt) || observedAt < child.startedAt || observedAt > child.endedAt) fail("ZCode terminal failure metadata has foreign identity", "native_child_turn_conflict");
|
|
586
588
|
const providerError = safeNativeFailure(terminal.provider_error);
|
|
587
|
-
|
|
588
|
-
...(providerError ? { provider_error: providerError } : {}) }
|
|
589
|
+
const item = { ...child, terminal_turn_id: terminal.terminal_turn_id, terminal_observed_at: terminal.terminal_observed_at,
|
|
590
|
+
...(providerError ? { provider_error: providerError } : {}) };
|
|
591
|
+
terminalCache.set(cacheKey, item);
|
|
592
|
+
items.push(item);
|
|
589
593
|
clock.sample(child.childSessionId);
|
|
590
594
|
}
|
|
591
595
|
return { ...first, ended: { total: first.ended.total, items } };
|
|
@@ -29,7 +29,9 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook) {
|
|
|
29
29
|
delete event.env.OPENCODE_PASSWORD; delete event.env.OPENCODE_SERVER_PASSWORD;
|
|
30
30
|
});
|
|
31
31
|
await ctx.tool.hook("execute.before", async event => {
|
|
32
|
-
if (event.tool !== "shell"
|
|
32
|
+
if (event.tool !== "shell") return;
|
|
33
|
+
if (event.input.timeout === undefined) event.input.timeout = 0;
|
|
34
|
+
if (!binding.ddFlowBin) return;
|
|
33
35
|
const budget = hookBudget(), payload = await envelope("before", event, budget);
|
|
34
36
|
event.input = { ...event.input, workdir: payload.session.directory };
|
|
35
37
|
const result = await deliver({ ...payload, input: event.input }, budget);
|
|
@@ -6,6 +6,8 @@ export function classifyCliOperation(args, env = process.env) {
|
|
|
6
6
|
if (isMbUpgradeMode(args, env)) {
|
|
7
7
|
return { mode: "mb_upgrade", operation, family, command, action };
|
|
8
8
|
}
|
|
9
|
+
if (family === "run" && command === "reconcile" && !args.includes("--apply"))
|
|
10
|
+
return { mode: "read_only_diagnostics", operation, family, command, action };
|
|
9
11
|
if (isRouterNative(args)) {
|
|
10
12
|
return { mode: "router_native", operation, family, command, action };
|
|
11
13
|
}
|
|
@@ -85,6 +87,8 @@ function isRouterNative(args) {
|
|
|
85
87
|
return true;
|
|
86
88
|
if (["engine", "version", "schema"].includes(first))
|
|
87
89
|
return true;
|
|
90
|
+
if (first === "run" && args[1] === "reconcile")
|
|
91
|
+
return true;
|
|
88
92
|
if (first === "storage" && args[1] === "migrate-writer")
|
|
89
93
|
return true;
|
|
90
94
|
return (first === "codex" && args[1] === "hook" && args[2] === "handle")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
|
|
3
|
+
/** A durable observation is a dispatch fence, not authority to complete Work. */
|
|
4
|
+
export function retainedChildObserver(input) {
|
|
5
|
+
let active = true, pending;
|
|
6
|
+
let gap = null;
|
|
7
|
+
const retained = new Map();
|
|
8
|
+
const sameScope = (scope) => scope?.controller_id === input.scope.controller_id && scope.generation === input.scope.generation && scope.stage === input.scope.stage && scope.root_session_id === input.scope.root_session_id;
|
|
9
|
+
const normalized = (child) => {
|
|
10
|
+
if (!child || child.parent_session_id !== input.scope.root_session_id || child.status !== "failed" || typeof child.terminal_turn_id !== "string" || ![child.session_id, child.terminal_turn_id, child.source].every(value => typeof value === "string" && value.length > 0 && value.length <= 256))
|
|
11
|
+
throw new Error("native_child_observation_invalid");
|
|
12
|
+
const provider_error = safeNativeFailure(child.provider_error);
|
|
13
|
+
return { session_id: child.session_id, parent_session_id: child.parent_session_id, status: "failed", source: child.source, terminal_turn_id: child.terminal_turn_id,
|
|
14
|
+
...(typeof child.terminal_observed_at === "string" && Number.isFinite(Date.parse(child.terminal_observed_at)) ? { terminal_observed_at: child.terminal_observed_at } : {}),
|
|
15
|
+
...(provider_error ? { provider_error } : {}) };
|
|
16
|
+
};
|
|
17
|
+
const accept = (children) => {
|
|
18
|
+
for (const raw of children) {
|
|
19
|
+
const child = normalized(raw);
|
|
20
|
+
retained.set(`${child.session_id}:${child.terminal_turn_id}`, child);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
if (fs.existsSync(input.file))
|
|
24
|
+
for (const line of fs.readFileSync(input.file, "utf8").split("\n").filter(Boolean)) {
|
|
25
|
+
const observation = JSON.parse(line);
|
|
26
|
+
if (sameScope(observation.scope)) {
|
|
27
|
+
if (Array.isArray(observation.children))
|
|
28
|
+
accept(observation.children);
|
|
29
|
+
if (Object.hasOwn(observation, "gap"))
|
|
30
|
+
gap = observation.gap ?? null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const complete = (children) => {
|
|
34
|
+
if (!active)
|
|
35
|
+
return;
|
|
36
|
+
input.assertOwner();
|
|
37
|
+
const failed = children.filter(child => child.parent_session_id === input.scope.root_session_id && child.status === "failed" && child.terminal_turn_id && !retained.has(`${child.session_id}:${child.terminal_turn_id}`)).map(normalized);
|
|
38
|
+
if (!failed.length && !gap)
|
|
39
|
+
return;
|
|
40
|
+
const fd = fs.openSync(input.file, "a", 0o600);
|
|
41
|
+
try {
|
|
42
|
+
fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, children: failed, gap: null })}\n`);
|
|
43
|
+
fs.fsyncSync(fd);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
accept(failed);
|
|
49
|
+
gap = null;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
failed: () => [...retained.values()],
|
|
53
|
+
gap: () => gap,
|
|
54
|
+
drain: () => pending ?? Promise.resolve(),
|
|
55
|
+
completePostTurn(receipt) {
|
|
56
|
+
const evidence = receipt.evidence;
|
|
57
|
+
const tree = evidence?.subagents;
|
|
58
|
+
if (receipt.provider_session_id !== input.scope.root_session_id || receipt.observation_error || evidence?.observation_error || !evidence?.read?.projection || !evidence.read.session || !tree || tree.observation_error || !Array.isArray(tree.running))
|
|
59
|
+
return false;
|
|
60
|
+
if (tree.ended ? !Array.isArray(tree.ended.items) || tree.ended.total !== tree.ended.items.length || tree.ended.nextCursor : !Array.isArray(tree.completed))
|
|
61
|
+
return false;
|
|
62
|
+
const children = normalizeNativeChildren(receipt, input.scope.root_session_id);
|
|
63
|
+
if (children.some(child => child.status === "failed" && !child.terminal_turn_id))
|
|
64
|
+
return false;
|
|
65
|
+
complete(children);
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
tick() {
|
|
69
|
+
if (!active || pending)
|
|
70
|
+
return;
|
|
71
|
+
pending = (async () => {
|
|
72
|
+
const children = await input.read();
|
|
73
|
+
if (!active || children === null)
|
|
74
|
+
return;
|
|
75
|
+
complete(children);
|
|
76
|
+
})().catch(error => {
|
|
77
|
+
if (!active)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
input.assertOwner();
|
|
81
|
+
const code = String(error?.code ?? "observation_unavailable");
|
|
82
|
+
gap = /^[\w-]{1,128}$/.test(code) ? code : "observation_unavailable";
|
|
83
|
+
const fd = fs.openSync(input.file, "a", 0o600);
|
|
84
|
+
try {
|
|
85
|
+
fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, gap })}\n`);
|
|
86
|
+
fs.fsyncSync(fd);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
fs.closeSync(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch { /* Lost owner cannot publish; the gap remains an explicit caller error. */ }
|
|
93
|
+
input.gap(error);
|
|
94
|
+
}).finally(() => { pending = undefined; });
|
|
95
|
+
},
|
|
96
|
+
// The caller aborts its request; a late reply cannot publish after closure.
|
|
97
|
+
close() { active = false; return pending ?? Promise.resolve(); },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AppError, errorRecord } from "../shared/errors.js";
|
|
2
2
|
import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
|
|
3
|
-
import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey } from "./vnext-fanout.js";
|
|
3
|
+
import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey, verifiedNativeWorkFailure } from "./vnext-fanout.js";
|
|
4
4
|
import { getFlowRunVariables } from "./runs.js";
|
|
5
5
|
import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
|
|
6
6
|
import { resolveExecutionPolicy } from "./execution-policy.js";
|
|
@@ -54,8 +54,11 @@ export async function nextControllerFanout(context, input) {
|
|
|
54
54
|
throw new AppError("fanout_reconciliation_required", "Native children have unresolved lifecycle ownership", 1, { issues });
|
|
55
55
|
}
|
|
56
56
|
let fanout = getVnextFanoutStatus(context, input).orchestration;
|
|
57
|
-
if (!fanout)
|
|
57
|
+
if (!fanout) {
|
|
58
|
+
if (input.dispatchFence)
|
|
59
|
+
throw new AppError("fanout_reconciliation_required", "Observed native failure has no current graph; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
|
|
58
60
|
return { kind: "none" };
|
|
61
|
+
}
|
|
59
62
|
if (fanout.state === "compatibility_required")
|
|
60
63
|
throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
|
|
61
64
|
if (children.length) {
|
|
@@ -67,6 +70,8 @@ export async function nextControllerFanout(context, input) {
|
|
|
67
70
|
return { kind: "wait", reason: "native_children_unsettled" };
|
|
68
71
|
fanout = getVnextFanoutStatus(context, input).orchestration;
|
|
69
72
|
}
|
|
73
|
+
if (input.dispatchFence && !fanout.works.failed)
|
|
74
|
+
throw new AppError("fanout_reconciliation_required", "Observed native failure has no proven failed Work; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
|
|
70
75
|
if (fanout.state === "dispatch_required") {
|
|
71
76
|
dispatchVnextPlanReview(context, input);
|
|
72
77
|
fanout = getVnextFanoutStatus(context, input).orchestration;
|
|
@@ -88,7 +93,9 @@ export async function nextControllerFanout(context, input) {
|
|
|
88
93
|
}
|
|
89
94
|
catch { /* Missing legacy metadata is unknown. */ }
|
|
90
95
|
const providerError = safeNativeFailure(receipt.provider_error);
|
|
96
|
+
const nativeBinding = verifiedNativeWorkFailure(context, { projectRoot: input.projectRoot, runId: input.runId, stage: input.stage, parentSessionId: input.sessionId, parentWorkId: fanout.parent_work_id, workId: work.work_id, receipt });
|
|
91
97
|
return { work_id: work.work_id, status: work.status, ...(providerError ? { provider_error: providerError } : {}),
|
|
98
|
+
...(nativeBinding ? { native_binding: nativeBinding } : {}),
|
|
92
99
|
...Object.fromEntries(["terminal_turn_id", "terminal_observed_at"].filter(key => typeof receipt[key] === "string" && receipt[key].length <= 256).map(key => [key, receipt[key]])) };
|
|
93
100
|
});
|
|
94
101
|
throw new AppError("fanout_child_failed", "A child Work failed or was cancelled; no retry authority has been issued", 1, { stage: input.stage, run_id: input.runId, parent_work_id: fanout.parent_work_id, failed: fanout.works.failed, cancelled: fanout.works.cancelled, failures, effect: "unknown" });
|
package/dist/services/engines.js
CHANGED
|
@@ -30,7 +30,7 @@ export function isRouterNativeCommand(args) {
|
|
|
30
30
|
return true;
|
|
31
31
|
if (args.includes("--help") || args.includes("-h"))
|
|
32
32
|
return true;
|
|
33
|
-
return routerNativeFamilies.has(first) || first === "codex" && args[1] === "hook" && args[2] === "handle";
|
|
33
|
+
return first === "run" && args[1] === "reconcile" || routerNativeFamilies.has(first) || first === "codex" && args[1] === "hook" && args[2] === "handle";
|
|
34
34
|
}
|
|
35
35
|
export function installCurrentEngine(context, input = {}) {
|
|
36
36
|
const installation = prepareInstallation(context, input);
|
|
@@ -289,6 +289,36 @@ export function processIsAlive(record) {
|
|
|
289
289
|
const started = processStartedAt(record.pid);
|
|
290
290
|
return !record.pid_started_at || !started || record.pid_started_at === started;
|
|
291
291
|
}
|
|
292
|
+
/** Unlike an admission guard, observability must distinguish unknown from
|
|
293
|
+
* alive. Unknown identity still cannot authorize termination or finalization. */
|
|
294
|
+
export function managedProcessObservation(record) {
|
|
295
|
+
if (!record.pid || !record.pid_started_at)
|
|
296
|
+
return 'unknown';
|
|
297
|
+
try {
|
|
298
|
+
process.kill(record.pid, 0);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
return error.code === 'ESRCH' ? 'absent' : 'unknown';
|
|
302
|
+
}
|
|
303
|
+
const reply = spawnSync('ps', ['-o', 'lstart=,stat=', '-p', String(record.pid)], { encoding: 'utf8', timeout: 1000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } });
|
|
304
|
+
if (reply.status !== 0 || !reply.stdout.trim())
|
|
305
|
+
return 'unknown';
|
|
306
|
+
const parts = reply.stdout.trim().split(/\s+/), state = parts.pop();
|
|
307
|
+
if (state?.startsWith('Z'))
|
|
308
|
+
return 'absent';
|
|
309
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
310
|
+
if (Object.hasOwn(metadata, 'process_birth')) {
|
|
311
|
+
const expected = parseBirth(metadata.process_birth);
|
|
312
|
+
if (!expected)
|
|
313
|
+
return 'unknown';
|
|
314
|
+
const observed = `ps-lstart-utc-c@1:${parts.join(' ')}`;
|
|
315
|
+
return expected.replace(/\s+/g, ' ') === observed ? 'alive' : 'absent';
|
|
316
|
+
}
|
|
317
|
+
// Legacy lstart lacks a recorded timezone. A mismatch might be timezone
|
|
318
|
+
// drift, so only equal identity or a missing PID establishes an outcome.
|
|
319
|
+
const legacy = processStartedAt(record.pid);
|
|
320
|
+
return legacy && legacy === record.pid_started_at ? 'alive' : 'unknown';
|
|
321
|
+
}
|
|
292
322
|
export function processTreeIsAlive(record) {
|
|
293
323
|
if (processIsAlive(record))
|
|
294
324
|
return true;
|