@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.124
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 +10 -0
- package/README.md +23 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/run-cli.js +1 -1
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +100 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +66 -16
- package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +39 -10
- package/dist/harness-runtime/lib/dd-codex.mjs +11 -25
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +19 -12
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +42 -10
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +57 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +25 -23
- package/dist/harness-runtime/lib/dd-zcode.mjs +9 -10
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- package/dist/harness-runtime/lib/managed-daemon.d.mts +2 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +100 -32
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/services/code-checks.js +14 -18
- package/dist/services/codex-capacity.js +149 -48
- package/dist/services/controller-fanout.js +1 -0
- package/dist/services/external-work-launch.js +10 -3
- package/dist/services/harness-adapter.js +21 -15
- package/dist/services/managed-processes.js +14 -6
- package/dist/services/merge-server.js +10 -4
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-controller-adapter.js +8 -5
- package/dist/services/run-controller-recovery.js +17 -0
- package/dist/services/run-controller.js +42 -14
- package/dist/services/run-recovery-runtime.js +49 -3
- package/dist/services/runs.js +1 -1
- package/dist/services/verification-matrix.js +30 -1
- package/dist/services/vnext-code-review.js +16 -4
- package/dist/services/vnext-merge.js +20 -2
- package/dist/services/vnext-plan-review.js +81 -18
- package/dist/services/work-registry.js +9 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.124
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Stop only two confirmed continuation overload refusals within the inclusive two-minute window, preserving native refusal times and exact owner admission across recovery.
|
|
8
|
+
|
|
9
|
+
Retain final PLAN check obligations, completion markers and native output bytes in verification evidence; require canonical fixes for duplicated CODE P0/P1 findings.
|
|
10
|
+
|
|
11
|
+
Join native daemon cleanup before durable stop acknowledgement and reconcile already-closed provider bridges without replay.
|
|
12
|
+
|
|
3
13
|
## 0.9.0-beta.123
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
See [dd-zcode integration](src/harness-runtime/DD-ZCODE.md) for native transport,
|
|
4
4
|
adapter ownership and the versioned bridge contract.
|
|
5
5
|
|
|
6
|
+
## Native daemon shutdown
|
|
7
|
+
|
|
8
|
+
`daemon.stop` fences productive dispatch before awaiting settlement. A successful
|
|
9
|
+
durable stop receipt follows native tree settlement, physical provider exit,
|
|
10
|
+
journal drain and required resource/auth finalization. The endpoint then retires
|
|
11
|
+
before ACK; clients also verify the retained daemon PID has exited. A missing
|
|
12
|
+
socket alone is not physical shutdown proof.
|
|
13
|
+
|
|
14
|
+
Partial cleanup preserves the same incarnation's completed phases in
|
|
15
|
+
`daemon.json` (`dd-flow/daemon-shutdown@1`). A fresh control operation can finish
|
|
16
|
+
remaining cleanup without replaying agent work or inspecting a closed pipe.
|
|
17
|
+
Failed operation IDs remain immutable. Unknown ownership and persistent EPERM
|
|
18
|
+
remain failures; never remove sockets or repair runtime databases manually to
|
|
19
|
+
make an outcome look clean.
|
|
20
|
+
|
|
21
|
+
Shutdown proof records `required_phases`; every recorded phase must be complete,
|
|
22
|
+
including native tree and daemon resource settlement. A `clean` label without
|
|
23
|
+
this proof cannot authorize retained restart. `stopping`, `cleanup_failed` and
|
|
24
|
+
`unclean` cannot start a replacement native bridge. Forced AGY termination
|
|
25
|
+
without a current native terminal receipt is unclean, even after a previous
|
|
26
|
+
successful Turn. Mandatory stream-journal failures remain failures after
|
|
27
|
+
physical cleanup. Listener retirement must not unlink a replacement endpoint.
|
|
28
|
+
|
|
6
29
|
## Managed temporary HTTP services
|
|
7
30
|
|
|
8
31
|
For an interactive scenario, `runtime process start --run <RUN-ID>
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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-
|
|
3
|
+
"cli_version": "0.9.0-beta.124",
|
|
4
|
+
"cli_commit": "7bc8e8d17471c2cd33a93ca25e55770a4cb6e336",
|
|
5
|
+
"built_at": "2026-10-01T12:53:56.722Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.2",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "2e57b987ec91b7c3b0fa97f6169047802a1233fb",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -2658,7 +2658,7 @@ async function dispatchRuntime(context, command, parsed, progress, prepared = {}
|
|
|
2658
2658
|
if (!run || run.status !== "running" && !retainedHitlPrompt && !acceptedHitlPrompt && !["judge", "probe"].includes(metadata.role ?? ""))
|
|
2659
2659
|
throw new AppError("invalid_run_state", "Subject dispatch requires a running RUN or its retained recovery/HITL prompt", 1);
|
|
2660
2660
|
if (!recovering)
|
|
2661
|
-
assertCapacityOwnerAdmission(context, record, optionalOption(parsed, "operation-id"));
|
|
2661
|
+
assertCapacityOwnerAdmission(context, record, optionalOption(parsed, "operation-id"), { nativeSessionId: optionalOption(parsed, "native-session-id"), promptSha256: optionalOption(parsed, "prompt-sha256") });
|
|
2662
2662
|
}
|
|
2663
2663
|
const budget = reserveProviderTurn(context, record, { operationId: optionalOption(parsed, "operation-id") ?? "", operation: optionalOption(parsed, "operation") ?? "", sessionId: optionalOption(parsed, "native-session-id"), daemonId: optionalOption(parsed, "daemon-id") });
|
|
2664
2664
|
return { ok: true, ...budget, process_id: record.id, generation: metadata.run_generation ?? null };
|
|
@@ -3,7 +3,7 @@ export const vnextStages = [
|
|
|
3
3
|
{ id: "specify", directory: "01-specify", result: "specify.json", schema: "dd-flow/vnext-specify-result@4", next: ["protocolize"] },
|
|
4
4
|
{ id: "protocolize", directory: "02-protocolize", result: "protocolize-result.json", schema: "dd-flow/vnext-protocolize-result@3", next: ["plan"] },
|
|
5
5
|
{ id: "plan", directory: "03-plan", result: "stage-report.json", schema: "dd-flow/stage-report@2", next: ["plan-review", "code"] },
|
|
6
|
-
{ id: "plan-review", directory: "04-plan-review", result: "decision.json", schema: "dd-flow/plan-review-decision@
|
|
6
|
+
{ id: "plan-review", directory: "04-plan-review", result: "decision.json", schema: "dd-flow/plan-review-decision@4", next: ["code"] },
|
|
7
7
|
{ id: "code", directory: "05-code", result: "code-verification.json", schema: "dd-flow/code-verification@2", next: ["code-review", "merge"] },
|
|
8
8
|
{ id: "code-review", directory: "06-code-review", result: "decision.json", schema: "dd-flow/code-review-decision@3", next: ["merge"] },
|
|
9
9
|
{ id: "merge", directory: "07-merge", result: "stage-report.json", schema: "dd-flow/stage-report@2", next: [] }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const CAPACITY_POLICY: "codex-overload-burst@1";
|
|
2
|
+
export const OVERLOAD_WINDOW_MS: number;
|
|
3
|
+
export type CapacityRefusal = { policy: string; ordinal: number; session_id: string; turn_id: string; first_observed_at: string; native_terminal_at?: string; native_terminal_basis?: string };
|
|
4
|
+
export function parseCapacityOperation(id: string): { root: string; ordinal: number };
|
|
5
|
+
export function normalizeCodexFailure(error: unknown, sessionId?: string, turnId?: string): null | { code: string; session_id: string; turn_id: string; native_error: Record<string, unknown>; observed_at: unknown; terminal_at: unknown; native_turn_items: unknown; primary_error: { details?: Record<string, unknown> } };
|
|
6
|
+
export function terminalCodexOverload(error: unknown, sessionId?: string, turnId?: string): boolean;
|
|
7
|
+
export function nativeItemsSettled(items: unknown): boolean;
|
|
8
|
+
export function capacityBackoff(error: unknown, ordinal: number, now?: number): number;
|
|
9
|
+
export function refusalObservation(error: unknown, ordinal: number, firstObservedAt: string): CapacityRefusal;
|
|
10
|
+
export function overloadBurst(refusals: CapacityRefusal[]): null | { policy: string; basis: string; elapsed_ms: number; refusals: CapacityRefusal[] };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Pure policy shared by the controller and pinned-engine EVAL consumers. */
|
|
2
|
+
export const CAPACITY_POLICY = "codex-overload-burst@1";
|
|
3
|
+
export const OVERLOAD_WINDOW_MS = 120_000;
|
|
4
|
+
|
|
5
|
+
function policyError(code, message, details = {}) { return Object.assign(new Error(message), { code, details }); }
|
|
6
|
+
|
|
7
|
+
export function parseCapacityOperation(id) {
|
|
8
|
+
if (typeof id !== "string" || !id) throw policyError("capacity_continuation_conflict", "Missing capacity operation identity");
|
|
9
|
+
const match = /^(.*):capacity:(\d+):session\.prompt$/.exec(id);
|
|
10
|
+
if (!match) return { root: id, ordinal: 0 };
|
|
11
|
+
const ordinal = Number(match[2]);
|
|
12
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 1 || String(ordinal) !== match[2]) throw policyError("capacity_continuation_conflict", "Capacity ordinal is not a canonical positive safe integer", { operation_id: id });
|
|
13
|
+
return { root: match[1], ordinal };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeCodexFailure(error, expectedSessionId, expectedTurnId) {
|
|
17
|
+
let current = error;
|
|
18
|
+
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) {
|
|
19
|
+
if (current.code === "turn_interrupted") {
|
|
20
|
+
const details = current.details ?? {}, native = details.provider_error;
|
|
21
|
+
const code = typeof native?.codexErrorInfo === "string" ? native.codexErrorInfo : native?.codexErrorInfo?.code;
|
|
22
|
+
if (details.terminal_status !== "failed" || typeof details.provider_session_id !== "string" || !details.provider_session_id
|
|
23
|
+
|| expectedSessionId !== undefined && details.provider_session_id !== expectedSessionId
|
|
24
|
+
|| typeof details.turn_id !== "string" || !details.turn_id || details.native_turn_id !== details.turn_id
|
|
25
|
+
|| expectedTurnId !== undefined && details.turn_id !== expectedTurnId || typeof code !== "string") return null;
|
|
26
|
+
return { code, session_id: details.provider_session_id, turn_id: details.turn_id, native_error: native,
|
|
27
|
+
observed_at: details.observed_at ?? null, terminal_at: details.terminal_at ?? null, terminal_basis: details.terminal_basis ?? null,
|
|
28
|
+
native_turn_items: details.native_turn_items ?? null, primary_error: current };
|
|
29
|
+
}
|
|
30
|
+
// No arbitrary recursive cause search: an ownership/hook/lifecycle error wins.
|
|
31
|
+
if (current.code !== "harness_adapter_failed") return null;
|
|
32
|
+
current = current.details?.cause ?? current.cause;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function terminalCodexOverload(error, expectedSessionId, expectedTurnId) {
|
|
38
|
+
return normalizeCodexFailure(error, expectedSessionId, expectedTurnId)?.code === "serverOverloaded";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function nativeItemsSettled(items) {
|
|
42
|
+
return items?.observed === true && typeof items.possible_effects === "boolean" && items.pending !== true
|
|
43
|
+
&& (items.possible_effects === false || items.pending === false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function capacityBackoff(error, ordinal, now = Date.now()) {
|
|
47
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 1 || !Number.isFinite(now)) throw policyError("capacity_continuation_conflict", "Invalid capacity timing identity");
|
|
48
|
+
const native = normalizeCodexFailure(error)?.native_error ?? error?.details?.provider_error ?? error;
|
|
49
|
+
const header = Object.entries(native?.headers ?? {}).find(([key]) => key.toLowerCase() === "retry-after")?.[1];
|
|
50
|
+
const value = header ?? native?.retryAfter ?? native?.retry_after;
|
|
51
|
+
let retry = 0;
|
|
52
|
+
if (value !== undefined && value !== null) {
|
|
53
|
+
retry = typeof value === "number" || typeof value === "string" && /^\d+(\.\d+)?$/.test(value.trim()) ? Number(value) * 1000 : typeof value === "string" ? Math.max(0, Date.parse(value) - now) : NaN;
|
|
54
|
+
if (!Number.isFinite(retry) || retry < 0 || !Number.isFinite(now + retry) || now + retry > 8.64e15)
|
|
55
|
+
throw policyError("capacity_retry_metadata_invalid", "Retry-After cannot be represented safely", { retry_after: value });
|
|
56
|
+
}
|
|
57
|
+
return Math.max(ordinal === 1 ? 5_000 : 15_000, retry);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function refusalObservation(error, ordinal, firstObservedAt) {
|
|
61
|
+
const failure = normalizeCodexFailure(error);
|
|
62
|
+
const observed = Date.parse(firstObservedAt);
|
|
63
|
+
if (!failure || !Number.isSafeInteger(ordinal) || ordinal < 0 || !Number.isFinite(observed))
|
|
64
|
+
throw policyError("capacity_continuation_conflict", "Refusal lacks an immutable native identity/time");
|
|
65
|
+
if (failure.observed_at !== null && (typeof failure.observed_at !== "string" || !Number.isFinite(Date.parse(failure.observed_at)) || Date.parse(failure.observed_at) !== observed))
|
|
66
|
+
throw policyError("capacity_continuation_conflict", "Native refusal observation time is invalid or changed");
|
|
67
|
+
const native = failure.terminal_at && Date.parse(failure.terminal_at);
|
|
68
|
+
return { policy: CAPACITY_POLICY, ordinal, session_id: failure.session_id, turn_id: failure.turn_id,
|
|
69
|
+
first_observed_at: firstObservedAt, ...(Number.isFinite(native) && typeof failure.terminal_basis === "string" && failure.terminal_basis ? { native_terminal_at: failure.terminal_at, native_terminal_basis: failure.terminal_basis } : {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function overloadBurst(refusals) {
|
|
73
|
+
for (const [index, refusal] of refusals.entries()) {
|
|
74
|
+
const previous = refusals[index - 1];
|
|
75
|
+
if (typeof refusal.first_observed_at !== "string" || !Number.isFinite(Date.parse(refusal.first_observed_at))
|
|
76
|
+
|| previous && Date.parse(refusal.first_observed_at) < Date.parse(previous.first_observed_at))
|
|
77
|
+
throw policyError("capacity_continuation_conflict", "Refusal observation clock reversed or is invalid");
|
|
78
|
+
}
|
|
79
|
+
const continuations = refusals.filter(item => item.ordinal > 0);
|
|
80
|
+
const pair = continuations.slice(-2);
|
|
81
|
+
if (pair.length !== 2) return null;
|
|
82
|
+
const [previous, current] = pair;
|
|
83
|
+
if (previous.session_id !== current.session_id || previous.turn_id === current.turn_id || current.ordinal !== previous.ordinal + 1)
|
|
84
|
+
throw policyError("capacity_continuation_conflict", "Refusal chain identity is inconsistent");
|
|
85
|
+
const basis = previous.native_terminal_at && current.native_terminal_at && previous.native_terminal_basis && previous.native_terminal_basis === current.native_terminal_basis ? "native_terminal_at" : "first_observed_at";
|
|
86
|
+
const elapsed = Date.parse(current[basis]) - Date.parse(previous[basis]);
|
|
87
|
+
if (!Number.isFinite(elapsed) || elapsed < 0) throw policyError("capacity_continuation_conflict", "Refusal clock reversed or is invalid");
|
|
88
|
+
return elapsed <= OVERLOAD_WINDOW_MS ? { policy: CAPACITY_POLICY, basis, elapsed_ms: elapsed, refusals: pair } : null;
|
|
89
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
2
3
|
import { mkdir, readFile, readdir, link, unlink, open } from "node:fs/promises";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
@@ -10,6 +11,99 @@ import { readModelObservations, modelAttribution } from "./model-observations.mj
|
|
|
10
11
|
const productive = new Set(["session.create", "session.prompt", "session.start", "session.fork", "session.resume"]);
|
|
11
12
|
const controls = new Map();
|
|
12
13
|
const dispatchGenerations = new Map();
|
|
14
|
+
const shutdowns = new Map();
|
|
15
|
+
|
|
16
|
+
export function assertDaemonDispatch(root) {
|
|
17
|
+
const stop = shutdowns.get(root);
|
|
18
|
+
if (stop?.pending || stop?.committed) throw Object.assign(new Error("daemon shutdown fences productive dispatch"), { code: "daemon_stopping" });
|
|
19
|
+
}
|
|
20
|
+
export function assertDaemonRestartable(state) {
|
|
21
|
+
if (["unclean", "cleanup_failed", "stopping"].includes(state?.shutdown_state)) throw Object.assign(new Error("previous daemon shutdown is unresolved; do not restart a native bridge for cleanup"), { code: "daemon_state_terminal", details: { daemon_id: state.daemon_id ?? state.config?.daemonId, shutdown_state: state.shutdown_state } });
|
|
22
|
+
}
|
|
23
|
+
export function commitDaemonShutdown(root) {
|
|
24
|
+
if (typeof root !== 'string') return; // In-memory adapter diagnostics own no daemon endpoint.
|
|
25
|
+
const fence = shutdowns.get(root) ?? { pending: 0 };
|
|
26
|
+
fence.committed = true; shutdowns.set(root, fence);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The same incarnation may retry remaining cleanup, never a native Turn. */
|
|
30
|
+
export async function completeDaemonShutdown(root, state, persist, prepare, phases) {
|
|
31
|
+
const daemonId = state.daemon_id ?? state.config?.daemonId;
|
|
32
|
+
let shutdown = state.shutdown;
|
|
33
|
+
if (!shutdown || shutdown.daemon_id !== daemonId) shutdown = { schema_id: "dd-flow/daemon-shutdown@1", daemon_id: daemonId, generation: dispatchGenerations.get(root) ?? 0, phases: {} };
|
|
34
|
+
shutdown = { ...shutdown, required_phases: ["tree", ...phases.map(([phase]) => phase)] };
|
|
35
|
+
try {
|
|
36
|
+
if (!shutdown.phases.tree) {
|
|
37
|
+
const result = await prepare();
|
|
38
|
+
shutdown = { ...shutdown, phases: { tree: true }, tree_observed_at: new Date().toISOString(), result: result ?? { clean: true } };
|
|
39
|
+
commitDaemonShutdown(root);
|
|
40
|
+
await persist({ shutdown, shutdown_state: "stopping", active_tree: false });
|
|
41
|
+
}
|
|
42
|
+
commitDaemonShutdown(root);
|
|
43
|
+
for (const [phase, action] of phases) {
|
|
44
|
+
if (shutdown.phases[phase]) continue;
|
|
45
|
+
await action();
|
|
46
|
+
shutdown = { ...shutdown, phases: { ...shutdown.phases, [phase]: true } };
|
|
47
|
+
await persist({ shutdown });
|
|
48
|
+
}
|
|
49
|
+
const result = { ...shutdown.result, daemon_id: daemonId, stopped: true, settled: true, shutdown_contract: "dd-flow/daemon-shutdown@1", _shutdown: true };
|
|
50
|
+
await persist({ shutdown, shutdown_state: result.clean === true ? "clean" : "unclean", active_tree: false, stopped_at: new Date().toISOString(), cleanup_error: null });
|
|
51
|
+
return result;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code === "tree_not_settled" && !shutdowns.get(root)?.committed) throw error;
|
|
54
|
+
try { await persist({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); }
|
|
55
|
+
catch (storage) { error.details = { ...error.details, persistence_error: errorRecord(storage) }; }
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const daemonConnections = new WeakMap();
|
|
61
|
+
export function trackDaemonConnections(server) {
|
|
62
|
+
const sockets = new Set(); daemonConnections.set(server, sockets);
|
|
63
|
+
server.once("listening", () => {
|
|
64
|
+
const endpoint = server.address();
|
|
65
|
+
if (typeof endpoint !== "string") return;
|
|
66
|
+
try { const info = lstatSync(endpoint); daemonEndpoints.set(server, { endpoint, dev: info.dev, ino: info.ino }); }
|
|
67
|
+
catch (error) { daemonEndpoints.set(server, { endpoint, error }); }
|
|
68
|
+
});
|
|
69
|
+
server.on("connection", socket => { sockets.add(socket); socket.on("error", () => {}); socket.once("close", () => sockets.delete(socket)); });
|
|
70
|
+
}
|
|
71
|
+
const daemonEndpoints = new WeakMap();
|
|
72
|
+
|
|
73
|
+
/** Retire the listener after durable publication, before ACK; no provider IO. */
|
|
74
|
+
export function retireDaemonEndpoint(server, exitCode = 0) {
|
|
75
|
+
if (!server.listening) return;
|
|
76
|
+
const retained = daemonEndpoints.get(server);
|
|
77
|
+
let endpointError = retained?.error;
|
|
78
|
+
if (retained && !endpointError) {
|
|
79
|
+
try {
|
|
80
|
+
const current = lstatSync(retained.endpoint);
|
|
81
|
+
if (current.dev !== retained.dev || current.ino !== retained.ino || !current.isSocket()) endpointError = Object.assign(new Error("daemon endpoint was replaced; refusing to unlink another listener"), { code: "daemon_endpoint_identity_mismatch", details: { socket: retained.endpoint, phase: "retire_listener" } });
|
|
82
|
+
} catch (error) { if (error.code !== "ENOENT") endpointError = error; }
|
|
83
|
+
}
|
|
84
|
+
if (endpointError) {
|
|
85
|
+
// libuv's close unlinks by pathname, not inode. Exit without closing its
|
|
86
|
+
// listener handle when the name belongs to a different incarnation.
|
|
87
|
+
const timer = setTimeout(() => { for (const socket of daemonConnections.get(server) ?? []) socket.destroy(); process.exit(1); }, 1_000);
|
|
88
|
+
timer.unref();
|
|
89
|
+
throw endpointError;
|
|
90
|
+
}
|
|
91
|
+
const timer = setTimeout(() => { for (const socket of daemonConnections.get(server) ?? []) socket.destroy(); }, 1_000);
|
|
92
|
+
timer.unref();
|
|
93
|
+
server.close(() => { clearTimeout(timer); process.exit(exitCode); });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function retireFailedDaemon(server, state, error, persist) {
|
|
97
|
+
// An already-finished registry lease cannot own a listening productive daemon.
|
|
98
|
+
if (state.shutdown?.phases?.daemon_resource) {
|
|
99
|
+
// Durable result publication and listener retirement occur after the
|
|
100
|
+
// cleanup helper returns. Their failures still invalidate overall clean.
|
|
101
|
+
if (persist) try { await persist({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); }
|
|
102
|
+
catch (storage) { error.details = { ...error.details, persistence_error: errorRecord(storage) }; }
|
|
103
|
+
process.stderr.write(`${JSON.stringify({ shutdown_failure: errorRecord(error) })}\n`);
|
|
104
|
+
try { retireDaemonEndpoint(server, 1); } catch { /* Retirement already scheduled a nonzero exit without unlinking a foreign endpoint. */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
13
107
|
|
|
14
108
|
async function controlDispatch(root, request, action) {
|
|
15
109
|
const key = request.operation === "daemon.stop" ? `stop:${request.params?.cancelTree === true}` : `cancel:${request.params?.sessionId ?? ""}`;
|
|
@@ -122,13 +216,16 @@ export async function durableDaemonDispatch(root, request, action, observe) {
|
|
|
122
216
|
if (!productive.has(request.operation) && !control) return await action();
|
|
123
217
|
const generation = dispatchGenerations.get(root) ?? 0;
|
|
124
218
|
if (control) dispatchGenerations.set(root, generation + 1);
|
|
125
|
-
const assertCurrent = () => { if (generation !== (dispatchGenerations.get(root) ?? 0)) throw Object.assign(new Error("control cancelled a queued productive dispatch"), { code: "operation_cancelled" }); };
|
|
126
|
-
const
|
|
219
|
+
const assertCurrent = () => { assertDaemonDispatch(root); if (generation !== (dispatchGenerations.get(root) ?? 0)) throw Object.assign(new Error("control cancelled a queued productive dispatch"), { code: "operation_cancelled" }); };
|
|
220
|
+
const stop = request.operation === "daemon.stop" ? (shutdowns.get(root) ?? { pending: 0, committed: false }) : null;
|
|
221
|
+
if (stop) { stop.pending++; shutdowns.set(root, stop); }
|
|
222
|
+
let result;
|
|
223
|
+
try { result = await journalDispatch(root, request, async () => {
|
|
127
224
|
// Persist before admission so a racing fence can observe the writer.
|
|
128
225
|
if (!control) await assertDaemonOwnership(root, { "operation-id": request.id, operation: request.operation, "native-session-id": request.params?.sessionId, "prompt-sha256": typeof request.params?.prompt === "string" ? createHash("sha256").update(request.params.prompt).digest("hex") : undefined }, assertCurrent);
|
|
129
226
|
if (!control) assertCurrent();
|
|
130
227
|
return control ? await controlDispatch(root, request, action) : await action();
|
|
131
|
-
});
|
|
228
|
+
}); } finally { if (stop) { stop.pending--; if (!stop.pending && !stop.committed) shutdowns.delete(root); } }
|
|
132
229
|
if (control) return result;
|
|
133
230
|
let settlement;
|
|
134
231
|
try {
|
|
@@ -10,7 +10,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
10
10
|
import { ObservationClock } from "./observation-clock.mjs";
|
|
11
11
|
import { hookBudget } from "./native-hook-command.mjs";
|
|
12
12
|
import { agyWorkspaceHooks } from "./agy-hooks.mjs";
|
|
13
|
-
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
13
|
+
import { durableDaemonDispatch, completeDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { observeModel } from "./model-observations.mjs";
|
|
16
16
|
import { AGY_VERSION_TIMEOUT_MS, AgyError, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
|
|
@@ -210,7 +210,9 @@ export async function startDaemon(options) {
|
|
|
210
210
|
const paths = locations(options.stateDir); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
211
211
|
const previous = await readState(paths.dir), noFlow = options.noFlow === true, cwd = await realpath(absolute(options.cwd, "--cwd")), config = { cwd, journal: options.journal ? absolute(options.journal, "--journal") : paths.journal, bin: options.bin ?? process.env.DD_AGY_BIN ?? "agy", provider: String(options.provider ?? "google"), model: String(options.model ?? "gemini-3.1-pro-high"), reasoning: String(options.reasoning ?? "high"), mode: String(options.mode ?? "accept-edits"), projectRoot: noFlow ? cwd : await realpath(absolute(options.projectRoot, "--project-root")), ddFlowBin: noFlow ? null : absolute(options.ddFlowBin, "--dd-flow-bin"), ddFlowHome: noFlow ? null : absolute(options.ddFlowHome, "--dd-flow-home"), resourceHome: noFlow ? null : (process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null), env: noFlow ? {} : { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") }, entryPath: absolute(options.entryPath, "dd-agy entry"), daemonId: randomUUID(), resumeSessionId: options.sessionId ?? null, geminiDir: paths.gemini, appDataDir: "runtime", temporary: paths.temporary, noFlow };
|
|
212
212
|
await prepareRuntimeOwner(config, paths.dir);
|
|
213
|
+
assertDaemonRestartable(previous);
|
|
213
214
|
try { const status = await callDaemon(paths.dir, "daemon.status", {}, 1000); if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration"); return { ...status, already_running: true }; } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; }
|
|
215
|
+
assertDaemonRestartable(await readState(paths.dir));
|
|
214
216
|
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
215
217
|
if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous Antigravity daemon died with an active or unproven tree");
|
|
216
218
|
await removeSocket(paths); await prepare(paths, config);
|
|
@@ -235,11 +237,11 @@ export async function startDaemon(options) {
|
|
|
235
237
|
throw error;
|
|
236
238
|
}
|
|
237
239
|
}
|
|
238
|
-
export const stopDaemon = async 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); };
|
|
240
|
+
export const stopDaemon = async 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, { readState: () => readState(options.stateDir) }); };
|
|
239
241
|
|
|
240
242
|
export class Runtime {
|
|
241
243
|
constructor(paths, state) { this.paths = paths; this.state = state; this.config = state.config; this.child = null; this.providerProcess = state.provider_process ?? null; this.providerFinalization = Promise.resolve(); this.buffer = ""; this.init = null; this.readyPromise = null; this.active = null; this.exited = false; this.toolObservations = new ToolObservations(); for (const reason of state.tool_observation_gaps ?? []) this.toolObservations.gaps.add(reason); for (const event of state.tool_observations ?? []) this.toolObservations.add(event); if (state.tool_calls?.length && !state.tool_observations) this.toolObservations.gaps.add("legacy_tools_not_attributed"); this.turnGeneration = state.turn_generation ?? 0; this.descendants = new Map((state.descendants ?? []).map(child => [child.provider_session_id, child])); this.lastResult = state.last_result ?? null; this.sessionObservations = new Map(state.session_observations ?? []); this.startupError = null; this.persisting = Promise.resolve(); this.draining = Promise.resolve(); this.lastActivityAt = state.last_activity_at ?? null; this.usageIngest = null; this.lastProviderEvent = null; }
|
|
242
|
-
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations], tool_observations: [...this.toolObservations.calls.values(), ...this.toolObservations.sources.values()], tool_observation_gaps: [...this.toolObservations.gaps], turn_generation: this.turnGeneration, descendants: [...this.descendants.values()], sessions: this.init ? [{ provider_session_id: this.init.conversation_id, root_provider_session_id: this.init.conversation_id, cwd: this.config.cwd }] : [] }); const snapshot = structuredClone(this.state); const queued = performance.now(); this.persisting = this.persisting.then(async () => { const started = performance.now(); await writeJson(this.paths.state, snapshot); this.persistTiming = { queue_ms: Math.round(started - queued), write_ms: Math.round(performance.now() - started) }; }); return this.persisting; }
|
|
244
|
+
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations], tool_observations: [...this.toolObservations.calls.values(), ...this.toolObservations.sources.values()], tool_observation_gaps: [...this.toolObservations.gaps], turn_generation: this.turnGeneration, descendants: [...this.descendants.values()], sessions: this.init ? [{ provider_session_id: this.init.conversation_id, root_provider_session_id: this.init.conversation_id, cwd: this.config.cwd }] : [] }); const snapshot = structuredClone(this.state); const queued = performance.now(); this.persisting = this.persisting.catch(() => {}).then(async () => { const started = performance.now(); await writeJson(this.paths.state, snapshot); this.persistTiming = { queue_ms: Math.round(started - queued), write_ms: Math.round(performance.now() - started) }; }); return this.persisting; }
|
|
243
245
|
async journal(kind, payload = {}) { const line = `${JSON.stringify({ schema_id: "dd-agy/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), kind, ...payload })}\n`; for (const journal of new Set([this.paths.journal, this.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
|
|
244
246
|
args(sessionId) { return ["--input-format", "stream-json", "--output-format", "stream-json", "--model", this.config.model, "--effort", this.config.reasoning, "--mode", this.config.mode, "--dangerously-skip-permissions", "--print-timeout", "6h", `--gemini_dir=${this.paths.gemini}`, "--app_data_dir=runtime", ...(sessionId ? ["--conversation", sessionId] : ["--new-project"])]; }
|
|
245
247
|
requireSessionIdentity(requestedId) {
|
|
@@ -257,12 +259,16 @@ export class Runtime {
|
|
|
257
259
|
if (["init", "step_update", "result"].includes(event.event)) this.markActivity();
|
|
258
260
|
}
|
|
259
261
|
async start(sessionId = null) {
|
|
262
|
+
if (this.drainingError) throw this.drainingError;
|
|
263
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
260
264
|
if (this.child && !this.exited) {
|
|
261
265
|
await this.readyPromise;
|
|
262
266
|
this.requireSessionIdentity(sessionId);
|
|
263
267
|
return this.init;
|
|
264
268
|
}
|
|
265
269
|
await this.providerFinalization;
|
|
270
|
+
if (this.drainingError) throw this.drainingError;
|
|
271
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
266
272
|
const target = executable(this.config.bin, this.args(sessionId)); this.exited = false; this.buffer = ""; this.init = null;
|
|
267
273
|
let readyResolve, readyReject; this.readyPromise = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
|
268
274
|
// The provider can reject before durable registration finishes. Keep a
|
|
@@ -273,12 +279,12 @@ export class Runtime {
|
|
|
273
279
|
const providerProcess = this.providerProcess;
|
|
274
280
|
try { await this.persist({ provider_process: providerProcess }); }
|
|
275
281
|
catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
|
|
276
|
-
try { this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] }); }
|
|
282
|
+
try { assertDaemonDispatch(this.paths?.dir); this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] }); }
|
|
277
283
|
catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
|
|
278
284
|
const providerChild = this.child;
|
|
279
285
|
let allowProviderFinalization;
|
|
280
286
|
const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
|
|
281
|
-
this.child.stdout.setEncoding("utf8").on("data", chunk => { this.buffer += chunk; this.draining = this.draining.then(() => this.drain(readyResolve)).catch(error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } }); });
|
|
287
|
+
this.child.stdout.setEncoding("utf8").on("data", chunk => { this.buffer += chunk; this.draining = this.draining.then(() => this.drain(readyResolve)).catch(error => { this.drainingError ??= error; readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } }); });
|
|
282
288
|
this.child.stderr.setEncoding("utf8").on("data", chunk => void this.journal("stderr", { text: String(chunk).slice(0, 4000) }));
|
|
283
289
|
this.child.on("error", error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } });
|
|
284
290
|
// A dead leader is not yet a finalized tree: stop must also join the
|
|
@@ -426,6 +432,7 @@ export class Runtime {
|
|
|
426
432
|
try {
|
|
427
433
|
await this.persist({ active_tree: true, last_hook_rejection: null });
|
|
428
434
|
assertDispatch();
|
|
435
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
429
436
|
if (this.active !== current) throw new DaemonError("operation_cancelled", "prompt reservation was superseded");
|
|
430
437
|
this.child.stdin.write(`${JSON.stringify({ event: "user", message: { content: text } })}\n`, error => {
|
|
431
438
|
if (error) { if (this.active === current) this.active = null; current.reject(error); }
|
|
@@ -489,19 +496,41 @@ export class Runtime {
|
|
|
489
496
|
}
|
|
490
497
|
async finalizeProviderForControl() {
|
|
491
498
|
const pending = this.providerFinalization;
|
|
492
|
-
try {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
499
|
+
try {
|
|
500
|
+
try { await pending; }
|
|
501
|
+
catch (error) {
|
|
502
|
+
// Control retries only retained physical/resource cleanup, never a
|
|
503
|
+
// missing mandatory stream record or a productive provider request.
|
|
504
|
+
if (this.providerFinalization !== pending) await this.providerFinalization;
|
|
505
|
+
else {
|
|
506
|
+
if (!this.retryProviderFinalization) throw error;
|
|
507
|
+
this.providerFinalization = this.retryProviderFinalization(5_000);
|
|
508
|
+
await this.providerFinalization;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (!this.drainingError) throw error;
|
|
513
|
+
this.drainingError.cleanup_error = errorRecord(error);
|
|
514
|
+
this.drainingError.details = { ...this.drainingError.details, cleanup_error: this.drainingError.cleanup_error };
|
|
515
|
+
throw this.drainingError;
|
|
500
516
|
}
|
|
517
|
+
if (this.drainingError) throw this.drainingError;
|
|
518
|
+
}
|
|
519
|
+
async prepareStop(cancelTree) {
|
|
520
|
+
const current = this.active, priorResult = this.lastResult;
|
|
521
|
+
const emptyTree = !current && !this.state.active_tree && !this.state.unclaimed_activity && !priorResult && !this.descendants.size;
|
|
522
|
+
if (cancelTree) await this.cancel();
|
|
523
|
+
else if (this.active || this.state.active_tree || this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled)) throw new DaemonError("tree_not_settled", "Antigravity root or child tree is still running");
|
|
524
|
+
// A physical kill is settlement, not a native clean terminal receipt.
|
|
525
|
+
// In particular, the previous Turn's SUCCESS cannot certify a cancelled
|
|
526
|
+
// in-flight Turn that exited without its own result.
|
|
527
|
+
const currentResult = !current || current.terminalResult === this.lastResult || this.lastResult !== priorResult;
|
|
528
|
+
const clean = emptyTree || (currentResult && this.receipt().settled);
|
|
529
|
+
return { clean, ...(clean ? {} : { native_settlement: { confirmed: false, reason: "native_terminal_unconfirmed" } }) };
|
|
501
530
|
}
|
|
502
531
|
async cancel() { cancelPendingDispatch(this); if (!this.child) return { ...this.receipt(), settled: Boolean(this.exited) }; if (!this.exited) { try { process.kill(-this.child.pid, "SIGINT"); } catch (error) { if (error.code !== "ESRCH") throw error; } const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await stopProcessGroup(this.child, 5_000); } await this.finalizeProviderForControl(); await this.persist({ active_tree: false }); return { ...this.receipt(), cancelled: true, settled: Boolean(this.exited) };
|
|
503
532
|
}
|
|
504
|
-
async close(cancelTree) { if (cancelTree) cancelPendingDispatch(this); if (!cancelTree && (this.active || this.state.active_tree || this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled))) throw new DaemonError("tree_not_settled", "Antigravity root or child tree is still running"); if (cancelTree) await this.cancel(); else if (this.child && !this.exited) this.child.stdin.end(); const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await this.cancel(); await this.
|
|
533
|
+
async close(cancelTree) { if (cancelTree) cancelPendingDispatch(this); if (!cancelTree && !this.exited && (this.active || this.state.active_tree || this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled))) throw new DaemonError("tree_not_settled", "Antigravity root or child tree is still running"); if (cancelTree) await this.cancel(); else if (this.child && !this.exited) this.child.stdin.end(); const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await this.cancel(); await this.finalizeProviderForControl(); }
|
|
505
534
|
async abortProvider(reason) {
|
|
506
535
|
const child = this.child, providerProcess = this.providerProcess;
|
|
507
536
|
await stopProcessGroup(child);
|
|
@@ -514,7 +543,27 @@ export class Runtime {
|
|
|
514
543
|
export async function serveDaemon(stateDir) {
|
|
515
544
|
const paths = locations(stateDir), state = await readState(paths.dir); if (!state?.config) throw new DaemonError("daemon_state_missing", "Antigravity daemon state is missing");
|
|
516
545
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
517
|
-
const runtime = new Runtime(paths, state); await removeSocket(paths);
|
|
546
|
+
const runtime = new Runtime(paths, state); await removeSocket(paths);
|
|
547
|
+
const server = net.createServer(socket => {
|
|
548
|
+
let buffer = "", consumed = false; socket.setEncoding("utf8");
|
|
549
|
+
socket.on("data", chunk => {
|
|
550
|
+
if (consumed) return;
|
|
551
|
+
buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
|
|
552
|
+
void (async () => {
|
|
553
|
+
let request;
|
|
554
|
+
try {
|
|
555
|
+
request = JSON.parse(buffer.slice(0, at));
|
|
556
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} }));
|
|
557
|
+
if (result._shutdown) retireDaemonEndpoint(server);
|
|
558
|
+
socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
|
|
561
|
+
socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
562
|
+
}
|
|
563
|
+
})();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
trackDaemonConnections(server); runtime.server = server;
|
|
518
567
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
|
|
519
568
|
try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
|
|
520
569
|
catch (error) {
|
|
@@ -528,6 +577,7 @@ export async function serveDaemon(stateDir) {
|
|
|
528
577
|
|
|
529
578
|
async function dispatch(runtime, request) {
|
|
530
579
|
if (request.schema_id !== REQUEST_SCHEMA) throw new DaemonError("daemon_protocol_mismatch", "unsupported dd-agy request schema"); const params = request.params ?? {};
|
|
580
|
+
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(request.operation)) assertDaemonDispatch(runtime.paths.dir);
|
|
531
581
|
if (request.operation === "daemon.status") { await runtime.observeHookFailures(); return { daemon_id: runtime.config.daemonId, shutdown_state: runtime.state.shutdown_state, active_tree: runtime.state.active_tree, active_operation: runtime.active?.operationId ?? null, last_hook_rejection: runtime.state.last_hook_rejection ?? null, last_activity_at: runtime.lastActivityAt, provider_ready: Boolean(runtime.init && !runtime.exited), pid: process.pid, provider_pid: runtime.child?.pid ?? null, versions: runtime.state.versions, config: runtime.config, sessions: runtime.state.sessions ?? [], receipt: runtime.receipt() }; }
|
|
532
582
|
if (request.operation === "session.create") { if (params.sessionId) throw new DaemonError("invalid_create", "create does not accept a Session ID"); if (params.prompt) throw new DaemonError("invalid_create", "create does not execute a prompt; use session.prompt"); await runtime.start(); await runtime.zeroUsage(); return runtime.receipt(); }
|
|
533
583
|
if (request.operation === "session.prompt") { const assertDispatch = captureDispatchGuard(runtime); await runtime.start(params.sessionId ?? null); runtime.requireSessionIdentity(params.sessionId); return await runtime.prompt(params.prompt, assertDispatch, request.id); }
|
|
@@ -557,7 +607,7 @@ async function dispatch(runtime, request) {
|
|
|
557
607
|
}
|
|
558
608
|
catch (error) { await runtime.rejectHook(error, params.event, params.payload ?? {}, current); throw error; }
|
|
559
609
|
}
|
|
560
|
-
if (request.operation === "daemon.stop")
|
|
610
|
+
if (request.operation === "daemon.stop") return await completeDaemonShutdown(runtime.paths.dir, runtime.state, patch => runtime.persist(patch), () => runtime.prepareStop(params.cancelTree === true), [["provider", () => runtime.close(false)], ["daemon_resource", () => runtime.finishResource()]]);
|
|
561
611
|
throw new DaemonError("unknown_operation", `unknown dd-agy daemon operation: ${request.operation}`);
|
|
562
612
|
}
|
|
563
613
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -4,6 +4,8 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { checkObservedProfile } from "./model-observations.mjs";
|
|
7
|
+
import { stopProcessGroup } from "./managed-daemon.mjs";
|
|
8
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
7
9
|
|
|
8
10
|
const DEFAULT_MODEL = "gemini-3.1-pro-high";
|
|
9
11
|
export const AGY_VERSION_TIMEOUT_MS = 30_000;
|
|
@@ -19,14 +21,22 @@ export function executable(bin, args = []) {
|
|
|
19
21
|
export async function runAgy(bin, args, options = {}) {
|
|
20
22
|
const target = executable(bin, args);
|
|
21
23
|
return await new Promise((resolve, reject) => {
|
|
22
|
-
const child = spawn(target.command, target.args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
24
|
+
const child = spawn(target.command, target.args, { cwd: options.cwd, env: options.env ?? process.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
|
|
23
25
|
let stdout = "", stderr = "", settled = false;
|
|
24
|
-
|
|
26
|
+
let timeoutError, inputError, cleanup;
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
timeoutError = new AgyError("agy_timeout", `${args[0] ?? "agy"} timed out`, true);
|
|
29
|
+
cleanup = stopProcessGroup(child, 1_000).catch(error => { timeoutError.cleanup_error = errorRecord(error); timeoutError.details = { cleanup_error: timeoutError.cleanup_error }; finish(timeoutError); });
|
|
30
|
+
}, options.timeoutMs ?? 30_000);
|
|
25
31
|
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(value); };
|
|
26
32
|
child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk);
|
|
27
33
|
child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
|
|
28
34
|
child.on("error", error => finish(error));
|
|
29
|
-
child.on("close", code => code === 0
|
|
35
|
+
child.on("close", async code => { await cleanup; if (timeoutError) finish(timeoutError); else if (inputError) finish(inputError); else if (code === 0) finish(null, { stdout, stderr, code }); else finish(new AgyError("agy_cli_failed", stderr.trim() || stdout.trim() || `agy exited ${code}`, false, { exit_code: code })); });
|
|
36
|
+
child.stdin.on("error", error => {
|
|
37
|
+
inputError ??= error;
|
|
38
|
+
cleanup ??= stopProcessGroup(child, 1_000).catch(cause => { inputError.details = { ...inputError.details, cleanup_error: errorRecord(cause) }; finish(timeoutError ?? inputError); });
|
|
39
|
+
});
|
|
30
40
|
child.stdin.end(options.stdin ?? "");
|
|
31
41
|
});
|
|
32
42
|
}
|