@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79
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/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +2 -2
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +84 -41
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +195 -92
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const
|
|
2
|
+
const args = process.argv.slice(2);
|
|
3
|
+
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
|
+
const exitCode = managedHook
|
|
5
|
+
? await (await import("./cli/hook-ingress.js")).runHookIngress(args)
|
|
6
|
+
: await (await import("./cli/run-cli.js")).runCli(args);
|
|
4
7
|
process.exitCode = exitCode;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function callDaemon(stateDir: string, operation: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<Record<string, unknown>>;
|
|
@@ -81,7 +81,7 @@ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 3
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export async function startDaemon(options) {
|
|
84
|
-
const paths = locations(options.stateDir); const cwd = absolute(options.cwd, "--cwd"); const config = { cwd, journal: absolute(options.journal, "--journal"), bin: options.bin ?? null, ddFlowBin: options.ddFlowBin ? absolute(options.ddFlowBin, "--dd-flow-bin") : null, ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null, projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : cwd, resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null, env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {} };
|
|
84
|
+
const paths = locations(options.stateDir); const cwd = absolute(options.cwd, "--cwd"); const config = { cwd, journal: absolute(options.journal, "--journal"), bin: options.bin ?? null, ddFlowBin: options.ddFlowBin ? absolute(options.ddFlowBin, "--dd-flow-bin") : null, ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null, projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : cwd, resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null, env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home"), ...(options.ddFlowBin ? { DD_FLOW_BIN: absolute(options.ddFlowBin, "--dd-flow-bin") } : {}) } : {} };
|
|
85
85
|
await prepareRuntimeOwner(config, paths.dir);
|
|
86
86
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
87
87
|
try { const status = await callDaemon(paths.dir, "daemon.status", {}, 1_000); if (JSON.stringify(status.config) !== JSON.stringify(config)) throw Object.assign(new Error("running daemon has a different configuration"), { code: "daemon_config_mismatch" }); return { ...status, already_running: true }; } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; }
|
|
@@ -96,10 +96,14 @@ export async function stopDaemon(options) { const timeoutMs = options.timeoutMs
|
|
|
96
96
|
|
|
97
97
|
export async function serveDaemon(stateDir) {
|
|
98
98
|
const paths = locations(stateDir); const state = await readState(paths.state); if (!state || state.schema_id !== stateSchema) throw Object.assign(new Error("daemon state missing"), { code: "daemon_state_missing" });
|
|
99
|
-
const bridge = new CodexBridge({ ...state.config, env: { ...state.config.env, DD_FLOW_DAEMON_ID: state.daemon_id } }); await bridge.start(); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
99
|
+
const bridge = new CodexBridge({ ...state.config, env: { ...state.config.env, CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_DAEMON_ID: state.daemon_id, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await bridge.start(); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
100
100
|
const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.then(() => writeState(paths.state, snapshot)).then(() => heartbeatDaemonProcess(snapshot.config, snapshot.resource_process).catch(() => {})); return persist; };
|
|
101
101
|
const dispatch = async (operation, params) => {
|
|
102
102
|
const assertDispatch = captureDispatchGuard(bridge);
|
|
103
|
+
if (operation === "hook.verify") {
|
|
104
|
+
if (params.daemonId !== state.daemon_id) throw Object.assign(new Error("Hook daemon identity differs"), { code: "native_hook_unproven" });
|
|
105
|
+
return { ...bridge.verifyNativeHook(params), daemonId: state.daemon_id, ddFlowHome: state.config.ddFlowHome, projectRoot: state.config.projectRoot };
|
|
106
|
+
}
|
|
103
107
|
if (operation === "daemon.status") return { daemon_id: state.daemon_id, pid: process.pid, cwd: state.cwd, journal: state.journal, config: state.config, sessions: [...sessions], active_operation: active, shutdown_state: state.shutdown_state };
|
|
104
108
|
// The runner may need to retire an orphaned daemon after its caller was
|
|
105
109
|
// interrupted. `daemon.stop` is the one control operation that remains
|
|
@@ -124,6 +128,7 @@ export async function serveDaemon(stateDir) {
|
|
|
124
128
|
const owned = new Set(sessions);
|
|
125
129
|
for (const id of owned) for (const child of bridge.directChildren?.(id) ?? []) owned.add(child.provider_session_id);
|
|
126
130
|
if (!owned.has(params.sessionId)) throw Object.assign(new Error("inspection Session is not owned by this daemon"), { code: "session_identity_mismatch" });
|
|
131
|
+
bridge.assertHookHealthy(params.sessionId);
|
|
127
132
|
try { return await inspectCodexTree(bridge, { ...state.config, ...params }, { remember: id => sessions.add(id), active: () => active }); }
|
|
128
133
|
finally { await save(); }
|
|
129
134
|
}
|
|
@@ -7,6 +7,13 @@ import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
|
|
|
7
7
|
import { observeModel } from "./model-observations.mjs";
|
|
8
8
|
|
|
9
9
|
const LATE_RESPONSE_GRACE_MS = 5_000;
|
|
10
|
+
const CODEX_SESSION_CONFIG = Object.freeze({
|
|
11
|
+
bypass_hook_trust: true,
|
|
12
|
+
"features.plugins": false,
|
|
13
|
+
"features.multi_agent_v2.enabled": true,
|
|
14
|
+
"features.multi_agent_v2.tool_namespace": "collaboration",
|
|
15
|
+
"features.multi_agent_v2.wait_agent_enabled": true
|
|
16
|
+
});
|
|
10
17
|
|
|
11
18
|
// Minimal JSON-RPC wrapper around the public Codex app-server protocol. It is
|
|
12
19
|
// intentionally one-shot: the provider owns durable Threads, while the caller
|
|
@@ -61,6 +68,8 @@ export class CodexBridge {
|
|
|
61
68
|
receive(line) {
|
|
62
69
|
let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
|
|
63
70
|
this.observeTopology(message);
|
|
71
|
+
this.observeNativeHook(message);
|
|
72
|
+
this.observeHookFailure(message);
|
|
64
73
|
if (["model/rerouted", "thread/settings/updated"].includes(message.method)) {
|
|
65
74
|
const params = message.params ?? {}, id = params.threadId;
|
|
66
75
|
let root = id; const seen = new Set();
|
|
@@ -117,24 +126,122 @@ export class CodexBridge {
|
|
|
117
126
|
if (threadId && message.method === "item/completed" && message.params?.item?.type === "agentMessage") this.finalMessages.set(threadId, { item: message.params.item, completedAt: Date.now() });
|
|
118
127
|
}
|
|
119
128
|
|
|
129
|
+
isManagedHook(run) {
|
|
130
|
+
const home = this.options.env?.CODEX_HOME;
|
|
131
|
+
return Boolean(home && run?.eventName === "preToolUse" && run.handlerType === "command"
|
|
132
|
+
&& run.executionMode === "sync" && run.statusMessage === "dd-flow runtime observation"
|
|
133
|
+
&& run.sourcePath === path.join(home, "hooks.json"));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
observeNativeHook(message) {
|
|
137
|
+
if (message.method === "turn/completed") {
|
|
138
|
+
const { threadId, turn } = message.params ?? {};
|
|
139
|
+
for (const [id, hook] of this.activeHooks ?? []) {
|
|
140
|
+
if (hook.threadId === threadId && hook.turnId === turn?.id) this.activeHooks.delete(id);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!["hook/started", "hook/completed"].includes(message.method)) return;
|
|
145
|
+
const { threadId, turnId, run } = message.params ?? {};
|
|
146
|
+
if (!this.isManagedHook(run) || typeof run.id !== "string" || !threadId) return;
|
|
147
|
+
this.activeHooks ??= new Map();
|
|
148
|
+
if (message.method === "hook/started") this.activeHooks.set(run.id, { threadId, turnId });
|
|
149
|
+
else this.activeHooks.delete(run.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
verifyNativeHook(input) {
|
|
153
|
+
const { rootSessionId, sessionId, toolCallId, turnId } = input;
|
|
154
|
+
const home = this.options.env?.CODEX_HOME;
|
|
155
|
+
const id = `pre-tool-use:0:${path.join(home ?? "", "hooks.json")}:${toolCallId}`;
|
|
156
|
+
const active = this.activeHooks?.get(id);
|
|
157
|
+
let current = sessionId;
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
while (current && current !== rootSessionId && !seen.has(current)) {
|
|
160
|
+
seen.add(current); current = this.descendants.get(current)?.parent_provider_session_id;
|
|
161
|
+
}
|
|
162
|
+
if (!active || active.threadId !== sessionId || active.turnId !== turnId || !toolCallId
|
|
163
|
+
|| current !== rootSessionId || !this.requestedProfiles.has(rootSessionId)) {
|
|
164
|
+
fail("No matching active managed native hook", "native_hook_unproven", { provider_session_id: sessionId, tool_call_id: toolCallId });
|
|
165
|
+
}
|
|
166
|
+
return { rootSessionId, sessionId, toolCallId, parentSessionId: sessionId === rootSessionId ? null : this.descendants.get(sessionId)?.parent_provider_session_id };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
observeHookFailure(message) {
|
|
170
|
+
const { threadId, turnId, run } = message.params ?? {};
|
|
171
|
+
const home = this.options.env?.CODEX_HOME;
|
|
172
|
+
if (message.method !== "hook/completed" || run?.status !== "failed" ||
|
|
173
|
+
run.eventName !== "preToolUse" || run.handlerType !== "command" ||
|
|
174
|
+
run.statusMessage !== "dd-flow runtime observation" || !home ||
|
|
175
|
+
run.sourcePath !== path.join(home, "hooks.json")) return;
|
|
176
|
+
const ancestors = new Set();
|
|
177
|
+
let root;
|
|
178
|
+
let current = threadId;
|
|
179
|
+
while (current && !ancestors.has(current)) {
|
|
180
|
+
ancestors.add(current);
|
|
181
|
+
if (this.requestedProfiles.has(current)) root = current;
|
|
182
|
+
current = this.descendants.get(current)?.parent_provider_session_id;
|
|
183
|
+
}
|
|
184
|
+
if (!root) return;
|
|
185
|
+
this.hookFailures ??= new Map();
|
|
186
|
+
if (this.hookFailures.has(root)) return;
|
|
187
|
+
const entries = Array.isArray(run.entries) ? run.entries : [];
|
|
188
|
+
const reason = entries.filter(entry => entry.kind === "error").map(entry => entry.text).join("; ");
|
|
189
|
+
const error = Object.assign(new Error(reason || "Managed Codex hook failed"), {
|
|
190
|
+
code: /timed out/i.test(reason) ? "native_hook_timeout" : "native_hook_failed",
|
|
191
|
+
details: { provider_session_id: threadId, root_provider_session_id: root, turn_id: turnId,
|
|
192
|
+
hook_run_id: run.id, effect: "unknown", duration_ms: run.durationMs }
|
|
193
|
+
});
|
|
194
|
+
this.hookFailures.set(root, error);
|
|
195
|
+
this.journal.write("native_hook_failure", { code: error.code, message: error.message, ...error.details });
|
|
196
|
+
for (const [id, pending] of this.pending) {
|
|
197
|
+
if (ancestors.has(pending.threadId) && pending.method !== "turn/interrupt") {
|
|
198
|
+
clearTimeout(pending.timer); this.pending.delete(id); pending.reject(error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
assertHookHealthy(sessionId) {
|
|
204
|
+
const visited = new Set();
|
|
205
|
+
while (sessionId && !visited.has(sessionId)) {
|
|
206
|
+
visited.add(sessionId);
|
|
207
|
+
const error = this.hookFailures?.get(sessionId);
|
|
208
|
+
if (error) throw error;
|
|
209
|
+
sessionId = this.descendants.get(sessionId)?.parent_provider_session_id;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
120
213
|
// Collaboration child Threads are provider facts. Keep only objects that
|
|
121
214
|
// explicitly carry a parent relationship, including native spawn receipts.
|
|
122
215
|
// A wait/send receiver is not necessarily a child of its sender.
|
|
123
216
|
observeTopology(value, depth = 0) {
|
|
124
217
|
if (!value || typeof value !== "object" || depth > 8) return;
|
|
125
218
|
if (Array.isArray(value)) { for (const item of value) this.observeTopology(item, depth + 1); return; }
|
|
219
|
+
const activity = value.params?.item;
|
|
220
|
+
const activityParent = value.params?.threadId;
|
|
221
|
+
if (activity?.type === "subAgentActivity" && typeof activity.agentThreadId === "string" && activity.agentThreadId &&
|
|
222
|
+
typeof activityParent === "string" && activityParent && activity.agentThreadId !== activityParent) {
|
|
223
|
+
const previous = this.descendants.get(activity.agentThreadId);
|
|
224
|
+
const observed = activity.kind === "completed" ? "completed" : activity.kind === "interrupted" ? "interrupted" : "running";
|
|
225
|
+
const status = previous?.status === "completed" ? "completed" : observed;
|
|
226
|
+
this.descendants.set(activity.agentThreadId, { ...previous, provider_session_id: activity.agentThreadId,
|
|
227
|
+
parent_provider_session_id: activityParent, status, agent_path: activity.agentPath ?? previous?.agent_path ?? null,
|
|
228
|
+
source: "codex.app_server.subAgentActivity" });
|
|
229
|
+
}
|
|
126
230
|
if (value.type === "collabAgentToolCall" && value.tool === "spawnAgent" &&
|
|
127
231
|
typeof value.senderThreadId === "string" && value.senderThreadId && Array.isArray(value.receiverThreadIds)) {
|
|
128
232
|
for (const child of value.receiverThreadIds) {
|
|
129
233
|
if (typeof child !== "string" || !child || child === value.senderThreadId) continue;
|
|
130
|
-
this.descendants.
|
|
131
|
-
|
|
234
|
+
const previous = this.descendants.get(child);
|
|
235
|
+
this.descendants.set(child, { ...previous, provider_session_id: child, parent_provider_session_id: value.senderThreadId,
|
|
236
|
+
status: previous?.status === "completed" ? "completed" : value.agentsStates?.[child]?.status ?? "unknown", source: "codex.app_server" });
|
|
132
237
|
}
|
|
133
238
|
}
|
|
134
239
|
const id = value.threadId ?? value.id ?? null;
|
|
135
240
|
const parent = value.parentThreadId ?? value.parent_thread_id ?? null;
|
|
136
241
|
if (typeof id === "string" && id && typeof parent === "string" && parent) {
|
|
137
|
-
this.descendants.
|
|
242
|
+
const previous = this.descendants.get(id);
|
|
243
|
+
this.descendants.set(id, { ...previous, provider_session_id: id, parent_provider_session_id: parent,
|
|
244
|
+
status: previous?.status === "completed" ? "completed" : value.status?.type ?? value.status ?? "unknown", source: "codex.app_server" });
|
|
138
245
|
}
|
|
139
246
|
for (const child of Object.values(value)) this.observeTopology(child, depth + 1);
|
|
140
247
|
}
|
|
@@ -169,7 +276,7 @@ export class CodexBridge {
|
|
|
169
276
|
this.pending.delete(id); reject(timeoutError);
|
|
170
277
|
}, LATE_RESPONSE_GRACE_MS);
|
|
171
278
|
}, timeoutMs, { onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
|
|
172
|
-
this.pending.set(id, { resolve, reject, timer, method }); this.send({ jsonrpc: "2.0", id, method, params });
|
|
279
|
+
this.pending.set(id, { resolve, reject, timer, method, threadId: params.threadId }); this.send({ jsonrpc: "2.0", id, method, params });
|
|
173
280
|
});
|
|
174
281
|
}
|
|
175
282
|
send(value) {
|
|
@@ -198,6 +305,21 @@ async function withBridge(options, action) { const bridge = new CodexBridge(opti
|
|
|
198
305
|
function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
|
|
199
306
|
function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
|
|
200
307
|
function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
|
|
308
|
+
function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
|
|
309
|
+
|
|
310
|
+
async function requestConfiguredThread(bridge, method, params, timeoutMs) {
|
|
311
|
+
try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs); }
|
|
312
|
+
catch (error) {
|
|
313
|
+
const data = error?.details?.data;
|
|
314
|
+
const key = data?.configKey ?? data?.config_key ?? null;
|
|
315
|
+
if (error?.code === "rpc_error" && (data?.reason === "config" || String(key ?? "").startsWith("features.multi_agent_v2"))) {
|
|
316
|
+
throw Object.assign(new Error("Codex runtime rejected the required multi-agent v2 session configuration"), {
|
|
317
|
+
code: "codex_multi_agent_v2_unavailable", cause: error, details: { method, provider_error: error.details }
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
201
323
|
|
|
202
324
|
function threadReceipt(thread) {
|
|
203
325
|
// Thread reads can include the whole transcript even with includeTurns:false.
|
|
@@ -231,13 +353,12 @@ export async function createSessionWithBridge(bridge, options) {
|
|
|
231
353
|
// dd-codex is the eval-only adapter. Its CODEX_HOME is generated by
|
|
232
354
|
// dd-flow, so trust only that managed PreToolUse hook for this thread and
|
|
233
355
|
// do not load user plugin hooks into the isolated evaluator.
|
|
234
|
-
const result = await bridge
|
|
356
|
+
const result = await requestConfiguredThread(bridge, "thread/start", {
|
|
235
357
|
cwd,
|
|
236
358
|
model: options.model ?? null,
|
|
237
359
|
approvalPolicy: "never",
|
|
238
360
|
sandbox: "danger-full-access",
|
|
239
|
-
ephemeral: false
|
|
240
|
-
config: { bypass_hook_trust: true, "features.plugins": false }
|
|
361
|
+
ephemeral: false
|
|
241
362
|
});
|
|
242
363
|
// ThreadStartParams has no reasoning-effort field. The first turn applies
|
|
243
364
|
// it, so reporting the Desktop default here would be a false profile drift.
|
|
@@ -258,6 +379,7 @@ export async function startSessionWithBridge(bridge, options) {
|
|
|
258
379
|
bridge.requestedProfiles?.set(sessionId, profile(options));
|
|
259
380
|
bridge.finalMessages?.delete(sessionId);
|
|
260
381
|
options.assertDispatch?.();
|
|
382
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
261
383
|
const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, options.timeoutMs ?? 30_000);
|
|
262
384
|
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
263
385
|
if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
|
|
@@ -273,10 +395,12 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
273
395
|
bridge.requestedProfiles?.set(sessionId, profile(options));
|
|
274
396
|
bridge.finalMessages?.delete(sessionId);
|
|
275
397
|
options.assertDispatch?.();
|
|
398
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
276
399
|
const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, options.timeoutMs ?? 1_800_000);
|
|
277
400
|
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
278
401
|
const completed = await waitForTurn(bridge, sessionId, turnId, options.timeoutMs ?? 1_800_000, options, cwd);
|
|
279
402
|
const thread = await readCompletedThread(bridge, sessionId);
|
|
403
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
280
404
|
const assistantText = latestAssistantText(thread) ?? latestAssistantText(completed);
|
|
281
405
|
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: directChildren(bridge, sessionId), ...(assistantText ? { assistant_text: assistantText } : {}), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread?.model ?? null, reasoning: thread?.reasoningEffort ?? null, provider: thread?.modelProvider ?? null }, "thread/read.after_turn") };
|
|
282
406
|
}
|
|
@@ -285,13 +409,12 @@ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
|
|
|
285
409
|
const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }, 10_000);
|
|
286
410
|
const thread = value?.thread ?? value;
|
|
287
411
|
if (thread?.status?.type !== "notLoaded") return;
|
|
288
|
-
await bridge
|
|
412
|
+
await requestConfiguredThread(bridge, "thread/resume", {
|
|
289
413
|
threadId: sessionId,
|
|
290
414
|
cwd,
|
|
291
415
|
model: options.model ?? null,
|
|
292
416
|
approvalPolicy: "never",
|
|
293
|
-
sandbox: "danger-full-access"
|
|
294
|
-
config: { bypass_hook_trust: true, "features.plugins": false }
|
|
417
|
+
sandbox: "danger-full-access"
|
|
295
418
|
}, 30_000);
|
|
296
419
|
}
|
|
297
420
|
|
|
@@ -303,6 +426,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
|
|
|
303
426
|
const failureNotBefore = Date.now() + 1_000;
|
|
304
427
|
let nextStoredReadAt = Date.now();
|
|
305
428
|
while (!clock.sample(bridge.activity?.get(sessionId))) {
|
|
429
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
306
430
|
const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed") return terminalTurn(known.value, turnId, bridge);
|
|
307
431
|
if (Date.now() >= nextStoredReadAt) {
|
|
308
432
|
const stored = await readTurn(bridge, sessionId, turnId, false);
|
|
@@ -374,20 +498,20 @@ async function readTurn(bridge, sessionId, turnId, includeTurns) {
|
|
|
374
498
|
const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns }, 10_000);
|
|
375
499
|
const thread = result?.thread ?? result;
|
|
376
500
|
return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
|
|
377
|
-
} catch { return null; }
|
|
501
|
+
} catch { bridge.assertHookHealthy?.(sessionId); return null; }
|
|
378
502
|
}
|
|
379
503
|
|
|
380
504
|
async function readPersistedTurn(bridge, sessionId, turnId) {
|
|
381
505
|
try {
|
|
382
506
|
const result = await bridge.request("thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, 10_000);
|
|
383
507
|
return Array.isArray(result?.data) ? result.data.find((turn) => turn?.id === turnId) ?? null : null;
|
|
384
|
-
} catch { return null; }
|
|
508
|
+
} catch { bridge.assertHookHealthy?.(sessionId); return null; }
|
|
385
509
|
}
|
|
386
510
|
|
|
387
511
|
async function readCompletedThread(bridge, sessionId) {
|
|
388
512
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
389
513
|
try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
|
|
390
|
-
catch (error) { bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: error.message }); await new Promise((resolve) => setTimeout(resolve, 150)); }
|
|
514
|
+
catch (error) { bridge.assertHookHealthy?.(sessionId); bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: error.message }); await new Promise((resolve) => setTimeout(resolve, 150)); }
|
|
391
515
|
}
|
|
392
516
|
return null;
|
|
393
517
|
}
|
|
@@ -16,7 +16,7 @@ import { DroidMetadataReader, readDroidRoutingLog } from "./droid-observation.mj
|
|
|
16
16
|
const metadataReaders = new Map();
|
|
17
17
|
function metadataReader(factory) { if (!metadataReaders.has(factory)) metadataReaders.set(factory, new DroidMetadataReader(factory)); return metadataReaders.get(factory); }
|
|
18
18
|
import { confirmDaemonProcess, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
19
|
-
import { renderAdapterPolicy } from "./delegation-instructions.mjs";
|
|
19
|
+
import { renderAdapterPolicy, lifecycleRetryInstruction } from "./delegation-instructions.mjs";
|
|
20
20
|
|
|
21
21
|
const run = promisify(execFile);
|
|
22
22
|
export const DROID_CONTRACT = "dd-droid-harness@1";
|
|
@@ -55,7 +55,7 @@ export async function prepareDroidHome(config) {
|
|
|
55
55
|
const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --state-dir ${shell(paths.dir)}`;
|
|
56
56
|
const hooks = Object.fromEntries(["SessionStart", "PreToolUse", "PostToolUse", "Stop", "SessionEnd", "SubagentStop"].map(event => [event, [{ matcher: "*", hooks: [{ type: "command", command, timeout: 30 }] }]]));
|
|
57
57
|
await writeJsonAtomic(path.join(paths.factory, "hooks.json"), hooks);
|
|
58
|
-
const worker = `---\nname: dd-flow-worker\ndescription: Execute one explicit dd-flow Work packet or a technical capacity marker\nmodel: ${config.model}\nreasoningEffort: ${config.reasoning}\nmcpServers: []\n---\nFor a Work assignment, your first technical action is the exact standalone work start command supplied by the coordinator. If that command is absent, report an incomplete packet and stop: do not run stage pause, stage finish, or another lifecycle command. Follow only the returned Work packet, invoke its exact work finish or fail command, then stop. A Task child never executes or finishes its coordinator's Stage Work. Do not create children or take another Work. For a technical capacity marker, only return the requested marker; do not read the project or call dd-flow.\n`;
|
|
58
|
+
const worker = `---\nname: dd-flow-worker\ndescription: Execute one explicit dd-flow Work packet or a technical capacity marker\nmodel: ${config.model}\nreasoningEffort: ${config.reasoning}\nmcpServers: []\n---\nFor a Work assignment, your first technical action is the exact standalone work start command supplied by the coordinator. If that command is absent, report an incomplete packet and stop: do not run stage pause, stage finish, or another lifecycle command. Execute the supplied operation and target exactly; lifecycle invocation IDs are internal runtime authority, so never add or invent them. Continue only after a successful start returns the Work packet. ${lifecycleRetryInstruction} Follow only the returned Work packet, invoke its exact work finish or fail command, then stop. A Task child never executes or finishes its coordinator's Stage Work. Do not create children or take another Work. For a technical capacity marker, only return the requested marker; do not read the project or call dd-flow.\n`;
|
|
59
59
|
await writeFile(path.join(paths.factory, "droids", "dd-flow-worker.md"), worker, { mode: 0o600 });
|
|
60
60
|
return { hooks_sha256: createHash("sha256").update(JSON.stringify(hooks)).digest("hex"), worker_sha256: createHash("sha256").update(worker).digest("hex") };
|
|
61
61
|
}
|
|
@@ -145,7 +145,7 @@ export async function startDaemon(options) {
|
|
|
145
145
|
ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null,
|
|
146
146
|
projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : null,
|
|
147
147
|
resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null,
|
|
148
|
-
env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {},
|
|
148
|
+
env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home"), ...(options.ddFlowBin ? { DD_FLOW_BIN: absolute(options.ddFlowBin, "--dd-flow-bin") } : {}) } : {},
|
|
149
149
|
livenessTimeoutMs: positiveDuration(options.livenessTimeoutMs, "livenessTimeoutMs", DEFAULT_LIVENESS_TIMEOUT_MS),
|
|
150
150
|
requestTimeoutMs: optionalDuration(options.requestTimeoutMs, "requestTimeoutMs", DEFAULT_REQUEST_TIMEOUT_MS),
|
|
151
151
|
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const ADAPTERS = {
|
|
2
2
|
codex: {
|
|
3
3
|
name: "Codex",
|
|
4
|
-
launch: "
|
|
5
|
-
wait: "The spawn result is a handle, not completion.
|
|
4
|
+
launch: "This Session uses the Codex multi-agent v2 API in the `collaboration` namespace. In code mode call `tools.collaboration__spawn_agent` once per assignment with exactly `task_name`, `message`, and `fork_turns: \"none\"` for fresh context. Keep the current model and reasoning by omitting overrides. Do not create a root Session or run the child's command yourself.",
|
|
5
|
+
wait: "The spawn result is a handle, not completion. In code mode use `tools.collaboration__wait_agent` and native completion messages until every launched child is terminal. A mailbox update is not necessarily final. A wait timeout leaves the same child running: continue waiting without relaunch or interruption. Retain terminal results and product Work receipts. Codex v2 owns idle-agent residency; do not interrupt a completed child as cleanup.",
|
|
6
|
+
partial: "If a spawn is refused, stop issuing new spawns and retain every successful child handle. Wait for every already-launched child to become terminal; silence is not completion. Do not interrupt a running child to free capacity, and do not interrupt completed children as cleanup. Report completed/launched assignments, assignments not launched and the exact refusal. Return after this partial wave; the controller will issue remaining Work when capacity is available. Do not replay an ambiguous spawn."
|
|
6
7
|
},
|
|
7
8
|
zcode: {
|
|
8
9
|
name: "ZCode",
|
|
@@ -43,10 +44,13 @@ function requireAdapter(harness) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function delegationContract(harness) {
|
|
47
|
+
const key = delegationAdapter(harness);
|
|
46
48
|
requireAdapter(harness);
|
|
47
|
-
return `dd-flow/delegation/${
|
|
49
|
+
return `dd-flow/delegation/${key}@${key === "codex" ? 2 : 1}`;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
export const lifecycleRetryInstruction = "If the CLI rejects the call with effect=no_effect, recoverable=true and retry_command, execute that exact retry_command in this same Session after correcting the reported input. Never invent IDs. If the native shell tool rejects the launch before the CLI starts, correct only that tool-call error and repeat the same exact command; do not change its arguments. A hook/transport failure is an infrastructure error: stop and report it without retrying. For any other failure, stop and report the exact error; do not run status as a substitute, call a hook handler, retry blindly, or finish the Work.";
|
|
53
|
+
|
|
50
54
|
export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
|
|
51
55
|
if (!workId || !startCommand) throw new Error("ready fan-out Work lacks its exact start command");
|
|
52
56
|
const context = launchPolicy === "fresh_agent_required"
|
|
@@ -57,7 +61,11 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
|
|
|
57
61
|
context,
|
|
58
62
|
"Your first technical action must be this exact standalone lifecycle command:",
|
|
59
63
|
startCommand,
|
|
60
|
-
"
|
|
64
|
+
"Execute the issued operation and target exactly. Lifecycle invocation IDs are internal runtime authority and are intentionally omitted; never add or invent them. Preserve any explicit native receipt handle that the command does contain.",
|
|
65
|
+
"Use the native shell's inherited working directory. Do not set or override a workdir/cwd field for this lifecycle command.",
|
|
66
|
+
"Continue only if it succeeds and returns the authoritative Work packet.",
|
|
67
|
+
lifecycleRetryInstruction,
|
|
68
|
+
"After a successful start, complete the assigned Work, write its required result, invoke its exact standalone work finish command, then stop.",
|
|
61
69
|
"Do not start another Work, create a child, change dependencies, ask the user, or treat a quiet sibling as failed. You cannot ask the user or pause the parent Stage. Missing facts go through this Work's declared result/failure contract; only the coordinator handles Stage HITL."
|
|
62
70
|
].join("\n");
|
|
63
71
|
}
|
|
@@ -77,6 +85,7 @@ export function renderDelegationInstructions({ harness, stage, capacity, works =
|
|
|
77
85
|
"Retain the frozen coordinator model and reasoning for native children; do not choose a different model, reasoning level or permission mode.",
|
|
78
86
|
"You are the coordinator. The JSON assignments below contain child-only messages, not instructions for you to execute. Pass each message verbatim as the native tool's task/message/prompt argument. For context=fresh, select empty conversation context before launch; if unavailable, report the limitation and stop. Use only fields exposed by your actual native tool schema; if the native tool is absent, report that error without substituting a shell command.",
|
|
79
87
|
"Every child must be a direct child of this current Session. Wait for all launched children to settle before returning. One failed child is evidence for the coordinator, not a reason to cancel its siblings.",
|
|
88
|
+
adapter.partial ?? "If a spawn is refused, stop issuing new spawns and retain every successful child handle. Wait for all already-launched children, then release completed children using the adapter's exposed native close/release mechanism when supported. A failed batch does not undo successful spawns. Report completed/launched assignments, assignments not launched and the exact refusal; never claim no children launched when any spawn succeeded. Return after this partial wave; the controller will issue remaining Work when capacity is available. Do not replay an ambiguous spawn.",
|
|
80
89
|
"Do not finish this Stage, repair it, or start a successor in this Turn. Do not invent provider or internal session IDs; lifecycle evidence comes from the adapter.",
|
|
81
90
|
adapter.wait,
|
|
82
91
|
"Child assignments (JSON data):",
|
|
@@ -0,0 +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> }>;
|
package/dist/services/canon.js
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { prepareError } from "../cli/input-preparation.js";
|
|
4
6
|
const canonRootKey = "canon.root";
|
|
5
|
-
export function
|
|
7
|
+
export function prepareCanonRegistration(context, input) {
|
|
8
|
+
if (typeof input.root !== "string" || !input.root.trim())
|
|
9
|
+
return prepareError(new AppError("validation", "Canon root must be a non-empty path", 2));
|
|
6
10
|
const resolution = resolveCanonRoot(context, { explicitRoot: input.root, allowRegistered: false });
|
|
7
11
|
if (!resolution.ok || !resolution.canon) {
|
|
8
|
-
return {
|
|
9
|
-
...resolution,
|
|
10
|
-
action: "register",
|
|
11
|
-
exit_code: 1
|
|
12
|
-
};
|
|
12
|
+
return prepareError(new AppError("validation", "Canon root cannot be registered", 2, { root: input.root, blockers: resolution.blockers, candidates: resolution.candidates, bootstrap: resolution.bootstrap }));
|
|
13
13
|
}
|
|
14
|
+
return resolution.canon;
|
|
15
|
+
}
|
|
16
|
+
export function registerCanonRoot(context, input, canon = prepareCanonRegistration(context, input)) {
|
|
14
17
|
const now = context.now();
|
|
15
18
|
context.db.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at)
|
|
16
19
|
VALUES (?, ?, ?, ?)
|
|
17
20
|
ON CONFLICT(key) DO UPDATE SET
|
|
18
21
|
value_json = excluded.value_json,
|
|
19
|
-
updated_at = excluded.updated_at`, [canonRootKey, JSON.stringify({ root:
|
|
22
|
+
updated_at = excluded.updated_at`, [canonRootKey, JSON.stringify({ root: canon.root }), now, now]);
|
|
20
23
|
return {
|
|
21
24
|
ok: true,
|
|
22
25
|
action: "register",
|
|
23
26
|
canon: {
|
|
24
|
-
...
|
|
27
|
+
...canon,
|
|
25
28
|
source: "registered"
|
|
26
29
|
},
|
|
27
30
|
db_path: context.db.path
|
package/dist/services/cleanup.js
CHANGED
|
@@ -2,8 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import {
|
|
6
|
-
import { ensureReadableFile } from "../storage/database.js";
|
|
5
|
+
import { prepareJsonFile } from "../cli/input-preparation.js";
|
|
7
6
|
import { projectCheckoutRoot, resolveProjectRoot, runtimeStateJsonPath } from "../storage/paths.js";
|
|
8
7
|
import { appendAudit } from "./audit.js";
|
|
9
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
@@ -173,20 +172,29 @@ export function cleanupScan(context, input) {
|
|
|
173
172
|
plan: { project_root: project.root, project_id: project.id, actions }
|
|
174
173
|
};
|
|
175
174
|
}
|
|
176
|
-
export function
|
|
177
|
-
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
175
|
+
export function prepareCleanupApply(context, input) {
|
|
178
176
|
const reason = input.reason.trim();
|
|
179
177
|
if (!reason) {
|
|
180
178
|
throw new AppError("validation", "cleanup apply requires --reason", 2);
|
|
181
179
|
}
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
const value = prepareJsonFile(input.planFile, "cleanup plan").value;
|
|
181
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
182
|
+
throw new AppError("validation", "cleanup plan must be an object", 2);
|
|
183
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
184
|
+
const plan = validateCleanupPlan(value, project);
|
|
184
185
|
validateNoDestructiveSurprises(context, plan, input.force);
|
|
186
|
+
return { project, reason, plan };
|
|
187
|
+
}
|
|
188
|
+
export function cleanupApply(context, input) {
|
|
189
|
+
const { project, reason, plan } = input.prepared ?? prepareCleanupApply(context, input);
|
|
185
190
|
const now = context.now();
|
|
186
191
|
const results = [];
|
|
187
192
|
context.db.beginWriteTransaction();
|
|
193
|
+
let executing = false;
|
|
188
194
|
try {
|
|
195
|
+
validateNoDestructiveSurprises(context, plan, input.force);
|
|
189
196
|
for (const action of plan.actions) {
|
|
197
|
+
executing = true;
|
|
190
198
|
results.push(applyAction(context, action, reason, input.force, now));
|
|
191
199
|
}
|
|
192
200
|
const changed = results.filter((result) => result.changed).length;
|
|
@@ -203,6 +211,8 @@ export function cleanupApply(context, input) {
|
|
|
203
211
|
}
|
|
204
212
|
catch (error) {
|
|
205
213
|
context.db.exec("ROLLBACK");
|
|
214
|
+
if (executing)
|
|
215
|
+
throw new AppError("cleanup_apply_failed", "Cleanup failed after execution began; inspect retained artifacts before retrying", 1, { effect: "unknown", database_rolled_back: true, attempted_results: results, cause: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { cause_code: error.code } : {}) });
|
|
206
216
|
throw error;
|
|
207
217
|
}
|
|
208
218
|
return {
|
|
@@ -251,11 +261,29 @@ function validateCleanupPlan(value, project) {
|
|
|
251
261
|
throw new AppError("validation", "close_flow_session requires a native session identity", 2);
|
|
252
262
|
}
|
|
253
263
|
}
|
|
264
|
+
const requiredField = action.kind === "discard_stale_run" ? "run_id"
|
|
265
|
+
: ["repair_protocol_state", "cancel_queue_job", "close_worktree_record"].includes(String(action.kind)) ? "protocol_id" : undefined;
|
|
266
|
+
if (requiredField && (typeof action[requiredField] !== "string" || !action[requiredField].trim()))
|
|
267
|
+
throw new AppError("validation", `cleanup ${String(action.kind)} requires ${requiredField}`, 2);
|
|
268
|
+
const numericField = action.kind === "expire_lane_lock" ? "lock_id" : action.kind === "expire_lane_waiter" ? "waiter_id" : undefined;
|
|
269
|
+
if (numericField && (!Number.isSafeInteger(action[numericField]) || action[numericField] < 1))
|
|
270
|
+
throw new AppError("validation", `cleanup ${String(action.kind)} requires a positive integer ${numericField}`, 2);
|
|
271
|
+
if (action.worktree_path !== undefined && (typeof action.worktree_path !== "string" || !path.isAbsolute(action.worktree_path)))
|
|
272
|
+
throw new AppError("validation", "cleanup worktree_path must be an absolute path", 2);
|
|
254
273
|
return action;
|
|
255
274
|
});
|
|
256
275
|
return { project_root: project.root, project_id: project.id, actions };
|
|
257
276
|
}
|
|
258
277
|
function validateNoDestructiveSurprises(context, plan, force) {
|
|
278
|
+
for (const action of plan.actions) {
|
|
279
|
+
if (action.kind === "repair_protocol_state")
|
|
280
|
+
requireProtocol(context, action.protocol_id, action.project_id);
|
|
281
|
+
if (action.kind === "discard_stale_run") {
|
|
282
|
+
const run = context.db.get("SELECT index_json, status FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
|
|
283
|
+
if (run && ["running", "discarded"].includes(run.status))
|
|
284
|
+
cleanupRunIndex(run.index_json);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
259
287
|
const destructive = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
|
|
260
288
|
if (destructive && !force) {
|
|
261
289
|
throw new AppError("worktree_dirty", "cleanup apply refuses dirty worktree actions without --force --reason", 1, {
|
|
@@ -410,7 +438,7 @@ function applyAction(context, action, reason, force, now) {
|
|
|
410
438
|
return { kind: action.kind, changed: false, skipped: true, reason: "invalid_action" };
|
|
411
439
|
}
|
|
412
440
|
function discardedRunIndex(indexJson, reason, now) {
|
|
413
|
-
const index =
|
|
441
|
+
const index = cleanupRunIndex(indexJson);
|
|
414
442
|
index.schema_id = "dd-flow/flow-run@3";
|
|
415
443
|
index.status = "discarded";
|
|
416
444
|
index.verdict = "discarded";
|
|
@@ -420,12 +448,25 @@ function discardedRunIndex(indexJson, reason, now) {
|
|
|
420
448
|
index.finished_at ??= now;
|
|
421
449
|
return index;
|
|
422
450
|
}
|
|
451
|
+
function cleanupRunIndex(indexJson) {
|
|
452
|
+
try {
|
|
453
|
+
const index = JSON.parse(indexJson);
|
|
454
|
+
if (!index || typeof index !== "object" || Array.isArray(index))
|
|
455
|
+
throw new Error("RUN index must be an object");
|
|
456
|
+
return index;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
throw new AppError("runtime_state_invalid", "Cleanup cannot read retained RUN state", 1, { cause: String(error) });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
423
462
|
function dirtyGit(worktreePath) {
|
|
424
463
|
if (!fs.existsSync(worktreePath)) {
|
|
425
464
|
return false;
|
|
426
465
|
}
|
|
427
466
|
const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { encoding: "utf8" });
|
|
428
|
-
|
|
467
|
+
if (result.error || result.status !== 0)
|
|
468
|
+
throw new AppError("workspace_status_failed", "Cannot establish worktree status before cleanup", 1, { path: worktreePath, cause: result.error?.message ?? result.stderr });
|
|
469
|
+
return result.stdout.trim().length > 0;
|
|
429
470
|
}
|
|
430
471
|
function safeCleanupWorktreePath(context, projectId, worktreePath) {
|
|
431
472
|
if (!fs.existsSync(worktreePath)) {
|