@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.105
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 +6 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +23 -8
- package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/services/cleanup.js +81 -32
- package/dist/services/code-checks.js +18 -19
- package/dist/services/continuation-outcome.js +1 -1
- package/dist/services/controller-fanout.js +3 -3
- package/dist/services/eval-snapshots.js +69 -41
- package/dist/services/execution-policy.js +12 -1
- package/dist/services/hooks.js +150 -42
- package/dist/services/lanes.js +23 -18
- package/dist/services/lifecycle-command.js +11 -2
- package/dist/services/lifecycle-invocations.js +291 -54
- package/dist/services/managed-processes.js +35 -23
- package/dist/services/merge-queue.js +71 -67
- package/dist/services/merge-server.js +3 -11
- package/dist/services/migrations.js +28 -12
- package/dist/services/plan-runtime.js +8 -4
- package/dist/services/projects.js +3 -11
- package/dist/services/protocols.js +98 -68
- package/dist/services/recovery-snapshot-database.js +43 -8
- package/dist/services/run-control-worker.js +15 -32
- package/dist/services/run-control.js +3 -17
- package/dist/services/run-controller-capture.js +58 -27
- package/dist/services/run-controller-process.js +21 -30
- package/dist/services/run-controller-recovery.js +36 -37
- package/dist/services/run-controller.js +81 -86
- package/dist/services/run-fork.js +10 -6
- package/dist/services/run-projection.js +19 -64
- package/dist/services/run-recovery-runtime.js +24 -14
- package/dist/services/run-recovery.js +56 -54
- package/dist/services/runs.js +218 -160
- package/dist/services/runtime-budget.js +19 -45
- package/dist/services/runtime-scope-capture.js +8 -6
- package/dist/services/runtime-scope-control.js +49 -39
- package/dist/services/runtime-scope-resume.js +134 -122
- package/dist/services/runtime-scope-worker.js +48 -49
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/stage-context.js +6 -1
- package/dist/services/stage-lifecycle.js +1 -1
- package/dist/services/stage-pause.js +13 -29
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/usage.js +2 -9
- package/dist/services/vnext-code-review.js +10 -7
- package/dist/services/vnext-code.js +9 -17
- package/dist/services/vnext-merge.js +64 -21
- package/dist/services/vnext-plan-review.js +43 -30
- package/dist/services/vnext-plan.js +28 -24
- package/dist/services/vnext-protocolize.js +183 -20
- package/dist/services/vnext-specify.js +27 -19
- package/dist/services/work-registry.js +229 -150
- package/dist/storage/database.js +286 -56
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/writer-contract.js +3 -3
- package/dist/storage/writer-migration.js +24 -41
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +48 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.105
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Unify managed lifecycle correction and Stage/Work instructions across harnesses; enforce phase-specific workspace ownership; keep accepted Stage, Work, HITL, protocol, and RUN publications recoverable while moving filesystem and process work outside SQLite writer transactions. Retain exact native and physical-owner evidence through controller capture and snapshot restore.
|
|
8
|
+
|
|
3
9
|
## 0.9.0-beta.96
|
|
4
10
|
|
|
5
11
|
### 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.105",
|
|
4
|
+
"cli_commit": "e55ba4f273ae0873e06ebe9ea75e6a6e1eb8b095",
|
|
5
|
+
"built_at": "2026-09-26T22:58:06.111Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { quote } from "shell-quote";
|
|
7
7
|
import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../services/lifecycle-invocations.js";
|
|
8
8
|
import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
|
|
9
|
-
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
|
|
9
|
+
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, bindLifecyclePublishedPacket, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, retainObservedLifecycleSemantics, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
|
|
10
10
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
11
11
|
import { migrateStoreWriter } from "../storage/writer-migration.js";
|
|
12
12
|
import { helpForArgs } from "./help.js";
|
|
@@ -147,15 +147,19 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
147
147
|
}
|
|
148
148
|
const routerContext = createRouterContext(env);
|
|
149
149
|
const publicCommand = quote(["dd-flow", ...output.args]);
|
|
150
|
+
const nativeCommand = quote(["dd-flow", ...args]);
|
|
150
151
|
const lifecycleCommand = parseLifecycleCommand(publicCommand);
|
|
151
152
|
if (lifecycleCommand.kind === "standalone")
|
|
152
153
|
validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
|
|
153
154
|
const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
155
|
+
const nativeEvent = env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone"
|
|
156
|
+
? observedLifecycleNativeEvent(routerContext, nativeCommand, env.DD_FLOW_DAEMON_ID) : null;
|
|
157
|
+
const diagnostic = env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone"
|
|
158
|
+
? observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID, observedInvocation?.id) : null;
|
|
159
|
+
if (diagnostic && (!nativeEvent || Number(diagnostic.hook_event_id) > nativeEvent.id)) {
|
|
160
|
+
throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
161
|
+
}
|
|
154
162
|
if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
|
|
155
|
-
const diagnostic = observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
156
|
-
if (diagnostic)
|
|
157
|
-
throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
158
|
-
const nativeEvent = observedLifecycleNativeEvent(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
159
163
|
throw new AppError(nativeEvent ? "invocation_assignment_missing" : "invocation_receipt_missing", nativeEvent ? "Native call was observed, but its arguments do not match an issued lifecycle assignment" : "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
|
|
160
164
|
}
|
|
161
165
|
const invocationId = observedInvocation?.id ?? suppliedInvocationId;
|
|
@@ -253,6 +257,8 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
253
257
|
}
|
|
254
258
|
else {
|
|
255
259
|
invocationSettlement = { context: dispatchContext, id: invocationId };
|
|
260
|
+
if (observedInvocation)
|
|
261
|
+
retainObservedLifecycleSemantics(dispatchContext, invocationId, invocationArgs);
|
|
256
262
|
dispatchArgs = [...invocationArgs, "--hook-event-id", admission.eventKey];
|
|
257
263
|
preparedInput?.route?.parsed.options.set("hook-event-id", [admission.eventKey]);
|
|
258
264
|
dispatchContext.env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(lifecycleInvocationScope(dispatchContext, invocationId));
|
|
@@ -268,11 +274,20 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
268
274
|
if (!retainedOutcome)
|
|
269
275
|
preflightCliCompatibility(dispatchContext, invocationArgs, env, scopeProjectRoot);
|
|
270
276
|
if (!retainedOutcome) {
|
|
271
|
-
const command =
|
|
272
|
-
const event =
|
|
277
|
+
const command = !invocationId ? nativeCommand : quote(["dd-flow", ...dispatchArgs]);
|
|
278
|
+
const event = !invocationId ? nativeEvent : null;
|
|
273
279
|
settleHook = observeLifecycleCommand(dispatchContext, command, event ?? undefined);
|
|
274
280
|
}
|
|
275
281
|
const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output, preparedInput);
|
|
282
|
+
if (!retainedOutcome && invocationArgs[0] === "stage" && invocationArgs[1] === "start"
|
|
283
|
+
&& result && typeof result === "object" && "prompt_path" in result && typeof result.prompt_path === "string") {
|
|
284
|
+
try {
|
|
285
|
+
bindLifecyclePublishedPacket(dispatchContext, fs.readFileSync(result.prompt_path, "utf8"));
|
|
286
|
+
}
|
|
287
|
+
catch (cause) {
|
|
288
|
+
throw new AppError("invocation_packet_binding_failed", "Stage started but its published lifecycle assignments could not be retained", 1, { effect: "committed", cause: String(cause) });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
276
291
|
const completedHook = settleHook;
|
|
277
292
|
settleHook = undefined;
|
|
278
293
|
completedHook?.({ result });
|
|
@@ -303,7 +318,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
303
318
|
if (!settleHook && !invocationSettlement) {
|
|
304
319
|
let earlyContext;
|
|
305
320
|
try {
|
|
306
|
-
const command = quote(["dd-flow", ...
|
|
321
|
+
const command = quote(["dd-flow", ...args]);
|
|
307
322
|
const router = createRouterContext(env);
|
|
308
323
|
const event = observedLifecycleNativeEvent(router, command, env.DD_FLOW_DAEMON_ID);
|
|
309
324
|
// A correctable prepare rejection precedes the terminal command's
|
|
@@ -19,7 +19,7 @@ async function hook(options) {
|
|
|
19
19
|
if (identity.stale) return event === "PreToolUse" ? { decision: "deny", reason: "agy_stale_hook" } : {};
|
|
20
20
|
if (event === "PreToolUse" || event === "PostToolUse") {
|
|
21
21
|
const phase = event === "PreToolUse" ? "before" : "after", args = payload.toolCall?.args ?? {};
|
|
22
|
-
const normalized = { schema_id: "dd-flow/agy-tool-event@1", event_id: identity.event_id, phase, daemon_id: identity.daemon_id, conversation_id: payload.conversationId, parent_conversation_id: identity.parent_conversation_id, execution_num: payload.executionNum, step_index: payload.stepIdx, tool: payload.toolCall?.name, input: args, workspace_paths: payload.workspacePaths, transcript_path: payload.transcriptPath, model: payload.modelName, profile: identity.profile, ...(phase === "after" ? { error: payload.error ?? "" } : {}) };
|
|
22
|
+
const normalized = { schema_id: "dd-flow/agy-tool-event@1", event_id: identity.event_id, phase, daemon_id: identity.daemon_id, launch_cwd: identity.launch_cwd, conversation_id: payload.conversationId, parent_conversation_id: identity.parent_conversation_id, execution_num: payload.executionNum, step_index: payload.stepIdx, tool: payload.toolCall?.name, input: args, workspace_paths: payload.workspacePaths, transcript_path: payload.transcriptPath, model: payload.modelName, profile: identity.profile, ...(phase === "after" ? { error: payload.error ?? "" } : {}) };
|
|
23
23
|
await invokeNativeHook({ bin: options["dd-flow-bin"] ?? identity.dd_flow_bin,
|
|
24
24
|
args: ["agy", "event", "handle", "--project-root", options["project-root"] ?? identity.project_root, "--json"],
|
|
25
25
|
payload: normalized, home: options["dd-flow-home"] ?? identity.dd_flow_home, timeoutMs: budget.remaining("admission", 15_000) });
|
|
@@ -28,7 +28,7 @@ async function writeJson(file, value) { const temporary = `${file}.${process.pid
|
|
|
28
28
|
async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
|
|
29
29
|
async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace a non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
30
30
|
const shell = value => `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
31
|
-
function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary, DD_AGY_HOOK_NODE: process.execPath, DD_AGY_HOOK_ENTRY: config.entryPath, DD_AGY_STATE_DIR: config.stateDir, DD_AGY_DAEMON_ID: config.daemonId }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY"]) delete env[key]; return env; }
|
|
31
|
+
export function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary, DD_AGY_HOOK_NODE: process.execPath, DD_AGY_HOOK_ENTRY: config.entryPath, DD_AGY_STATE_DIR: config.stateDir, DD_AGY_DAEMON_ID: config.daemonId }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY","DD_FLOW_DAEMON_ID"]) delete env[key]; if (config.ddFlowHome && config.daemonId) env.DD_FLOW_DAEMON_ID = config.daemonId; return env; }
|
|
32
32
|
function errorPayload(error) { return { code: error.code ?? "operation_failed", message: error.message ?? String(error), retryable: error.retryable === true, ...(error.details === undefined ? {} : { details: error.details }) }; }
|
|
33
33
|
async function forwardUsage(config, sessionId, result, tools, descendants) {
|
|
34
34
|
if (!config.ddFlowBin || !config.projectRoot) return;
|
|
@@ -424,7 +424,7 @@ export class Runtime {
|
|
|
424
424
|
async observeHook(event, payload, budget = null) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); budget?.remaining("identity"); 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)); if (stale) { await this.journal("stale_hook", { hook_event: event, conversation_id: conversationId, step_index: step }); return { stale: true }; } 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, ...(event === "PreToolUse" ? { last_pre_hook: fingerprint } : {}), ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_pre_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 parent = child?.parent_provider_session_id ?? null;
|
|
425
425
|
const observed = payload.model ? observedProfile(payload) : {};
|
|
426
426
|
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" });
|
|
427
|
-
const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, 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(); 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, persistence_timing: this.persistTiming ?? null }); await this.completePendingTurn(); 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 };
|
|
427
|
+
const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, 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(); 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, persistence_timing: this.persistTiming ?? null }); await this.completePendingTurn(); return { event_id: eventId, daemon_id: this.config.daemonId, launch_cwd: this.config.cwd, 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 };
|
|
428
428
|
}
|
|
429
429
|
async finalizeProviderForControl() {
|
|
430
430
|
const pending = this.providerFinalization;
|
|
@@ -31,7 +31,7 @@ function errorPayload(error) { return { code: error.code ?? "operation_failed",
|
|
|
31
31
|
function shellQuote(value) { return `'${String(value).replace(/'/g, "'\\''")}'`; }
|
|
32
32
|
|
|
33
33
|
function executable(command, args = []) { const resolved = command ?? process.env.DD_GROK_BIN ?? "grok"; return /\.[cm]?js$/.test(resolved) ? { command: process.execPath, args: [resolved, ...args] } : { command: resolved, args }; }
|
|
34
|
-
function isolatedEnv(config) {
|
|
34
|
+
export function isolatedEnv(config) { const env = { ...process.env, HOME: config.grokHome, GROK_HOME: config.grokHome, ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome } : {}), ...(config.resourceHome ? { DD_FLOW_RESOURCE_HOME: config.resourceHome } : {}), ...(config.authPath ? { GROK_AUTH_PATH: config.authPath } : {}) }; delete env.DD_FLOW_DAEMON_ID; if (config.ddFlowHome && config.daemonId) env.DD_FLOW_DAEMON_ID = config.daemonId; return env; }
|
|
35
35
|
function within(candidate, root) { const resolved = path.resolve(candidate); const parent = path.resolve(root); return resolved === parent || resolved.startsWith(`${parent}${path.sep}`); }
|
|
36
36
|
|
|
37
37
|
async function writeIsolatedConfig(paths) {
|
|
@@ -167,8 +167,9 @@ export async function startDaemon(options) {
|
|
|
167
167
|
const previous = await readState(paths.dir); if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config); if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous daemon died with an active or unproven Session tree", false, { daemon_id: previous.daemon_id });
|
|
168
168
|
await removeSocket(paths.socket, paths.dir); await mkdir(paths.home, { recursive: true, mode: 0o700 }); const importedSession = await materializeSessionArchive(paths, config.sessionArchive); const copiedAuth = await copyAuth(paths, authSource); await writeIsolatedConfig(paths); if (!noFlow) await writeHook(paths, config, absolute(options.entryPath, "dd-grok entry")); const versions = await doctor({ bin: config.bin }); if (importedSession && importedSession.grok_version !== versions.versions.grok) throw new DaemonError("session_archive_version_mismatch", "Grok Build session archive was created by a different CLI version", false, { archive_version: importedSession.grok_version, current_version: versions.versions.grok }); const configIsolation = await inspectIsolatedConfig(config);
|
|
169
169
|
const resourceProcess = await registerDaemonProcess(config, { kind: "grok-daemon", owner: `grok:${path.basename(paths.dir)}`, operation: `grok-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
170
|
-
const
|
|
171
|
-
|
|
170
|
+
const daemonId = randomUUID();
|
|
171
|
+
const state = { descendants: previous?.descendants ?? [], schema_id: STATE_SCHEMA, daemon_id: daemonId, pid: null, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", active_tree: false, recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start", auth_status: copiedAuth ? "copied" : "absent", ...(importedSession ? { imported_session: importedSession } : {}), versions: versions.versions, config_isolation: configIsolation, config: { ...config, daemonId }, sessions: importedSession ? [{ provider_session_id: importedSession.provider_session_id, adapter_session_id: importedSession.provider_session_id, parent_provider_session_id: null, root_provider_session_id: importedSession.provider_session_id, cwd: importedSession.source_cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess };
|
|
172
|
+
await writeState(paths.state, state); const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [absolute(options.entryPath, "dd-grok entry"), "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(state.config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
172
173
|
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (status.ready) return status; } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
|
|
173
174
|
catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
|
|
174
175
|
}
|
|
@@ -2,6 +2,8 @@ export type DelegationWork = { work_id: string; start_command: string; launch_po
|
|
|
2
2
|
export type DelegationInput = { harness: string; stage: string; capacity: number; works?: DelegationWork[]; totalWorks?: number; remainingWorks?: number };
|
|
3
3
|
export function delegationAdapter(harness: string): string | null;
|
|
4
4
|
export function delegationContract(harness: string): string;
|
|
5
|
+
export const lifecycleRetryInstruction: string;
|
|
6
|
+
export const stageLifecycleInstruction: string;
|
|
5
7
|
export function workerDelegationTask(input: { workId: string; startCommand: string; launchPolicy?: string }): string;
|
|
6
8
|
export function renderDelegationInstructions(input: DelegationInput): string;
|
|
7
9
|
export function renderCapacityInstructions(input: { harness: string; maximum: number }): string;
|
|
@@ -49,7 +49,14 @@ export function delegationContract(harness) {
|
|
|
49
49
|
return `dd-flow/delegation/${key}@${key === "codex" ? 2 : 1}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export const lifecycleRetryInstruction = "
|
|
52
|
+
export const lifecycleRetryInstruction = "A failure is not permission to replay a lifecycle command. For proven effect=no_effect with recoverable=true and retry_command, correct the input and execute that exact issued successor. A check failure can retain receipts while leaving Work running: correct the evidenced cause first, then use the CLI's exact retry_command for an intentional fresh check attempt; never replay a check merely because Work is unsettled. If continuation.kind=repair_required, or a successful result has outcome=repair_required and a repair Work ID, end this Turn for the registered repair child; use any retry_command only after that repair settles and verification is refreshed. Unknown effects require reconciliation, never replay. A committed effect without an explicit continuation is not retryable. A native shell launch refusal before the CLI starts permits correcting only that shell-form error and retrying the same exact command. Otherwise report the primary error and stop; never invent IDs, call hooks directly, or run status as a substitute.";
|
|
53
|
+
|
|
54
|
+
export const stageLifecycleInstruction = [
|
|
55
|
+
"The project root identifies this RUN; use the Stage packet's declared execution workspace and absolute artifact paths. Do not change a native Session's working directory to a workspace provisioned for a later Stage.",
|
|
56
|
+
"In a controller-managed fanout, the coordinator ends the entry Turn after the Work graph is returned; the controller dispatches children. A completed child or Work is not Stage success: settle the declared graph and Stage gate before finishing.",
|
|
57
|
+
"Execute each runtime-issued lifecycle operation as a standalone call. Invocation IDs are internal authority; never add, copy or invent one.",
|
|
58
|
+
lifecycleRetryInstruction
|
|
59
|
+
].join(" ");
|
|
53
60
|
|
|
54
61
|
export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
|
|
55
62
|
if (!workId || !startCommand) throw new Error("ready fan-out Work lacks its exact start command");
|
|
@@ -62,16 +62,12 @@ export function ensureProtocolFiles(input) {
|
|
|
62
62
|
writeState(statePath, { ...state, flow_contract: flowContract, updated_at: input.now });
|
|
63
63
|
return { ...state, flow_contract: flowContract, updated_at: input.now };
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
/** Read legacy state and prepare a registration without publishing files. */
|
|
66
|
+
export function prepareRuntimeProtocolFiles(input) {
|
|
66
67
|
const currentFlowContract = loadProjectFlowContract(input.projectRoot);
|
|
67
68
|
const statePath = path.join(input.runtimeDir, "state.json");
|
|
68
69
|
const existing = readStateIfExists(statePath);
|
|
69
70
|
const flowContract = existing?.flow_contract ?? currentFlowContract;
|
|
70
|
-
ensureDir(input.runtimeDir);
|
|
71
|
-
const metadataPath = path.join(input.runtimeDir, "metadata.json");
|
|
72
|
-
if (!fs.existsSync(metadataPath)) {
|
|
73
|
-
fs.writeFileSync(metadataPath, `${JSON.stringify({ protocol_id: input.protocolId, handshake_id: input.handshakeId, project_root: input.projectRoot }, null, 2)}\n`);
|
|
74
|
-
}
|
|
75
71
|
const state = existing ??
|
|
76
72
|
{
|
|
77
73
|
schema_version: schemaVersion,
|
|
@@ -103,9 +99,22 @@ export function ensureRuntimeProtocolFiles(input) {
|
|
|
103
99
|
updated_at: input.now
|
|
104
100
|
};
|
|
105
101
|
const workspace = input.workspacePath ? { ...state.workspace, worktree_path: input.workspacePath } : state.workspace;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
return { ...state, workspace, flow_contract: flowContract, updated_at: input.now };
|
|
103
|
+
}
|
|
104
|
+
/** Metadata is a retained write-once compatibility artifact, never authority. */
|
|
105
|
+
export function publishRuntimeProtocolMetadata(input) {
|
|
106
|
+
ensureDir(input.runtimeDir);
|
|
107
|
+
const metadataPath = path.join(input.runtimeDir, "metadata.json");
|
|
108
|
+
if (fs.existsSync(metadataPath))
|
|
109
|
+
return;
|
|
110
|
+
const bytes = `${JSON.stringify({ protocol_id: input.protocolId, handshake_id: input.handshakeId, project_root: input.projectRoot }, null, 2)}\n`;
|
|
111
|
+
try {
|
|
112
|
+
fs.writeFileSync(metadataPath, bytes, { flag: "wx" });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== "EEXIST")
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
109
118
|
}
|
|
110
119
|
export function readState(projectRoot, protocolId) {
|
|
111
120
|
return readStateFile(stateJsonPath(projectRoot, protocolId), protocolId);
|
package/dist/runtime/context.js
CHANGED
|
@@ -8,11 +8,11 @@ export function createRouterContext(env) {
|
|
|
8
8
|
now: () => new Date().toISOString()
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
|
-
export function createContext(env, mode = "initialize") {
|
|
11
|
+
export function createContext(env, mode = "initialize", importPermitId) {
|
|
12
12
|
const ddFlowHome = resolveDdFlowHome(env);
|
|
13
13
|
return {
|
|
14
14
|
ddFlowHome,
|
|
15
|
-
db: getDatabase(ddFlowHome, mode),
|
|
15
|
+
db: getDatabase(ddFlowHome, mode, importPermitId),
|
|
16
16
|
env,
|
|
17
17
|
now: () => new Date().toISOString()
|
|
18
18
|
};
|
package/dist/services/cleanup.js
CHANGED
|
@@ -6,8 +6,9 @@ import { prepareJsonFile } from "../cli/input-preparation.js";
|
|
|
6
6
|
import { projectCheckoutRoot, resolveProjectRoot, runtimeStateJsonPath } from "../storage/paths.js";
|
|
7
7
|
import { appendAudit } from "./audit.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
|
-
import { protocolRunDiagnostics, readProtocolRuntimeState,
|
|
9
|
+
import { persistProtocolState, prepareProtocolRuntimeRepair, protocolRunDiagnostics, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
10
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
11
|
+
import { publishRunProjections } from "./runs.js";
|
|
11
12
|
const actionKinds = new Set([
|
|
12
13
|
"repair_protocol_state",
|
|
13
14
|
"cancel_queue_job",
|
|
@@ -187,30 +188,38 @@ export function prepareCleanupApply(context, input) {
|
|
|
187
188
|
}
|
|
188
189
|
export function cleanupApply(context, input) {
|
|
189
190
|
const { project, reason, plan } = input.prepared ?? prepareCleanupApply(context, input);
|
|
191
|
+
// A supplied plan can outlive its initial inspection. Refresh external
|
|
192
|
+
// workspace evidence before reserving the writer, then recheck SQL below.
|
|
193
|
+
if (input.prepared)
|
|
194
|
+
validateNoDestructiveSurprises(context, plan, input.force);
|
|
195
|
+
const prepared = prepareCleanupEffects(context, plan);
|
|
190
196
|
const now = context.now();
|
|
191
197
|
const results = [];
|
|
192
|
-
|
|
193
|
-
let executing = false;
|
|
198
|
+
let executing = false, committed = false;
|
|
194
199
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
200
|
+
context.db.writeTransaction(() => {
|
|
201
|
+
context.db.afterCommit(() => { committed = true; });
|
|
202
|
+
validateNoDestructiveSurprises(context, plan, input.force, false);
|
|
203
|
+
prepared.assertUnchanged();
|
|
204
|
+
for (const action of plan.actions) {
|
|
205
|
+
executing = true;
|
|
206
|
+
results.push(applyAction(context, action, reason, input.force, now, prepared));
|
|
207
|
+
}
|
|
208
|
+
const changed = results.filter((result) => result.changed).length;
|
|
209
|
+
if (changed > 0) {
|
|
210
|
+
appendAudit(context, {
|
|
211
|
+
projectId: project.id,
|
|
212
|
+
eventType: "cleanup.applied",
|
|
213
|
+
reason,
|
|
214
|
+
forced: input.force,
|
|
215
|
+
payload: { project_id: project.id, action_count: plan.actions.length, changed, results }
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
211
219
|
}
|
|
212
220
|
catch (error) {
|
|
213
|
-
|
|
221
|
+
if (committed || error instanceof AppError && error.details.effect === "committed")
|
|
222
|
+
throw new AppError(error instanceof AppError ? error.code : "cleanup_publication_failed", error instanceof Error ? error.message : "Committed cleanup publication failed", error instanceof AppError ? error.exitCode : 1, { ...(error instanceof AppError ? error.details : {}), effect: "committed", publication_pending: true, attempted_results: results, cause: String(error) });
|
|
214
223
|
if (executing)
|
|
215
224
|
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 } : {}) });
|
|
216
225
|
throw error;
|
|
@@ -274,7 +283,7 @@ function validateCleanupPlan(value, project) {
|
|
|
274
283
|
});
|
|
275
284
|
return { project_root: project.root, project_id: project.id, actions };
|
|
276
285
|
}
|
|
277
|
-
function validateNoDestructiveSurprises(context, plan, force) {
|
|
286
|
+
function validateNoDestructiveSurprises(context, plan, force, inspectFiles = true) {
|
|
278
287
|
for (const action of plan.actions) {
|
|
279
288
|
if (action.kind === "repair_protocol_state")
|
|
280
289
|
requireProtocol(context, action.protocol_id, action.project_id);
|
|
@@ -284,13 +293,13 @@ function validateNoDestructiveSurprises(context, plan, force) {
|
|
|
284
293
|
cleanupRunIndex(run.index_json);
|
|
285
294
|
}
|
|
286
295
|
}
|
|
287
|
-
const destructive = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
|
|
296
|
+
const destructive = inspectFiles && plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
|
|
288
297
|
if (destructive && !force) {
|
|
289
298
|
throw new AppError("worktree_dirty", "cleanup apply refuses dirty worktree actions without --force --reason", 1, {
|
|
290
299
|
path: destructive.worktree_path
|
|
291
300
|
});
|
|
292
301
|
}
|
|
293
|
-
const unsafeWorktree = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && !safeCleanupWorktreePath(context, action.project_id, action.worktree_path));
|
|
302
|
+
const unsafeWorktree = inspectFiles && plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && !safeCleanupWorktreePath(context, action.project_id, action.worktree_path));
|
|
294
303
|
if (unsafeWorktree) {
|
|
295
304
|
throw new AppError("worktree_cleanup_unsafe", "cleanup apply refuses unsafe worktree path", 1, {
|
|
296
305
|
path: unsafeWorktree.worktree_path
|
|
@@ -309,12 +318,53 @@ function validateNoDestructiveSurprises(context, plan, force) {
|
|
|
309
318
|
});
|
|
310
319
|
}
|
|
311
320
|
}
|
|
312
|
-
function
|
|
321
|
+
function prepareCleanupEffects(context, plan) {
|
|
322
|
+
const checks = [], files = new Map();
|
|
323
|
+
const repairs = new Map();
|
|
324
|
+
const read = (sql, params) => {
|
|
325
|
+
const value = context.db.get(sql, params), encoded = JSON.stringify(value);
|
|
326
|
+
checks.push(() => { if (JSON.stringify(context.db.get(sql, params)) !== encoded)
|
|
327
|
+
throw new AppError("cleanup_plan_changed", "Cleanup target changed during preparation", 1, { effect: "no_effect" }); });
|
|
328
|
+
return value;
|
|
329
|
+
};
|
|
330
|
+
const remember = (file) => { if (file !== undefined)
|
|
331
|
+
files.set(file, fs.existsSync(file)); };
|
|
332
|
+
for (const action of plan.actions) {
|
|
333
|
+
if (action.kind === "repair_protocol_state") {
|
|
334
|
+
read("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [action.project_id, action.protocol_id]);
|
|
335
|
+
const protocol = requireProtocol(context, action.protocol_id, action.project_id);
|
|
336
|
+
remember(protocol.state_path);
|
|
337
|
+
repairs.set(action, prepareProtocolRuntimeRepair(context, protocol));
|
|
338
|
+
}
|
|
339
|
+
if (action.kind === "discard_stale_run") {
|
|
340
|
+
const run = read("SELECT * FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
|
|
341
|
+
if (run) {
|
|
342
|
+
remember(run.runtime_path);
|
|
343
|
+
remember(run.run_dir);
|
|
344
|
+
remember(run.workspace_root);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (action.kind === "close_worktree_record") {
|
|
348
|
+
const record = read("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id]);
|
|
349
|
+
remember(action.worktree_path ?? record?.worktree_path);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { repairs, assertUnchanged: () => { for (const check of checks)
|
|
353
|
+
check(); }, exists: (file) => {
|
|
354
|
+
if (!files.has(file))
|
|
355
|
+
throw new AppError("cleanup_plan_changed", "Cleanup target has no prepared filesystem observation", 1, { path: file, effect: "no_effect" });
|
|
356
|
+
return files.get(file);
|
|
357
|
+
} };
|
|
358
|
+
}
|
|
359
|
+
function applyAction(context, action, reason, force, now, prepared) {
|
|
313
360
|
if (action.kind === "repair_protocol_state" && action.protocol_id) {
|
|
314
361
|
const protocol = requireProtocol(context, action.protocol_id, action.project_id);
|
|
315
362
|
const beforePath = protocol.state_path;
|
|
316
|
-
const existed =
|
|
317
|
-
|
|
363
|
+
const existed = prepared.exists(beforePath), repair = prepared.repairs.get(action);
|
|
364
|
+
context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [repair.statePath, repair.planPath, action.project_id, action.protocol_id]);
|
|
365
|
+
persistProtocolState(context, { ...protocol, state_path: repair.statePath, plan_path: repair.planPath }, repair.state);
|
|
366
|
+
protocol.state_path = repair.statePath;
|
|
367
|
+
appendAudit(context, { protocolId: protocol.id, projectId: protocol.project_id, eventType: "protocol.runtime_state_repaired", payload: { protocol_id: protocol.id, path: repair.statePath, repaired_from: repair.previousStatePath, source: "sqlite" } });
|
|
318
368
|
return {
|
|
319
369
|
kind: action.kind,
|
|
320
370
|
changed: !existed || beforePath !== protocol.state_path,
|
|
@@ -369,21 +419,19 @@ function applyAction(context, action, reason, force, now) {
|
|
|
369
419
|
if (run.status === "discarded") {
|
|
370
420
|
const index = discardedRunIndex(run.index_json, reason, now);
|
|
371
421
|
context.db.run("UPDATE runs SET verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE project_id = ? AND id = ?", [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
|
|
372
|
-
|
|
373
|
-
fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
|
|
422
|
+
context.db.afterCommit(() => publishRunProjections(context, action.project_id, action.run_id));
|
|
374
423
|
return { kind: action.kind, changed: true, run_id: action.run_id };
|
|
375
424
|
}
|
|
376
425
|
if (run.status !== "running") {
|
|
377
426
|
return { kind: action.kind, changed: false, skipped: true, reason: `already_${run.status}`, run_id: action.run_id };
|
|
378
427
|
}
|
|
379
|
-
if (
|
|
428
|
+
if (prepared.exists(run.runtime_path) || prepared.exists(run.run_dir) || prepared.exists(run.workspace_root)) {
|
|
380
429
|
return { kind: action.kind, changed: false, skipped: true, reason: "runtime_or_workspace_reappeared", run_id: action.run_id };
|
|
381
430
|
}
|
|
382
431
|
const index = discardedRunIndex(run.index_json, reason, now);
|
|
383
432
|
context.db.run(`UPDATE runs SET status = 'discarded', verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = ?
|
|
384
433
|
WHERE project_id = ? AND id = ? AND status = 'running'`, [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
|
|
385
|
-
|
|
386
|
-
fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
|
|
434
|
+
context.db.afterCommit(() => publishRunProjections(context, action.project_id, action.run_id));
|
|
387
435
|
context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
388
436
|
WHERE project_id = ? AND run_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.run_id]);
|
|
389
437
|
context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE project_id = ? AND run_id = ? AND ended_at IS NULL", [now, action.project_id, action.run_id]);
|
|
@@ -425,7 +473,7 @@ function applyAction(context, action, reason, force, now) {
|
|
|
425
473
|
}
|
|
426
474
|
if (action.kind === "close_worktree_record" && action.protocol_id) {
|
|
427
475
|
const worktreePath = action.worktree_path ?? context.db.get("SELECT worktree_path FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id])?.worktree_path;
|
|
428
|
-
const status = worktreePath &&
|
|
476
|
+
const status = worktreePath && prepared.exists(worktreePath) ? "kept" : "removed";
|
|
429
477
|
const result = context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ?
|
|
430
478
|
WHERE project_id = ? AND protocol_id = ? AND status = 'active'`, [status, now, now, action.project_id, action.protocol_id]);
|
|
431
479
|
return {
|
|
@@ -444,6 +492,7 @@ function discardedRunIndex(indexJson, reason, now) {
|
|
|
444
492
|
index.verdict = "discarded";
|
|
445
493
|
index.next_action = reason;
|
|
446
494
|
index.updated_at = now;
|
|
495
|
+
index.runtime_revision = (typeof index.runtime_revision === "number" ? index.runtime_revision : 0) + 1;
|
|
447
496
|
index.completed_at ??= now;
|
|
448
497
|
index.finished_at ??= now;
|
|
449
498
|
return index;
|
|
@@ -326,33 +326,40 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
326
326
|
return reconciled;
|
|
327
327
|
}
|
|
328
328
|
function reserveOrReuseReceipt(context, input) {
|
|
329
|
-
|
|
330
|
-
|
|
329
|
+
// Receipt files and artifact hashes are prepared before reserving SQLite.
|
|
330
|
+
// The compact SQL snapshot below fences races with another check settlement.
|
|
331
|
+
const snapshotSql = "SELECT id, status, exit_code, input_hash, profile_hash, command, gate, retry_of_receipt_id, receipt_path FROM check_receipts WHERE project_id = ? AND run_id = ? ORDER BY reservation_sequence, rowid";
|
|
332
|
+
const snapshot = JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId]));
|
|
333
|
+
const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
|
|
334
|
+
if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
|
|
335
|
+
throw new AppError("check_receipt_changed", "Check receipts changed during evidence preparation", 1);
|
|
336
|
+
const prior = receipts.filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash).at(-1) ?? null;
|
|
337
|
+
const reusablePrior = prior?.status === "passed" && checkReceiptEvidenceValid(prior);
|
|
338
|
+
const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
|
|
339
|
+
const successor = retryIds.size === 1 ? receipts.find((receipt) => receipt.retry_of_receipt_id === [...retryIds][0] && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash) : null;
|
|
340
|
+
const reusableSuccessor = successor?.status === "passed" && checkReceiptEvidenceValid(successor);
|
|
341
|
+
return context.db.writeTransaction(() => {
|
|
331
342
|
assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
|
|
332
|
-
|
|
343
|
+
if (JSON.stringify(context.db.all(snapshotSql, [input.projectId, input.runId])) !== snapshot)
|
|
344
|
+
throw new AppError("check_receipt_changed", "Check receipts changed before reservation", 1);
|
|
333
345
|
if (prior?.status === "running")
|
|
334
346
|
throw new AppError("check_in_progress", "A matching check invocation is still running", 1, { check_receipt_id: prior.id });
|
|
335
|
-
const retryIds = new Set([...(input.retryReceiptId ? [input.retryReceiptId] : []), ...(input.retryReceiptIds ?? [])]);
|
|
336
347
|
if (retryIds.size > 1)
|
|
337
348
|
throw new AppError("retry_check_invalid", "A check execution accepts exactly one retry predecessor", 2);
|
|
338
349
|
const retryId = [...retryIds][0];
|
|
339
350
|
if (retryId) {
|
|
340
351
|
if (!input.retryReason?.trim())
|
|
341
352
|
throw new AppError("retry_check_invalid", "A check retry requires the restored-condition reason", 2, { check_receipt_id: retryId });
|
|
342
|
-
const receipts = checkReceipts(context, { projectId: input.projectId, runId: input.runId });
|
|
343
353
|
const predecessor = receipts.find(receipt => receipt.id === retryId);
|
|
344
354
|
if (!predecessor || !["failed", "aborted"].includes(predecessor.status) || predecessor.input_hash !== input.inputHash || predecessor.profile_hash !== input.profileHash || predecessor.command !== input.group.command || predecessor.gate !== input.group.gate)
|
|
345
355
|
throw new AppError("retry_check_invalid", "Retry predecessor does not match this exact check contract", 2, { check_receipt_id: retryId });
|
|
346
|
-
const successor = receipts.find(receipt => receipt.retry_of_receipt_id === retryId && receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash);
|
|
347
356
|
if (prior?.id !== (successor?.id ?? predecessor.id))
|
|
348
357
|
throw new AppError("retry_check_invalid", "This retry was superseded by a newer check outcome; inspect the latest receipt", 2, { check_receipt_id: retryId, latest_check_receipt_id: prior?.id ?? null, latest_status: prior?.status ?? null });
|
|
349
358
|
if (successor?.status === "running")
|
|
350
359
|
throw new AppError("check_in_progress", "The requested retry is already running", 1, { check_receipt_id: successor.id, retry_of_receipt_id: retryId });
|
|
351
360
|
if (successor) {
|
|
352
|
-
if (
|
|
353
|
-
context.db.exec("COMMIT");
|
|
361
|
+
if (reusableSuccessor)
|
|
354
362
|
return { receipt: successor };
|
|
355
|
-
}
|
|
356
363
|
throw new AppError("retry_check_invalid", "The requested retry already has a terminal outcome; choose its failed receipt for another retry", 2, { check_receipt_id: successor.id, retry_of_receipt_id: retryId, status: successor.status });
|
|
357
364
|
}
|
|
358
365
|
}
|
|
@@ -360,10 +367,8 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
360
367
|
// failed check again: repairing dependencies/services need not change any
|
|
361
368
|
// tracked input. Lifecycle invocation replay owns command idempotency;
|
|
362
369
|
// the running-receipt guard above still prevents concurrent execution.
|
|
363
|
-
if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable &&
|
|
364
|
-
context.db.exec("COMMIT");
|
|
370
|
+
if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && reusablePrior)
|
|
365
371
|
return { receipt: prior };
|
|
366
|
-
}
|
|
367
372
|
let ordinal = (context.db.get("SELECT COALESCE(MAX(reservation_sequence), 0) + 1 AS sequence FROM check_receipts WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId])?.sequence ?? 1);
|
|
368
373
|
let localId = `RCP-${String(ordinal).padStart(3, "0")}`, id = `${input.workId ?? input.runId}/${localId}`;
|
|
369
374
|
while (context.db.get("SELECT 1 FROM check_receipts WHERE id = ?", [id])) {
|
|
@@ -374,13 +379,8 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
374
379
|
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
375
380
|
const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(retryId ? { retryOf: retryId, retryReason: input.retryReason } : {}), ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
|
|
376
381
|
context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, reservation_sequence, retry_of_receipt_id, retry_reason, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef).sort()), input.group.gate, input.group.command, input.inputHash, input.epoch, ordinal, reserved.retryOf ?? null, reserved.retryReason ?? null, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
|
|
377
|
-
context.db.exec("COMMIT");
|
|
378
382
|
return { reserved };
|
|
379
|
-
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
context.db.exec("ROLLBACK");
|
|
382
|
-
throw error;
|
|
383
|
-
}
|
|
383
|
+
}, "checks.reserveOrReuseReceipt");
|
|
384
384
|
}
|
|
385
385
|
function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, retry_of_receipt_id: input.retryOf ?? null, retry_reason: input.retryReason ?? null, status: input.status, exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, inputs: input.group.inputs, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
|
|
386
386
|
function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
@@ -653,7 +653,6 @@ function explicitInputFiles(root, inputs, ignoredRoots) {
|
|
|
653
653
|
}
|
|
654
654
|
function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.reusable ? null : group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
|
|
655
655
|
function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
|
|
656
|
-
function latestApplicableReceipt(context, input) { const candidates = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash); return candidates.at(-1) ?? null; }
|
|
657
656
|
export function checkReceiptEvidenceValid(receipt) { if (receipt.status !== "passed" || receipt.exit_code !== 0 || receipt.before_fingerprint !== receipt.after_fingerprint || !fs.existsSync(receipt.completion_path) || readCompletion(receipt.completion_path)?.exit_code !== 0)
|
|
658
657
|
return false; const stored = readReceipt(receipt.receipt_path); if (!stored || stored.status !== "passed" || stored.input_hash !== receipt.input_hash || stored.profile_hash !== receipt.profile_hash)
|
|
659
658
|
return false; const artifacts = collectCheckArtifacts(path.join(path.dirname(receipt.receipt_path), "artifacts"), stored.required_artifacts ?? []); return artifacts.complete && JSON.stringify(artifacts.items) === JSON.stringify(stored.artifacts ?? []); }
|
|
@@ -12,7 +12,7 @@ export function assertNoCorrelatedRejection(context, scope) {
|
|
|
12
12
|
const latest = new Map();
|
|
13
13
|
for (const row of rows) {
|
|
14
14
|
const value = JSON.parse(row.outcome_json);
|
|
15
|
-
latest.set(JSON.stringify([value.operation, value.session_id ?? null, value.work_id ?? null, value.attempt ?? null]), value);
|
|
15
|
+
latest.set(JSON.stringify([value.operation, value.session_id ?? null, value.correlation_key ?? value.fingerprint ?? value.work_id ?? null, value.attempt ?? null]), value);
|
|
16
16
|
}
|
|
17
17
|
for (const value of latest.values())
|
|
18
18
|
if (value.error && !isRegisteredRepair(context, value.error.details.continuation, scope))
|
|
@@ -3,7 +3,7 @@ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout,
|
|
|
3
3
|
import { getFlowRunVariables } from "./runs.js";
|
|
4
4
|
import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
|
|
5
5
|
import { resolveExecutionPolicy } from "./execution-policy.js";
|
|
6
|
-
import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
|
|
6
|
+
import { renderDelegationInstructions, stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
|
|
7
7
|
import { assertWorkLaunchReady } from "./work-registry.js";
|
|
8
8
|
function semanticCompletionPrompt(stage, completed) {
|
|
9
9
|
const task = stage === "plan-review"
|
|
@@ -15,7 +15,7 @@ function semanticCompletionPrompt(stage, completed) {
|
|
|
15
15
|
`All declared ${stage} child Work has settled (${completed} completed).`,
|
|
16
16
|
task,
|
|
17
17
|
"Then invoke the exact Stage finish command from the original authoritative packet.",
|
|
18
|
-
"
|
|
18
|
+
"If that command returns either outcome = repair_required with a repair Work ID, or error.details.continuation.kind = repair_required, the repair Work is already registered. End this Turn so the controller can dispatch it. Otherwise follow the command's actual result.",
|
|
19
19
|
"Do not create speculative repair Work or start a successor Stage."
|
|
20
20
|
].join(" ");
|
|
21
21
|
}
|
|
@@ -27,7 +27,7 @@ export function controllerStageEntryPrompt(stage, command) {
|
|
|
27
27
|
"Read the complete authoritative packet from the command's response_file result, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.",
|
|
28
28
|
"If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
|
|
29
29
|
"Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
|
|
30
|
-
|
|
30
|
+
stageLifecycleInstruction
|
|
31
31
|
].join("\n\n");
|
|
32
32
|
}
|
|
33
33
|
/** The shared controller owns this former eval-only Work-graph decision. */
|