@deksden-com/dd-flow-cli 0.9.0-beta.103 → 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 +32 -11
- 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/harness-runtime/lib/managed-daemon.mjs +11 -6
- 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,11 +318,17 @@ 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", ...
|
|
307
|
-
const
|
|
308
|
-
|
|
321
|
+
const command = quote(["dd-flow", ...args]);
|
|
322
|
+
const router = createRouterContext(env);
|
|
323
|
+
const event = observedLifecycleNativeEvent(router, command, env.DD_FLOW_DAEMON_ID);
|
|
324
|
+
// A correctable prepare rejection precedes the terminal command's
|
|
325
|
+
// single-use claim. Correlate its unique hook by the same lifecycle
|
|
326
|
+
// match key used at dispatch, including compound provider tool shells;
|
|
327
|
+
// otherwise a repaired retry sees two fresh, unclaimed events.
|
|
328
|
+
if (event || (error.details.phase === "prepare" && error.details.effect === "no_effect"
|
|
329
|
+
&& fs.existsSync(path.join(router.ddFlowHome, "db.sqlite")))) {
|
|
309
330
|
earlyContext = createContext({ ...env }, "hook");
|
|
310
|
-
observeLifecycleCommand(earlyContext, command, event)({ error });
|
|
331
|
+
observeLifecycleCommand(earlyContext, command, event ?? undefined)({ error });
|
|
311
332
|
}
|
|
312
333
|
}
|
|
313
334
|
catch (persistence) {
|
|
@@ -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");
|
|
@@ -53,15 +53,20 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
|
|
56
|
-
for (const lease of leases.values())
|
|
57
|
-
if (lease.error) throw lease.error;
|
|
58
|
-
if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown" });
|
|
59
|
-
}
|
|
56
|
+
for (const lease of leases.values()) if (!lease.stateDir && lease.error) throw lease.error;
|
|
60
57
|
const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
|
|
61
58
|
const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
|
|
62
59
|
if (!owner) return;
|
|
63
60
|
const record = state.resource_process;
|
|
64
61
|
if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
|
|
62
|
+
for (const lease of leases.values()) {
|
|
63
|
+
if (lease.stateDir !== stateDir) continue;
|
|
64
|
+
if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown", details: { process_id: lease.record.id, cause: lease.error ? errorRecord(lease.error) : null } });
|
|
65
|
+
// A failed renewal is not a lost lease. Reconfirm before productive work;
|
|
66
|
+
// never dispatch on the old lease merely because it has time remaining.
|
|
67
|
+
if (lease.error && !lease.lost) await heartbeatDaemonProcess(lease.config, lease.record);
|
|
68
|
+
if (lease.error) throw lease.error;
|
|
69
|
+
}
|
|
65
70
|
for (;;) {
|
|
66
71
|
assertCurrent();
|
|
67
72
|
const result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch });
|
|
@@ -211,7 +216,7 @@ export async function heartbeatDaemonProcess(config, record) {
|
|
|
211
216
|
if (!record?.id || !record.lease_token) return;
|
|
212
217
|
let lease = leases.get(record.id);
|
|
213
218
|
if (!lease) {
|
|
214
|
-
lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000 };
|
|
219
|
+
lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
|
|
215
220
|
leases.set(record.id, lease);
|
|
216
221
|
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
217
222
|
lease.timer.unref();
|
|
@@ -228,7 +233,7 @@ export async function heartbeatDaemonProcess(config, record) {
|
|
|
228
233
|
lease.error = null;
|
|
229
234
|
lease.expiresAt = Date.now() + 300_000;
|
|
230
235
|
} catch (cause) {
|
|
231
|
-
lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id } });
|
|
236
|
+
lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id, cause: errorRecord(cause) } });
|
|
232
237
|
throw lease.error;
|
|
233
238
|
}
|
|
234
239
|
})();
|
|
@@ -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;
|