@deksden-com/dd-flow-cli 0.9.0-beta.33 → 0.9.0-beta.35
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 +16 -0
- package/dist/build-info.json +3 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +42 -7
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +2 -1
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/observation-clock.mjs +5 -2
- package/dist/services/eval-snapshots.js +24 -2
- package/dist/services/run-recovery.js +2 -0
- package/dist/services/work-registry.js +154 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.35
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Wait for npm registry propagation and verify the annotated tag created by Changesets before completing a guarded release.
|
|
8
|
+
- Keep AGY native outcomes separate from settlement, invalidate old child settlement on new activity, and reserve productive operations before persistence across adapters.
|
|
9
|
+
- Retain process-local native Stop observation in no-flow technical probes without installing project hooks or forwarding flow mutations.
|
|
10
|
+
- Publish Work receipts after the authoritative SQL commit with resumable publication intents; preserve conflicting bytes, resume partial retries, and retain non-restorable diagnostic snapshots when receipts disagree.
|
|
11
|
+
- Freeze the release source/canon tuple, reconcile uncertain publication, verify the isolated and exact global consumer, and retain phase receipts without creating another version on retry.
|
|
12
|
+
|
|
13
|
+
## 0.9.0-beta.34
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Create and verify the immutable annotated Git tag as part of the guarded release publication.
|
|
18
|
+
|
|
3
19
|
## 0.9.0-beta.33
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.35",
|
|
4
|
+
"cli_commit": "e2ed74f88c272aa8269bd8be56b1f1d73467f4d7",
|
|
5
|
+
"built_at": "2026-09-07T21:56:49.561Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.0.6",
|
|
8
8
|
"commit": "c6fc50cb3b5526ea0162ee4454d1ee36f17be2da",
|
|
@@ -36,9 +36,15 @@ async function forwardUsage(config, sessionId, result, tools, descendants) {
|
|
|
36
36
|
return { status: "ingested" };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
async function prepare(paths, config) {
|
|
39
|
+
export async function prepare(paths, config) {
|
|
40
40
|
for (const dir of [paths.dir, paths.gemini, paths.runtime, paths.config, paths.temporary]) { await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); }
|
|
41
|
-
if (config.noFlow)
|
|
41
|
+
if (config.noFlow) {
|
|
42
|
+
// Technical probes still need native settlement, but must not install
|
|
43
|
+
// workspace hooks or forward any lifecycle mutation to dd-flow.
|
|
44
|
+
const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --event Stop --state-dir ${shell(paths.dir)} --json`;
|
|
45
|
+
await writeJson(path.join(paths.config, "hooks.json"), { "dd-flow-observer": { Stop: [{ type: "command", command, timeout: 30 }] } });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
42
48
|
const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --event __EVENT__ --state-dir ${shell(paths.dir)} --project-root ${shell(config.projectRoot)} --dd-flow-bin ${shell(config.ddFlowBin)} --dd-flow-home ${shell(config.ddFlowHome)} --json`;
|
|
43
49
|
const hooks = { "dd-flow": {
|
|
44
50
|
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: command.replace("__EVENT__", "PreToolUse"), timeout: 30 }] }],
|
|
@@ -92,7 +98,7 @@ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 3
|
|
|
92
98
|
const observed = Date.parse(status?.last_activity_at ?? "");
|
|
93
99
|
const progressed = status?.active_tree === true && Number.isFinite(observed) && observed > lastActivity;
|
|
94
100
|
if (progressed) lastActivity = observed;
|
|
95
|
-
if (!clock.sample(progressed ? lastActivity : undefined)) return;
|
|
101
|
+
if (!clock.sample(progressed ? lastActivity : undefined, progressed ? lastActivity : undefined)) return;
|
|
96
102
|
finish(new DaemonError(status?.active_tree ? "subject_liveness_timeout" : "operation_observation_lost", `${operation} has no recent productive provider activity`, true, {
|
|
97
103
|
operation_id: request.id,
|
|
98
104
|
last_activity_at: status?.last_activity_at ?? null,
|
|
@@ -204,7 +210,7 @@ export class Runtime {
|
|
|
204
210
|
// child can close while the asynchronous write is still pending.
|
|
205
211
|
if (!this.init && event.event === "result" && event.result?.status === "ERROR") this.startupError = new DaemonError("agy_provider_rejected", String(event.result.error ?? "Antigravity rejected the Session before initialization"), false, { provider_result: event.result });
|
|
206
212
|
this.observeProviderActivity(event); await this.journal("provider_event", { event }); if (event.event === "init") { this.init = { conversation_id: event.conversation_id, ...(event.init ?? {}) }; if (!this.init.conversation_id) throw new DaemonError("agy_init_missing", "Antigravity init has no conversation ID"); const requested = { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode, permission_mode: "always-proceed" }; this.profile = assertProfile(requested, observedProfile(this.init, requested)); await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: this.init.conversation_id, requested, observed: this.profile.observed, source: "native.init" }); await this.persist({ shutdown_state: "running", active_tree: false }); readyResolve(this.init); } else if (event.event === "step_update") this.observeStep(event.step_update ?? {}); else if (event.event === "result") { if (this.startupError) throw this.startupError; await this.finishTurn(event.result ?? {}); } } }
|
|
207
|
-
observeStep(step) { const sessionId = step.conversation_id ?? this.init?.conversation_id; const observation = this.sessionObservations.get(sessionId) ?? {}; if (Number.isInteger(step.step_index)) this.sessionObservations.set(sessionId, { ...observation, last_step_index: Math.max(observation.last_step_index ?? -1, step.step_index) }); if (step.step_type === "tool" && ["DONE", "ERROR"].includes(step.state)) { const stepId = step.step_id ?? step.stepId ?? step.id ?? step.tool_call_id ?? step.toolCallId ?? (Number.isInteger(step.step_index) ? `${sessionId}:${step.step_index}` : null); const duplicate = stepId && this.completedToolSteps.has(String(stepId)); if (!duplicate) { if (stepId) this.completedToolSteps.add(String(stepId)); else this.toolIdentityComplete = false; const name = step.tool_name ?? step.tool_info?.name ?? "unknown", current = this.toolCalls.get(name) ?? { total: 0, failures: 0 }; current.total += 1; if (step.state === "ERROR" || step.tool_info?.error) current.failures += 1; this.toolCalls.set(name, current); } } for (const child of step.subagent_info?.subagents ?? []) if (child.conversation_id) { const prior = this.descendants.get(child.conversation_id); this.descendants.set(child.conversation_id, { provider_session_id: child.conversation_id, parent_provider_session_id: this.init?.conversation_id ?? null, role: child.role ?? null, subagent_type: child.type_name ?? null, log_uri: child.log_uri ?? null, workspace_uris: child.workspace_uris ?? [], status: prior?.status ?? "unknown" }); } }
|
|
213
|
+
observeStep(step) { const sessionId = step.conversation_id ?? this.init?.conversation_id; const observation = this.sessionObservations.get(sessionId) ?? {}; if (Number.isInteger(step.step_index) && step.step_index > (observation.last_step_index ?? -1)) this.childActivity(sessionId); if (Number.isInteger(step.step_index)) this.sessionObservations.set(sessionId, { ...observation, last_step_index: Math.max(observation.last_step_index ?? -1, step.step_index) }); if (step.step_type === "tool" && ["DONE", "ERROR"].includes(step.state)) { const stepId = step.step_id ?? step.stepId ?? step.id ?? step.tool_call_id ?? step.toolCallId ?? (Number.isInteger(step.step_index) ? `${sessionId}:${step.step_index}` : null); const duplicate = stepId && this.completedToolSteps.has(String(stepId)); if (!duplicate) { if (stepId) this.completedToolSteps.add(String(stepId)); else this.toolIdentityComplete = false; const name = step.tool_name ?? step.tool_info?.name ?? "unknown", current = this.toolCalls.get(name) ?? { total: 0, failures: 0 }; current.total += 1; if (step.state === "ERROR" || step.tool_info?.error) current.failures += 1; this.toolCalls.set(name, current); } } for (const child of step.subagent_info?.subagents ?? []) if (child.conversation_id) { const prior = this.descendants.get(child.conversation_id); this.descendants.set(child.conversation_id, { ...prior, provider_session_id: child.conversation_id, parent_provider_session_id: this.init?.conversation_id ?? null, role: child.role ?? null, subagent_type: child.type_name ?? null, log_uri: child.log_uri ?? null, workspace_uris: child.workspace_uris ?? [], status: prior?.status ?? "unknown" }); } }
|
|
208
214
|
toolSnapshot() { const byName = {}, failuresByName = {}; let total = 0, failures = 0; for (const [name, value] of this.toolCalls) { byName[name] = value.total; failuresByName[name] = value.failures; total += value.total; failures += value.failures; } return { total, failures, by_name: byName, failures_by_name: failuresByName, completeness: this.toolIdentityComplete ? "complete" : "partial" }; }
|
|
209
215
|
async finishTurn(result) {
|
|
210
216
|
if (result.status === "RUNNING") { await this.persist({ active_tree: true }); return; }
|
|
@@ -245,7 +251,7 @@ export class Runtime {
|
|
|
245
251
|
receipt(result = this.lastResult) {
|
|
246
252
|
const assistantText = typeof result?.response === "string" ? result.response : typeof result?.text === "string" ? result.text : null;
|
|
247
253
|
const root = this.init?.conversation_id; const observation = this.sessionObservations.get(root);
|
|
248
|
-
const treeSettled = [...this.descendants.values()].every(child => ["completed", "failed", "cancelled", "settled_by_root"].includes(child.status));
|
|
254
|
+
const treeSettled = [...this.descendants.values()].every(child => child.tree_settled === true || ["completed", "failed", "cancelled", "settled_by_root"].includes(child.status));
|
|
249
255
|
// A terminal root result is the provider's authoritative end-of-turn
|
|
250
256
|
// observation when AGY does not emit a root Stop hook. A received
|
|
251
257
|
// non-idle Stop still wins, and every discovered child must be terminal.
|
|
@@ -254,7 +260,36 @@ export class Runtime {
|
|
|
254
260
|
return { harness: "antigravity-cli", runtime_family: "antigravity", provider_session_id: root ?? null, adapter_session_id: root ?? null, cwd: this.config.cwd, profile: this.profile, result, ...(assistantText === null ? {} : { assistant_text: assistantText }), usage: usageSnapshot(result ?? {}, this.toolSnapshot()), usage_ingest: this.usageIngest, descendants: [...this.descendants.values()], transcript_path: observation?.transcript_path ?? null, settled: Boolean((!this.active || terminalPending) && result && ["SUCCESS", "ERROR", "CANCELLED"].includes(result.status) && treeSettled && rootSettled) };
|
|
255
261
|
}
|
|
256
262
|
async zeroUsage() { await forwardUsage(this.config, this.init.conversation_id, { usage: {} }, this.toolSnapshot(), []); }
|
|
257
|
-
async prompt(text, assertDispatch = captureDispatchGuard(this)) {
|
|
263
|
+
async prompt(text, assertDispatch = captureDispatchGuard(this)) {
|
|
264
|
+
if (!text) throw new DaemonError("prompt_required", "prompt is required");
|
|
265
|
+
await this.start(); await this.draining;
|
|
266
|
+
if (this.active) throw new DaemonError("operation_in_progress", "an Antigravity turn is already running");
|
|
267
|
+
if (this.lastResult && !this.receipt().settled) throw new DaemonError("tree_not_settled", "the prior Antigravity agent tree is not fully settled");
|
|
268
|
+
let resolveTurn, rejectTurn;
|
|
269
|
+
const reply = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject; });
|
|
270
|
+
// Cancellation can reject while admission persistence is still pending.
|
|
271
|
+
void reply.catch(() => {});
|
|
272
|
+
const current = { resolve: resolveTurn, reject: rejectTurn, generation: ++this.turnGeneration, previousError: this.lastResult?.error ?? null };
|
|
273
|
+
// Reserve before persistence so different operation IDs cannot overlap.
|
|
274
|
+
this.active = current;
|
|
275
|
+
const root = this.init?.conversation_id, prior = this.sessionObservations.get(root) ?? {};
|
|
276
|
+
this.sessionObservations.set(root, { ...prior, step_floor: prior.last_step_index ?? null, stop: null, last_hook: null });
|
|
277
|
+
this.lastProviderEvent = null;
|
|
278
|
+
try {
|
|
279
|
+
await this.persist({ active_tree: true });
|
|
280
|
+
assertDispatch();
|
|
281
|
+
if (this.active !== current) throw new DaemonError("operation_cancelled", "prompt reservation was superseded");
|
|
282
|
+
this.child.stdin.write(`${JSON.stringify({ event: "user", message: { content: text } })}\n`, error => {
|
|
283
|
+
if (error) { if (this.active === current) this.active = null; current.reject(error); }
|
|
284
|
+
});
|
|
285
|
+
} catch (error) { if (this.active === current) this.active = null; current.reject(error); }
|
|
286
|
+
return await reply;
|
|
287
|
+
}
|
|
288
|
+
childActivity(sessionId) {
|
|
289
|
+
const child = this.descendants.get(sessionId);
|
|
290
|
+
if (!child) return;
|
|
291
|
+
this.descendants.set(sessionId, { ...child, previous_terminal_status: child.status === "unknown" ? child.previous_terminal_status : child.status, status: "unknown", tree_settled: false, settlement_evidence: null, activity_generation: this.turnGeneration });
|
|
292
|
+
}
|
|
258
293
|
async childForHook(conversationId) {
|
|
259
294
|
if (conversationId === this.init?.conversation_id) return null;
|
|
260
295
|
const deadline = Date.now() + 1_000;
|
|
@@ -263,7 +298,7 @@ export class Runtime {
|
|
|
263
298
|
if (!child) throw new DaemonError("agy_child_identity_unconfirmed", "Antigravity hook has no confirmed native parent", true, { conversation_id: conversationId });
|
|
264
299
|
return child;
|
|
265
300
|
}
|
|
266
|
-
async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && Number.isInteger(floor) && step <= floor; const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, last_hook: fingerprint, ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...
|
|
301
|
+
async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (child && !stale && event === "PreToolUse" && prior.last_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const settled = terminalPending ? this.receipt(this.lastResult) : null; const completed = settled?.settled ? this.active : null; if (completed) this.active = null; const parent = child?.parent_provider_session_id ?? null;
|
|
267
302
|
const observed = payload.model ? observedProfile(payload) : {};
|
|
268
303
|
await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: conversationId, parentSessionId: parent, requested: this.config, observed, source: "native.hook", evidence: payload.model ? "configured" : "unavailable", reason: payload.model ? null : "hook_omits_current_model" });
|
|
269
304
|
const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, turn_generation: this.turnGeneration, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(completed ? { active_tree: false } : {}); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null }); completed?.resolve(settled); return { event_id: eventId, daemon_id: this.config.daemonId, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
|
|
@@ -87,9 +87,9 @@ export async function serveDaemon(stateDir) {
|
|
|
87
87
|
return { stopped: true, clean: true, _shutdown: true };
|
|
88
88
|
}
|
|
89
89
|
if (active && !["session.inspect", "session.cancel"].includes(operation)) throw Object.assign(new Error(`${active} is in progress`), { code: "operation_busy", retryable: true });
|
|
90
|
-
if (operation === "session.create") { active = operation; await save({ active_operation: active });
|
|
91
|
-
if (operation === "session.prompt") { sessions.add(params.sessionId); active = operation; await save({ active_operation: active });
|
|
92
|
-
if (operation === "session.start") { sessions.add(params.sessionId); active = operation; await save({ active_operation: active });
|
|
90
|
+
if (operation === "session.create") { active = operation; try { await save({ active_operation: active }); const result = await createSessionWithBridge(bridge, { ...state.config, ...params }); sessions.add(result.provider_session_id); return result; } finally { active = null; await save({ active_operation: null }); } }
|
|
91
|
+
if (operation === "session.prompt") { sessions.add(params.sessionId); active = operation; try { await save({ active_operation: active }); return await promptSessionWithBridge(bridge, { ...state.config, ...params, assertDispatch }); } finally { active = null; await save({ active_operation: null }); } }
|
|
92
|
+
if (operation === "session.start") { sessions.add(params.sessionId); active = operation; try { await save({ active_operation: active }); return await startSessionWithBridge(bridge, { ...state.config, ...params, assertDispatch }); } finally { active = null; await save({ active_operation: null }); } }
|
|
93
93
|
if (operation === "session.inspect") return await inspectSessionWithBridge(bridge, { ...state.config, ...params });
|
|
94
94
|
if (operation === "session.cancel") { if (!sessions.has(params.sessionId)) throw Object.assign(new Error("cancellation Session is not owned by this daemon"), { code: "session_identity_mismatch" }); cancelPendingDispatch(bridge); return await cancelSessionWithBridge(bridge, { ...state.config, ...params }); }
|
|
95
95
|
throw Object.assign(new Error(`unknown operation: ${operation}`), { code: "unknown_operation" });
|
|
@@ -85,8 +85,9 @@ export async function serveDaemon(stateDir) {
|
|
|
85
85
|
catch (error) { stopping = false; await save({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); throw error; }
|
|
86
86
|
}
|
|
87
87
|
if (busy || stopping) throw new DroidError("operation_busy", "Another Droid operation is active");
|
|
88
|
-
busy = operation;
|
|
88
|
+
busy = operation;
|
|
89
89
|
try {
|
|
90
|
+
await save({ active_operation: busy });
|
|
90
91
|
if (operation === "session.create") { if (params.prompt) throw new DroidError("invalid_create", "create does not execute a prompt"); return await runtime.create(request.id); }
|
|
91
92
|
if (operation === "session.resume") return await runtime.resume(params.sessionId);
|
|
92
93
|
if (["session.prompt", "session.start"].includes(operation)) return await runtime.prompt(params.sessionId, params.prompt, request.id);
|
|
@@ -241,7 +241,7 @@ export class Runtime {
|
|
|
241
241
|
return running;
|
|
242
242
|
}
|
|
243
243
|
async requireSettled() { const running = await this.refreshTree(); if (running.length) throw new DaemonError("tree_not_settled", "daemon still owns a running Session tree", false, { sessions: running }); }
|
|
244
|
-
async productive(name, task) { if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true); this.active = name; for (const session of this.sessions.values()) session.native_root_receipt = null; await this.persist({ active_tree: true, active_operation: name });
|
|
244
|
+
async productive(name, task) { if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true); this.active = name; for (const session of this.sessions.values()) session.native_root_receipt = null; try { await this.persist({ active_tree: true, active_operation: name }); const result = await task(); this.track(result); const session = this.sessions.get(result.provider_session_id); if (session && (["session.create", "session.fork"].includes(name) || ["end_turn", "cancelled"].includes(result.turn?.stopReason))) session.native_root_receipt = { kind: name === "session.prompt" ? result.turn.stopReason : "created", daemon_id: this.state.daemon_id }; await this.persist({ active_tree: this.rootRunning(result) || this.running(result).length > 0, active_operation: null }); return { ...result, descendants: this.topology(result.provider_session_id) }; } catch (error) { await this.persist({ active_tree: true, active_operation: null }); throw error; } finally { this.active = null; } }
|
|
245
245
|
async dispatch(operation, params) {
|
|
246
246
|
const assertDispatch = captureDispatchGuard(this);
|
|
247
247
|
if (operation === "daemon.status") { let observationError; if (!this.active) try { await this.refreshTree(); } catch (error) { observationError = errorPayload(error); } return { observation_error: observationError ?? null, daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, config_isolation: this.state.config_isolation, shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status, auth_status: this.state.auth_status, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config: this.state.config }; }
|
|
@@ -62,7 +62,7 @@ export async function startDaemon(options) {
|
|
|
62
62
|
}
|
|
63
63
|
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); };
|
|
64
64
|
|
|
65
|
-
class Runtime {
|
|
65
|
+
export class Runtime {
|
|
66
66
|
constructor(paths, state, client, child, providerProcess) { this.paths = paths; this.state = state; this.client = client; this.child = child; this.providerProcess = providerProcess; this.sessions = new Map((state.sessions ?? []).map(x => [x.provider_session_id, x])); this.active = null; this.persisting = Promise.resolve(); }
|
|
67
67
|
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, sessions: [...this.sessions.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.then(async () => { await writeJson(this.paths.state, snapshot); await heartbeatDaemonProcess(snapshot.config, snapshot.resource_process).catch(() => {}); await heartbeatDaemonProcess(snapshot.config, this.providerProcess).catch(() => {}); }); return this.persisting; }
|
|
68
68
|
async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
|
|
@@ -82,7 +82,7 @@ class Runtime {
|
|
|
82
82
|
}
|
|
83
83
|
async requireSettled(id) { const value = await this.describe(id); if (!value.settled) throw new DaemonError("tree_not_settled", "OpenCode Session is not idle", false, { session_id: id, status: value.status }); return value; }
|
|
84
84
|
async reconcileUsage(observed) { await forwardUsage(this.state.config, observed.provider_session_id, observed.usage); for (const child of observed.children.filter(item => (item.parentID ?? observed.provider_session_id) === observed.provider_session_id)) await this.reconcileUsage(await this.describe(child.id)); }
|
|
85
|
-
async productive(name, task) { if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true); this.active = name; await this.persist({ active_tree: true, active_operation: name });
|
|
85
|
+
async productive(name, task) { if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true); this.active = name; try { await this.persist({ active_tree: true, active_operation: name }); const result = await task(); await this.persist({ active_tree: !result?.settled, active_operation: null }); await this.journal(name, result); return result; } catch (error) { await this.persist({ active_tree: true, active_operation: null }); await this.journal(`${name}.failed`, null); throw error; } finally { this.active = null; } }
|
|
86
86
|
async cancelTree(id, seen = new Set()) {
|
|
87
87
|
if (seen.has(id)) return [];
|
|
88
88
|
seen.add(id);
|
|
@@ -198,7 +198,7 @@ export async function stopDaemon(options) {
|
|
|
198
198
|
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
class DaemonRuntime {
|
|
201
|
+
export class DaemonRuntime {
|
|
202
202
|
constructor(paths, state, bridge, initialized) {
|
|
203
203
|
this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized;
|
|
204
204
|
this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item]));
|
|
@@ -250,8 +250,8 @@ class DaemonRuntime {
|
|
|
250
250
|
if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
|
|
251
251
|
this.activeProductive = name;
|
|
252
252
|
this.activeGeneration = this.controlGeneration ?? 0;
|
|
253
|
-
await this.persist({ active_tree: true, active_operation: name });
|
|
254
253
|
try {
|
|
254
|
+
await this.persist({ active_tree: true, active_operation: name });
|
|
255
255
|
const result = await task();
|
|
256
256
|
this.track(result);
|
|
257
257
|
const running = this.treeRunning(result);
|
|
@@ -7,7 +7,7 @@ export class ObservationClock {
|
|
|
7
7
|
this.lastWall = wall(); this.lastMono = monotonic(); this.elapsed = 0;
|
|
8
8
|
this.progress = undefined;
|
|
9
9
|
}
|
|
10
|
-
sample(progress) {
|
|
10
|
+
sample(progress, progressAt) {
|
|
11
11
|
const wall = this.wall(), mono = this.monotonic();
|
|
12
12
|
const delta = mono - this.lastMono, wallDelta = wall - this.lastWall;
|
|
13
13
|
const gap = delta > this.gapMs || wallDelta > this.gapMs || delta < 0 || wallDelta < 0;
|
|
@@ -18,7 +18,10 @@ export class ObservationClock {
|
|
|
18
18
|
this.elapsed = 0;
|
|
19
19
|
} else this.elapsed += delta;
|
|
20
20
|
this.lastWall = wall; this.lastMono = mono;
|
|
21
|
-
if (progress !== undefined && progress !== this.progress) {
|
|
21
|
+
if (progress !== undefined && progress !== this.progress) {
|
|
22
|
+
this.progress = progress;
|
|
23
|
+
this.elapsed = !gap && Number.isFinite(progressAt) ? Math.max(0, Math.min(this.timeoutMs, wall - progressAt)) : 0;
|
|
24
|
+
}
|
|
22
25
|
return this.elapsed >= this.timeoutMs;
|
|
23
26
|
}
|
|
24
27
|
}
|
|
@@ -8,7 +8,7 @@ import { resolveProjectRoot } from "../storage/paths.js";
|
|
|
8
8
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
9
9
|
import { getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
10
10
|
import { readVnextFlowDefinition } from "./vnext-specify.js";
|
|
11
|
-
import { assertRunWorkResultArtifacts } from "./work-registry.js";
|
|
11
|
+
import { assertRunWorkResultArtifacts, runWorkResultArtifactIssues } from "./work-registry.js";
|
|
12
12
|
/** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
|
|
13
13
|
export function createEvalBootstrapSnapshot(context, input) {
|
|
14
14
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
@@ -81,7 +81,8 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
81
81
|
const project = requireProjectByRoot(context, projectRoot);
|
|
82
82
|
const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
|
|
83
83
|
assertDedicatedHome(context, project.id, input.runId);
|
|
84
|
-
|
|
84
|
+
if (!input.incomplete)
|
|
85
|
+
assertRunWorkResultArtifacts(context, project.id, input.runId);
|
|
85
86
|
const recovery = typeof input.recoveryId === "string";
|
|
86
87
|
if (recovery) {
|
|
87
88
|
const guard = context.db.get("SELECT status, recovery_id FROM run_recovery_guards WHERE project_id = ? AND run_id = ? AND recovery_id = ?", [project.id, input.runId, input.recoveryId]);
|
|
@@ -130,6 +131,26 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
130
131
|
});
|
|
131
132
|
const projectGit = snapshotProjectGit(projectRoot, output);
|
|
132
133
|
const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
|
|
134
|
+
// Compare the copied files with the copied SQLite receipt, not a later read
|
|
135
|
+
// of the live database. Incomplete captures retain contradictions as evidence.
|
|
136
|
+
const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
|
|
137
|
+
let receiptIssues;
|
|
138
|
+
try {
|
|
139
|
+
receiptIssues = runWorkResultArtifactIssues(copiedContext, project.id, input.runId, file => {
|
|
140
|
+
const roots = [[context.ddFlowHome, "runtime"], [workspaceRoot, workspace.kind === "git_worktree" ? "workspace" : "project"], [projectRoot, "project"]];
|
|
141
|
+
for (const [source, payload] of roots) {
|
|
142
|
+
const relative = path.relative(source, file);
|
|
143
|
+
if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
|
144
|
+
return path.join(output, payload, relative);
|
|
145
|
+
}
|
|
146
|
+
throw new AppError("snapshot_receipt_path_invalid", "Work receipt is outside captured payloads", 1, { result_path: file });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
copiedContext.db.close?.();
|
|
151
|
+
}
|
|
152
|
+
if (!input.incomplete && receiptIssues.length)
|
|
153
|
+
throw new AppError("snapshot_work_receipt_inconsistent", "Copied Work result does not match its authoritative receipt", 1, { issues: receiptIssues });
|
|
133
154
|
const manifest = {
|
|
134
155
|
schema_id: "dd-flow/eval-run-snapshot@5",
|
|
135
156
|
run_id: input.runId,
|
|
@@ -140,6 +161,7 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
140
161
|
stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
|
|
141
162
|
...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : recovery ? { recovery_id: input.recoveryId, consistency: "sealed_writer_barrier_required", active_child_work_count: activeChildren, restorable: "same-runtime-or-explicit-restore" } : {}),
|
|
142
163
|
created_at: context.now(),
|
|
164
|
+
work_result_integrity: { valid: receiptIssues.length === 0, issues: receiptIssues },
|
|
143
165
|
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
144
166
|
project_sha256: treeHash(path.join(output, "project")),
|
|
145
167
|
project_git: projectGit,
|
|
@@ -8,6 +8,7 @@ import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
|
8
8
|
import { verifyRecoverySnapshot } from "./eval-snapshots.js";
|
|
9
9
|
import { findRecentMatchingHookEvent, hookSessionIdentity, recoveryAcceptMatchKey } from "./hooks.js";
|
|
10
10
|
import { flowCommand } from "./stage-pause.js";
|
|
11
|
+
import { materializePendingWorkResults } from "./work-registry.js";
|
|
11
12
|
function recoveryPacketReceipt(binding) {
|
|
12
13
|
if (!binding.packet_json || !binding.packet_sha256 || crypto.createHash("sha256").update(binding.packet_json).digest("hex") !== binding.packet_sha256)
|
|
13
14
|
throw new AppError("recovery_packet_missing", "Recovery requires its original verified Work packet", 1);
|
|
@@ -119,6 +120,7 @@ export function sealRunRecovery(context, input) {
|
|
|
119
120
|
return { ok: true, recovery: { ...guard, settlement: JSON.parse(guard.settlement_json ?? "{}") }, reused: true };
|
|
120
121
|
if (guard.status !== "draining")
|
|
121
122
|
throw new AppError("recovery_invalid_state", "Recovery is not draining", 1, { recovery_id: guard.recovery_id, status: guard.status });
|
|
123
|
+
materializePendingWorkResults(context, project.id, run.id);
|
|
122
124
|
const now = context.now();
|
|
123
125
|
const updated = context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining' AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [JSON.stringify(settlement), now, now, guard.recovery_id, project.id, run.id]);
|
|
124
126
|
if (updated.changes !== 1)
|
|
@@ -182,15 +182,90 @@ export function listWorks(context, input) {
|
|
|
182
182
|
}) };
|
|
183
183
|
}
|
|
184
184
|
export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id, ws.status, ws.prompt_path, ws.result_path, ws.created_at, ws.completed_at, s.harness AS harness_id, COALESCE(s.provider_session_id, s.session_id) AS native_session_id FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at", [work.project_id, work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions: sessions.map(({ session_id, harness_id, native_session_id, ...session }) => ({ ...session, ...(harness_id && native_session_id ? { session: publicSessionIdentity({ harness: harness_id, provider_session_id: native_session_id, session_id }) } : {}) })) } }; }
|
|
185
|
-
/**
|
|
186
|
-
export function
|
|
185
|
+
/** Inspect materializations without changing either the accepted receipt or conflicting bytes. */
|
|
186
|
+
export function runWorkResultArtifactIssues(context, projectId, runId, materializedPath = (file) => file) {
|
|
187
187
|
const works = context.db.all(`SELECT w.work_id, w.result, (SELECT ws.result_path FROM work_sessions ws WHERE ws.work_id = w.work_id ORDER BY ws.created_at DESC LIMIT 1) AS result_path FROM works w WHERE w.project_id = ? AND w.run_id = ? AND w.result_schema IS NOT NULL AND w.result IS NOT NULL AND w.status = 'completed'`, [projectId, runId]);
|
|
188
|
+
const issues = [];
|
|
188
189
|
for (const work of works) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const actual = fs.readFileSync(work.result_path, "utf8");
|
|
190
|
+
const file = work.result_path ? materializedPath(work.result_path) : null;
|
|
191
|
+
const actual = file && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
192
192
|
if (actual !== work.result)
|
|
193
|
-
|
|
193
|
+
issues.push({ code: actual === null ? "work_result_artifact_missing" : "work_result_artifact_drift", work_id: work.work_id, result_path: work.result_path, expected_sha256: crypto.createHash("sha256").update(work.result).digest("hex"), actual_sha256: actual === null ? null : crypto.createHash("sha256").update(actual).digest("hex") });
|
|
194
|
+
}
|
|
195
|
+
return issues;
|
|
196
|
+
}
|
|
197
|
+
export function assertRunWorkResultArtifacts(context, projectId, runId) {
|
|
198
|
+
const issue = runWorkResultArtifactIssues(context, projectId, runId)[0];
|
|
199
|
+
if (issue)
|
|
200
|
+
throw new AppError(issue.code, issue.code === "work_result_artifact_missing" ? "Completed structured Work has no retained result artifact" : "Completed Work result artifact no longer matches its authoritative receipt", 1, issue);
|
|
201
|
+
}
|
|
202
|
+
function receiptIntentPath(resultPath, sessionId) {
|
|
203
|
+
return `${resultPath}.pending-${crypto.createHash("sha256").update(sessionId).digest("hex")}`;
|
|
204
|
+
}
|
|
205
|
+
function receiptFileHash(file) {
|
|
206
|
+
return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
|
|
207
|
+
}
|
|
208
|
+
function publishReceiptFile(file, contents) {
|
|
209
|
+
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
210
|
+
try {
|
|
211
|
+
fs.writeFileSync(temporary, contents);
|
|
212
|
+
const descriptor = fs.openSync(temporary, "r");
|
|
213
|
+
try {
|
|
214
|
+
fs.fsyncSync(descriptor);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
fs.closeSync(descriptor);
|
|
218
|
+
}
|
|
219
|
+
fs.renameSync(temporary, file);
|
|
220
|
+
const directory = fs.openSync(path.dirname(file), "r");
|
|
221
|
+
try {
|
|
222
|
+
fs.fsyncSync(directory);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
fs.closeSync(directory);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
fs.rmSync(temporary, { force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Replay only a prepared publication whose exact receipt was committed in SQLite. */
|
|
233
|
+
export function materializePendingWorkResults(context, projectId, runId) {
|
|
234
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
235
|
+
try {
|
|
236
|
+
const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id AND ws.status = w.status WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NOT NULL AND w.result IS NOT NULL AND w.status IN ('completed', 'failed', 'cancelled') AND ws.result_path IS NOT NULL`, [projectId, runId]);
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
const intentPath = receiptIntentPath(row.result_path, row.id);
|
|
239
|
+
if (!fs.existsSync(intentPath))
|
|
240
|
+
continue;
|
|
241
|
+
const intent = JSON.parse(fs.readFileSync(intentPath, "utf8"));
|
|
242
|
+
const digest = crypto.createHash("sha256").update(row.result).digest("hex");
|
|
243
|
+
if (intent.work_id !== row.work_id || intent.work_session_id !== row.id || intent.status !== row.status || intent.result_sha256 !== digest)
|
|
244
|
+
throw new AppError("work_result_publication_conflict", "Pending result publication does not identify the committed receipt", 1, { work_id: row.work_id, intent_path: intentPath });
|
|
245
|
+
const actual = receiptFileHash(row.result_path);
|
|
246
|
+
if (actual !== digest && actual !== intent.previous_sha256)
|
|
247
|
+
throw new AppError("work_result_artifact_drift", "Result changed after its publication was prepared; preserve it for diagnosis", 1, { work_id: row.work_id, actual_sha256: actual, expected_sha256: digest });
|
|
248
|
+
if (fs.existsSync(row.result_path)) {
|
|
249
|
+
const previous = fs.readFileSync(row.result_path);
|
|
250
|
+
if (!previous.equals(Buffer.from(row.result))) {
|
|
251
|
+
const retained = `${row.result_path}.conflict-${crypto.createHash("sha256").update(previous).digest("hex")}`;
|
|
252
|
+
try {
|
|
253
|
+
fs.writeFileSync(retained, previous, { flag: "wx", flush: true });
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error.code !== "EEXIST" || !fs.readFileSync(retained).equals(previous))
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
publishReceiptFile(row.result_path, row.result);
|
|
262
|
+
fs.unlinkSync(intentPath);
|
|
263
|
+
}
|
|
264
|
+
context.db.exec("COMMIT");
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
context.db.exec("ROLLBACK");
|
|
268
|
+
throw error;
|
|
194
269
|
}
|
|
195
270
|
}
|
|
196
271
|
export function mutateWorkDeps(context, input) {
|
|
@@ -357,6 +432,8 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
357
432
|
const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
358
433
|
if (work.status !== "created")
|
|
359
434
|
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
435
|
+
if (fs.existsSync(path.join(requireRunHome(run), "works", work.work_id, ".retry.json")))
|
|
436
|
+
throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
|
|
360
437
|
const now = context.now();
|
|
361
438
|
const linkId = `WS-${crypto.randomUUID()}`;
|
|
362
439
|
const directory = path.join(requireRunHome(run), "works", work.work_id);
|
|
@@ -409,12 +486,74 @@ export function finishFanInWork(context, id, result) { const work = requireWork(
|
|
|
409
486
|
throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
|
|
410
487
|
export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
|
|
411
488
|
export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
|
|
412
|
-
export function retryWork(context, id, reason) {
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
489
|
+
export function retryWork(context, id, reason) {
|
|
490
|
+
const work = requireWork(context, id);
|
|
491
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
492
|
+
const directory = path.join(requireRunHome(run), "works", work.work_id);
|
|
493
|
+
const journal = path.join(directory, ".retry.json");
|
|
494
|
+
let archive = "";
|
|
495
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
496
|
+
try {
|
|
497
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
498
|
+
const current = requireWork(context, work.work_id);
|
|
499
|
+
const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
|
|
500
|
+
if (pending && (pending.work_id !== work.work_id || !/^ATT-\d{3,}$/.test(pending.archive) || pending.entries.some(file => !["prompt.md", "result.json", "context.json"].includes(file))))
|
|
501
|
+
throw new AppError("work_retry_invalid", "Retry journal does not identify this Work", 1);
|
|
502
|
+
if (current.status !== "failed" && !(pending && current.status === "created"))
|
|
503
|
+
throw new AppError("invalid_work_state", "Only failed Work may be retried", 2);
|
|
504
|
+
const attempts = path.join(directory, "attempts");
|
|
505
|
+
let number = 1;
|
|
506
|
+
while (fs.existsSync(path.join(attempts, `ATT-${String(number).padStart(3, "0")}`)))
|
|
507
|
+
number += 1;
|
|
508
|
+
const prepared = pending ?? { work_id: work.work_id, archive: `ATT-${String(number).padStart(3, "0")}`, entries: ["prompt.md", "result.json", "context.json"].filter(file => fs.existsSync(path.join(directory, file))), result_sha256: current.result === null ? null : crypto.createHash("sha256").update(current.result).digest("hex") };
|
|
509
|
+
archive = path.join(attempts, prepared.archive);
|
|
510
|
+
if (current.status === "failed") {
|
|
511
|
+
if (prepared.result_sha256 !== (current.result === null ? null : crypto.createHash("sha256").update(current.result).digest("hex")))
|
|
512
|
+
throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
|
|
513
|
+
fs.mkdirSync(archive, { recursive: true });
|
|
514
|
+
if (!pending)
|
|
515
|
+
publishReceiptFile(journal, JSON.stringify(prepared));
|
|
516
|
+
const receipt = path.join(archive, "accepted-receipt.json");
|
|
517
|
+
const receiptBytes = JSON.stringify({ work_id: current.work_id, status: current.status, result: current.result, result_sha256: prepared.result_sha256 });
|
|
518
|
+
if (fs.existsSync(receipt) && fs.readFileSync(receipt, "utf8") !== receiptBytes)
|
|
519
|
+
throw new AppError("work_retry_conflict", "Archived receipt differs from failed Work", 1);
|
|
520
|
+
if (!fs.existsSync(receipt))
|
|
521
|
+
publishReceiptFile(receipt, receiptBytes);
|
|
522
|
+
for (const file of prepared.entries) {
|
|
523
|
+
const source = path.join(directory, file), target = path.join(archive, file);
|
|
524
|
+
if (fs.existsSync(source)) {
|
|
525
|
+
if (fs.existsSync(target))
|
|
526
|
+
throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
|
|
527
|
+
fs.renameSync(source, target);
|
|
528
|
+
}
|
|
529
|
+
else if (!fs.existsSync(target))
|
|
530
|
+
throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
|
|
531
|
+
}
|
|
532
|
+
for (const folder of [archive, directory]) {
|
|
533
|
+
const descriptor = fs.openSync(folder, "r");
|
|
534
|
+
try {
|
|
535
|
+
fs.fsyncSync(descriptor);
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
fs.closeSync(descriptor);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const column of ["prompt_path", "result_path"]) {
|
|
542
|
+
const file = column === "prompt_path" ? "prompt.md" : "result.json";
|
|
543
|
+
context.db.run(`UPDATE work_sessions SET ${column} = ? WHERE work_id = ? AND ${column} = ?`, [path.join(archive, file), work.work_id, path.join(directory, file)]);
|
|
544
|
+
}
|
|
545
|
+
context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'failed'", [context.now(), work.work_id]);
|
|
546
|
+
}
|
|
547
|
+
context.db.exec("COMMIT");
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
context.db.exec("ROLLBACK");
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
fs.rmSync(journal, { force: true });
|
|
554
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
555
|
+
return { ok: true, work_id: work.work_id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason };
|
|
556
|
+
}
|
|
418
557
|
async function settle(context, id, status, result, progress) {
|
|
419
558
|
const work = requireWork(context, id);
|
|
420
559
|
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
@@ -461,10 +600,10 @@ async function settle(context, id, status, result, progress) {
|
|
|
461
600
|
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
462
601
|
if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
|
|
463
602
|
throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
|
|
603
|
+
if (link?.result_path && work.parent_work_id !== null)
|
|
604
|
+
publishReceiptFile(receiptIntentPath(link.result_path, link.id), JSON.stringify({ work_id: work.work_id, work_session_id: link.id, status, result_sha256: crypto.createHash("sha256").update(result).digest("hex"), previous_sha256: receiptFileHash(link.result_path) }));
|
|
464
605
|
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
|
|
465
606
|
if (link) {
|
|
466
|
-
if (link.result_path && work.parent_work_id !== null)
|
|
467
|
-
fs.writeFileSync(link.result_path, result);
|
|
468
607
|
context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, now, now, link.id]);
|
|
469
608
|
const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [link.session_id]);
|
|
470
609
|
if (!stillRunning)
|
|
@@ -476,6 +615,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
476
615
|
context.db.exec("ROLLBACK");
|
|
477
616
|
throw error;
|
|
478
617
|
}
|
|
618
|
+
materializePendingWorkResults(context, work.project_id, work.run_id);
|
|
479
619
|
settleRecoveryWork(context, work.work_id, status);
|
|
480
620
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
481
621
|
const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
|