@deksden-com/dd-flow-cli 0.9.0-beta.94 → 0.9.0-beta.96
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 +12 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/run-cli.js +20 -4
- package/dist/harness-runtime/DD-ZCODE.md +12 -3
- package/dist/harness-runtime/bin/dd-agy.mjs +7 -5
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +19 -4
- package/dist/harness-runtime/lib/dd-droid.mjs +2 -5
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +29 -4
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +21 -2
- package/dist/harness-runtime/lib/dd-opencode.mjs +1 -1
- package/dist/harness-runtime/lib/dd-zcode.mjs +29 -7
- package/dist/harness-runtime/lib/managed-daemon.mjs +7 -0
- package/dist/services/continuation-outcome.js +1 -1
- package/dist/services/hooks.js +8 -1
- package/dist/services/lifecycle-invocations.js +5 -1
- package/dist/services/run-controller.js +26 -16
- package/dist/services/runtime-budget.js +7 -2
- package/dist/services/vnext-code.js +2 -1
- package/dist/services/vnext-protocolize.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.96
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4769ad6: Settle early lifecycle rejections against their native calls, reconcile retained provider capacity before continuing, and preserve causal outcomes across AGY, Grok, Droid, OpenCode, Codex, and ZCode adapters.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.95
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Qualify the exact ZCode 0.16.9 / zcode-acp 0.46.7 contract @2 tuple after live public-command receipts, and reject inferred or failed native completion evidence.
|
|
14
|
+
|
|
3
15
|
## 0.9.0-beta.94
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.96",
|
|
4
|
+
"cli_commit": "cb6c669ff3f24115ac0a4d2c04c8877eb8683bc6",
|
|
5
|
+
"built_at": "2026-09-22T12:33:21.451Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -89,9 +89,25 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
89
89
|
output = parseOutputOptions(args);
|
|
90
90
|
}
|
|
91
91
|
catch (error) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const rejection = isAppError(error) ? error : new AppError("usage", error instanceof Error ? error.message : String(error), 2);
|
|
93
|
+
Object.assign(rejection.details, { phase: "prepare", effect: "no_effect", recoverable: true, ...rejection.details });
|
|
94
|
+
try {
|
|
95
|
+
const command = quote(["dd-flow", ...args]), event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
|
|
96
|
+
if (event) {
|
|
97
|
+
const context = createContext({ ...env }, "hook");
|
|
98
|
+
try {
|
|
99
|
+
observeLifecycleCommand(context, command, event)({ error: rejection });
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
context.db.close?.();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (persistence) {
|
|
107
|
+
rejection.details.lifecycle_diagnostic_error = String(persistence);
|
|
108
|
+
}
|
|
109
|
+
writeJson(io.stderr, { ok: false, error: { code: rejection.code, message: rejection.message, details: rejection.details } });
|
|
110
|
+
return rejection.exitCode;
|
|
95
111
|
}
|
|
96
112
|
const progress = progressForCommand(output.args);
|
|
97
113
|
let heartbeat;
|
|
@@ -284,7 +300,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
284
300
|
}
|
|
285
301
|
catch (caught) {
|
|
286
302
|
const error = isAppError(caught) ? caught : new AppError("unexpected", caught instanceof Error ? caught.message : String(caught), 1, { effect: "unknown", cause: { code: caught?.code ?? null, sqlite_extended_code: caught?.errcode ?? null } });
|
|
287
|
-
if (!settleHook && !invocationSettlement
|
|
303
|
+
if (!settleHook && !invocationSettlement) {
|
|
288
304
|
let earlyContext;
|
|
289
305
|
try {
|
|
290
306
|
const command = quote(["dd-flow", ...output.args]);
|
|
@@ -18,10 +18,18 @@ closed transport is not proof that its native descendants stopped. The adapter
|
|
|
18
18
|
must retain causal errors, missing observations and usage completeness rather
|
|
19
19
|
than turn them into successful/zero-valued results.
|
|
20
20
|
|
|
21
|
+
The bridge marks heuristic ACP completion with
|
|
22
|
+
`_meta.zcodeCompletionEvidence: "inferred"` and exhausted failed turns with
|
|
23
|
+
`"failed"`. dd-zcode rejects these before any post-turn materializing read:
|
|
24
|
+
inferred means `native_outcome_unknown` (not retryable automatically), failed
|
|
25
|
+
means `zcode_turn_not_completed`. This additive evidence does not change the
|
|
26
|
+
acceptance of already-qualified immutable older tuples. Native allocation
|
|
27
|
+
notifications are causal identity evidence, not authorization to replay a turn.
|
|
28
|
+
|
|
21
29
|
## Bridge contract
|
|
22
30
|
|
|
23
31
|
The identity check uses `--dd-harness-version` (`dd-zcode-harness@1` for existing
|
|
24
|
-
qualified builds, `dd-zcode-harness@2` for the
|
|
32
|
+
qualified builds, `dd-zcode-harness@2` for the qualified v0.46.7 bridge) and
|
|
25
33
|
`--dd-harness-commit`, alongside native and bridge versions. This is the current
|
|
26
34
|
mechanism, not a general capability negotiation protocol. `zcodeLifecycleQualification`
|
|
27
35
|
also admits exact native-binary SHA / bridge-commit tuples; a new tuple requires
|
|
@@ -56,8 +64,9 @@ remain supported through their existing measured projection. All adapter usage
|
|
|
56
64
|
paths use the same helper; dd-flow services still own accounting deltas.
|
|
57
65
|
|
|
58
66
|
Contract support does not admit a new binary/commit tuple automatically. The
|
|
59
|
-
v0.46.7 @2
|
|
60
|
-
|
|
67
|
+
v0.46.7 @2 tuple is admitted only for native SHA `500ae84f…f466` and bridge
|
|
68
|
+
commit `f4d47b5…2833`, after a live public-command receipt probe. Preserve that
|
|
69
|
+
fail-closed distinction when preparing later evals.
|
|
61
70
|
|
|
62
71
|
Future consumer-only retry, recovery, readiness and accounting rules belong
|
|
63
72
|
here or in the owning dd-flow service. Bridge changes are justified when native
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
|
-
import {
|
|
4
|
+
import { invokeNativeHook } from "../lib/native-hook-command.mjs";
|
|
5
5
|
import { doctor } from "../lib/dd-agy.mjs";
|
|
6
6
|
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-agy-daemon.mjs";
|
|
7
7
|
|
|
@@ -10,12 +10,14 @@ const prompt = async options => options["prompt-file"] ? await readFile(options[
|
|
|
10
10
|
async function stdin() { let value = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) value += chunk; return value; }
|
|
11
11
|
async function hook(options) {
|
|
12
12
|
const payload = JSON.parse(await stdin() || "{}"), event = options.event;
|
|
13
|
-
const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload });
|
|
13
|
+
const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload }, 10_000);
|
|
14
|
+
if (identity.stale) return event === "PreToolUse" ? { decision: "deny", reason: "agy_stale_hook" } : {};
|
|
14
15
|
if (event === "PreToolUse" || event === "PostToolUse") {
|
|
15
16
|
const phase = event === "PreToolUse" ? "before" : "after", args = payload.toolCall?.args ?? {};
|
|
16
|
-
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, 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 ?? "" } : {}) };
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
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 ?? "" } : {}) };
|
|
18
|
+
await invokeNativeHook({ bin: options["dd-flow-bin"] ?? identity.dd_flow_bin,
|
|
19
|
+
args: ["agy", "event", "handle", "--project-root", options["project-root"] ?? identity.project_root, "--json"],
|
|
20
|
+
payload: normalized, home: options["dd-flow-home"] ?? identity.dd_flow_home });
|
|
19
21
|
}
|
|
20
22
|
return event === "PreToolUse" ? { decision: "allow" } : {};
|
|
21
23
|
}
|
|
@@ -284,7 +284,10 @@ export class Runtime {
|
|
|
284
284
|
if (result.conversation_id && result.conversation_id !== this.init?.conversation_id) {
|
|
285
285
|
await this.journal("foreign_terminal_observed", { conversation_id: result.conversation_id, result });
|
|
286
286
|
const child = this.descendants.get(result.conversation_id);
|
|
287
|
-
if (child)
|
|
287
|
+
if (child) {
|
|
288
|
+
this.descendants.set(result.conversation_id, { ...child, status: result.status === "SUCCESS" ? "completed" : result.status === "ERROR" ? "failed" : "cancelled" });
|
|
289
|
+
await this.completePendingTurn();
|
|
290
|
+
}
|
|
288
291
|
return;
|
|
289
292
|
}
|
|
290
293
|
const current = this.active;
|
|
@@ -312,7 +315,19 @@ export class Runtime {
|
|
|
312
315
|
: "agy_provider_failed";
|
|
313
316
|
return current.reject(new DaemonError(code, message, ["agy_provider_quota_exhausted", "agy_provider_rate_limited"].includes(code), { provider_result: result, provider_error_scope: current.previousError && current.previousError === result.error ? "repeated_conversation_error" : "terminal_result", usage_ingest: this.usageIngest }));
|
|
314
317
|
}
|
|
315
|
-
|
|
318
|
+
await this.completePendingTurn();
|
|
319
|
+
}
|
|
320
|
+
async completePendingTurn() {
|
|
321
|
+
const current = this.active;
|
|
322
|
+
if (!current?.terminalResult || current.terminalResult !== this.lastResult) return;
|
|
323
|
+
await this.persist({ active_tree: !this.receipt().settled });
|
|
324
|
+
// Hooks can invalidate the tree while persistence is pending. Never
|
|
325
|
+
// publish a proof captured before that asynchronous boundary.
|
|
326
|
+
if (this.active !== current) return;
|
|
327
|
+
const receipt = this.receipt();
|
|
328
|
+
if (!receipt.settled) { await this.persist({ active_tree: true }); return; }
|
|
329
|
+
this.active = null;
|
|
330
|
+
current.resolve(receipt);
|
|
316
331
|
}
|
|
317
332
|
receipt(result = this.lastResult) {
|
|
318
333
|
const assistantText = typeof result?.response === "string" ? result.response : typeof result?.text === "string" ? result.text : null;
|
|
@@ -365,10 +380,10 @@ export class Runtime {
|
|
|
365
380
|
if (!child) throw new DaemonError("agy_child_identity_unconfirmed", "Antigravity hook has no confirmed native parent", true, { conversation_id: conversationId });
|
|
366
381
|
return child;
|
|
367
382
|
}
|
|
368
|
-
async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.
|
|
383
|
+
async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); 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;
|
|
369
384
|
const observed = payload.model ? observedProfile(payload) : {};
|
|
370
385
|
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" });
|
|
371
|
-
const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId,
|
|
386
|
+
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 }); 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 };
|
|
372
387
|
}
|
|
373
388
|
async finalizeProviderForControl() {
|
|
374
389
|
const pending = this.providerFinalization;
|
|
@@ -324,11 +324,8 @@ export class DroidRuntime {
|
|
|
324
324
|
catch (error) {
|
|
325
325
|
receipt = { provider_session_id: binding.provider_session_id, adapter_session_id: binding.provider_session_id, settled: false, settlement_observation_error: errorRecord(error) };
|
|
326
326
|
}
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// durable binding for the normal settlement observer instead of exposing
|
|
330
|
-
// a misleading successful receipt.
|
|
331
|
-
if (receipt.settled !== true) return null;
|
|
327
|
+
// Native outcome and tree settlement are independent. Retain the former
|
|
328
|
+
// even while children are running; settled:false keeps capacity reserved.
|
|
332
329
|
const current = this.active;
|
|
333
330
|
if (current?.operationId === operationId && current.turnId === binding.turn_id) {
|
|
334
331
|
current.outcome = outcome; this.lastResult = outcome;
|
|
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
5
5
|
import { chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import net from "node:net";
|
|
8
|
-
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
8
|
+
import { durableDaemonDispatch as dispatchOperation } from "./daemon-operations.mjs";
|
|
9
9
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
@@ -17,6 +17,10 @@ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmD
|
|
|
17
17
|
const REQUEST_SCHEMA = "dd-grok/daemon-request@1";
|
|
18
18
|
const RESPONSE_SCHEMA = "dd-grok/daemon-response@1";
|
|
19
19
|
const STATE_SCHEMA = "dd-grok/daemon-state@1";
|
|
20
|
+
// Normalize raw socket callers too, before the shared layer reserves capacity.
|
|
21
|
+
export function durableDaemonDispatch(root, request, action, observe) {
|
|
22
|
+
return dispatchOperation(root, request.operation === "session.resume" ? { ...request, operation: "session.inspect" } : request, action, observe);
|
|
23
|
+
}
|
|
20
24
|
class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
|
|
21
25
|
function absolute(value, label) { if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`); return path.resolve(value); }
|
|
22
26
|
function locations(stateDir) { const dir = absolute(stateDir, "--state-dir"); const localSocket = path.join(dir, "daemon.sock"); return { dir, socket: Buffer.byteLength(localSocket) < 100 ? localSocket : `/tmp/dd-grok-${createHash("sha256").update(dir).digest("hex").slice(0, 24)}.sock`, state: path.join(dir, "daemon.json"), log: path.join(dir, "daemon.log"), home: path.join(dir, "grok-home") }; }
|
|
@@ -142,6 +146,8 @@ async function materializeSessionArchive(paths, archiveDir) {
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
149
|
+
// Grok resume observes an already-owned Session; it never starts a turn.
|
|
150
|
+
if (operation === "session.resume") operation = "session.inspect";
|
|
145
151
|
const { socket } = locations(stateDir); const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
|
|
146
152
|
return await new Promise((resolve, reject) => {
|
|
147
153
|
const client = net.createConnection(socket); let buffer = ""; let settled = false;
|
|
@@ -233,13 +239,32 @@ export class Runtime {
|
|
|
233
239
|
return running;
|
|
234
240
|
}
|
|
235
241
|
async requireSettled() { const running = await this.refreshTree(); if (running.length) throw new DaemonError("tree_not_settled", "daemon still owns a running Session tree", false, { sessions: running }); }
|
|
236
|
-
async productive(name, task
|
|
242
|
+
async productive(name, task, sessionId) {
|
|
243
|
+
if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true);
|
|
244
|
+
this.active = name;
|
|
245
|
+
// Only a new native turn invalidates its own root proof. Creation, fork
|
|
246
|
+
// and local archive export must not erase an unrelated Session's receipt.
|
|
247
|
+
if (name === "session.prompt" && this.sessions.has(sessionId)) this.sessions.get(sessionId).native_root_receipt = null;
|
|
248
|
+
try {
|
|
249
|
+
await this.persist({ active_tree: true, active_operation: name });
|
|
250
|
+
const result = await task();
|
|
251
|
+
this.track(result);
|
|
252
|
+
const session = this.sessions.get(result.provider_session_id);
|
|
253
|
+
if (session && (["session.create", "session.fork"].includes(name) || ["end_turn", "cancelled"].includes(result.turn?.stopReason))) session.native_root_receipt = { kind: name === "session.prompt" ? result.turn.stopReason : "created", daemon_id: this.state.daemon_id };
|
|
254
|
+
// Archive requires an idle tree before entering this exclusive section
|
|
255
|
+
// and performs no native work. Its file receipt is not tree evidence.
|
|
256
|
+
const running = name === "session.archive" ? false : this.treeRunning(result);
|
|
257
|
+
await this.persist({ active_tree: running, active_operation: null });
|
|
258
|
+
return { ...result, descendants: this.topology(result.provider_session_id), settled: !running };
|
|
259
|
+
} catch (error) { await this.persist({ active_tree: true, active_operation: null }); throw error; }
|
|
260
|
+
finally { this.active = null; }
|
|
261
|
+
}
|
|
237
262
|
async dispatch(operation, params) {
|
|
238
263
|
const assertDispatch = captureDispatchGuard(this);
|
|
239
264
|
if (operation === "daemon.status") { let observationError; if (!this.active) try { await this.refreshTree(); } catch (error) { observationError = errorPayload(error); } return { observation_error: observationError ?? null, daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, config_isolation: this.state.config_isolation, shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status, auth_status: this.state.auth_status, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config: this.state.config }; }
|
|
240
|
-
if (operation === "hook.resolve") { const sessionId = String(params.sessionId ?? ""); if (!sessionId) throw new DaemonError("hook_identity_missing", "hook has no sessionId"); let session = this.sessions.get(sessionId); if (!session) { const roots = [...this.sessions.values()].filter((item) => !item.parent_provider_session_id); if (roots.length !== 1) throw new DaemonError("hook_identity_unknown", "unknown hook Session has no unique root");
|
|
265
|
+
if (operation === "hook.resolve") { const sessionId = String(params.sessionId ?? ""); if (!sessionId) throw new DaemonError("hook_identity_missing", "hook has no sessionId"); let session = this.sessions.get(sessionId); if (!session) { const roots = [...this.sessions.values()].filter((item) => !item.parent_provider_session_id); if (roots.length !== 1) throw new DaemonError("hook_identity_unknown", "unknown hook Session has no unique root"); const observed = await inspectSessionWithBridge(this.bridge, this.options({ sessionId, liveSession: true })); this.track(observed); session = this.sessions.get(sessionId); if (!session || session.root_provider_session_id !== roots[0].provider_session_id) throw new DaemonError("hook_identity_unknown", "hook Session is not a member of the retained native tree"); await this.persist(); } return { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} }; }
|
|
241
266
|
if (operation === "session.create") return await this.productive(operation, async () => { const result = await createSessionWithBridge(this.bridge, { ...this.options(params), onSessionCreated: async (session) => { this.track(session); this.loadedSessionId = session.provider_session_id; await this.persist(); } }, this.initialized); this.loadedSessionId = result.provider_session_id; return result; });
|
|
242
|
-
if (operation === "session.prompt") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await promptSessionWithBridge(this.bridge, { ...this.options(params), assertDispatch }); this.loadedSessionId = params.sessionId; return result; }); }
|
|
267
|
+
if (operation === "session.prompt") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await promptSessionWithBridge(this.bridge, { ...this.options(params), assertDispatch }); this.loadedSessionId = params.sessionId; return result; }, params.sessionId); }
|
|
243
268
|
if (operation === "session.fork") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await forkSessionWithBridge(this.bridge, this.options(params)); this.loadedSessionId = params.sessionId; return result; }); }
|
|
244
269
|
if (operation === "session.inspect" || operation === "session.resume") { const result = await inspectSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); this.track(result); await this.persist(); return { ...result, settled: !this.active && !this.treeRunning(result), descendants: this.topology(result.provider_session_id) }; }
|
|
245
270
|
if (operation === "session.cancel") { if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon"); cancelPendingDispatch(this); const result = await cancelSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); await this.persist({ active_tree: Boolean(this.active) || !result.settled }); return result; }
|
|
@@ -34,7 +34,8 @@ function errorPayload(error) { return { code: error.code ?? "operation_failed",
|
|
|
34
34
|
|
|
35
35
|
async function writePlugin(paths, config) {
|
|
36
36
|
const directory = path.join(paths.config, "opencode", "plugins"); await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
37
|
-
const
|
|
37
|
+
const helper = new URL("./native-hook-command.mjs", import.meta.url).href;
|
|
38
|
+
const source = `import { invokeNativeHook } from ${JSON.stringify(helper)};\nconst candidate = command => typeof command === "string" && command.length > 0;\nconst participating = new Set();\nconst invoke = async payload => await invokeNativeHook({bin:process.env.DD_OPENCODE_DD_FLOW_BIN,args:["opencode","event","handle","--project-root",process.env.DD_OPENCODE_PROJECT_ROOT,"--json"],payload,home:process.env.DD_FLOW_HOME,timeoutMs:15000});\nexport const DdFlowPlugin=async({client,project,directory})=>{ const envelope=async(phase,input,extra={})=>{ const response=await client.session.get({path:{id:input.sessionID}}); if(response?.error||!response?.data) throw Object.assign(new Error("OpenCode Session identity is unavailable"),{code:"opencode_session_identity_unavailable"}); const session=response.data; return {schema_id:"dd-flow/opencode-tool-event@1",event_id:[input.sessionID,input.callID,phase].join(":"),phase,daemon_id:process.env.DD_OPENCODE_DAEMON_ID,server_version:process.env.DD_OPENCODE_SERVER_VERSION,plugin_sha256:process.env.DD_OPENCODE_PLUGIN_SHA256,session:{provider_session_id:input.sessionID,parent_provider_session_id:session.parentID??null,agent_id:process.env.DD_OPENCODE_AGENT,directory:session.directory??directory,project_id:session.projectID??project?.id},message_id:input.messageID??null,tool_call_id:input.callID,tool:input.tool,profile:{provider:process.env.DD_OPENCODE_PROVIDER,model:process.env.DD_OPENCODE_MODEL,variant:process.env.DD_OPENCODE_VARIANT,agent:process.env.DD_OPENCODE_AGENT},...extra}; }; return {"tool.execute.before":async(input,output)=>{ const command=output.args?.command; if(!candidate(command)) return; const result=await invoke(await envelope("before",input,{input:output.args})); if(result?.observed) participating.add(input.callID); const updated=result?.hookSpecificOutput?.updatedInput; if(updated) Object.assign(output.args,updated); },"tool.execute.after":async(input,output)=>{ if(!participating.delete(input.callID)) return; await invoke(await envelope("after",input,{input:input.args,outcome:{status:"completed",title:output.title}})); }}; };\n`;
|
|
38
39
|
const checksum = createHash("sha256").update(source).digest("hex"); await writeFile(path.join(directory, "dd-flow.js"), source, { mode: 0o600 }); return checksum;
|
|
39
40
|
}
|
|
40
41
|
async function prepare(paths, config, authSource) {
|
|
@@ -84,7 +85,23 @@ export class Runtime {
|
|
|
84
85
|
}
|
|
85
86
|
async requireSettled(id) { const value = await this.describe(id); if (!value.settled) throw new DaemonError("tree_not_settled", "OpenCode Session is not idle", false, { session_id: id, status: value.status }); return value; }
|
|
86
87
|
async reconcileUsage(observed) { const errors = []; try { await forwardUsage(this.state.config, observed.provider_session_id, observed.usage); } catch (error) { errors.push({ session_id: observed.provider_session_id, phase: "usage", ...errorPayload(error) }); } for (const child of observed.children.filter(item => (item.parentID ?? observed.provider_session_id) === observed.provider_session_id)) { try { errors.push(...await this.reconcileUsage(await this.describe(child.id))); } catch (error) { errors.push({ session_id: child.id, phase: "usage_child", ...errorPayload(error) }); } } return errors; }
|
|
87
|
-
async productive(name, task) {
|
|
88
|
+
async productive(name, task) {
|
|
89
|
+
if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true);
|
|
90
|
+
this.active = name; let result, completed = false;
|
|
91
|
+
try {
|
|
92
|
+
await this.persist({ active_tree: true, active_operation: name });
|
|
93
|
+
result = await task(); completed = true;
|
|
94
|
+
await this.persist({ active_tree: !result?.settled, active_operation: null });
|
|
95
|
+
await this.journal(name, result); return result;
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
const error = completed ? new DaemonError("operation_observation_lost", "OpenCode returned but its durable observation could not be saved; do not resend", false, { result, cause: errorPayload(cause) }) : cause;
|
|
98
|
+
const failures = [];
|
|
99
|
+
try { await this.persist({ active_tree: true, active_operation: null }); } catch (secondary) { failures.push(errorPayload(secondary)); }
|
|
100
|
+
try { await this.journal(`${name}.failed`, null); } catch (secondary) { failures.push(errorPayload(secondary)); }
|
|
101
|
+
if (failures.length) error.details = { ...error.details, observation_errors: failures };
|
|
102
|
+
throw error;
|
|
103
|
+
} finally { this.active = null; }
|
|
104
|
+
}
|
|
88
105
|
async cancelTree(id, seen = new Set()) {
|
|
89
106
|
if (seen.has(id)) return [];
|
|
90
107
|
seen.add(id);
|
|
@@ -170,6 +187,8 @@ export class Runtime {
|
|
|
170
187
|
},
|
|
171
188
|
...(params.cancelTree ? { cancel: id => this.cancelTree(id) } : {})
|
|
172
189
|
});
|
|
190
|
+
// The durable clean receipt must follow the owned physical stop barrier.
|
|
191
|
+
await stopProcessGroup(this.child);
|
|
173
192
|
await this.persist({ active_tree: false, shutdown_state: "stopping" });
|
|
174
193
|
return { stopped: true, clean: true, _shutdown: true };
|
|
175
194
|
}
|
|
@@ -21,7 +21,7 @@ export class OpenCodeClient {
|
|
|
21
21
|
} catch (error) {
|
|
22
22
|
if (error instanceof OpenCodeError) throw error;
|
|
23
23
|
// Aborting HTTP only loses the observer; it does not cancel the provider.
|
|
24
|
-
throw new OpenCodeError(error.name === "AbortError" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname });
|
|
24
|
+
throw new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname });
|
|
25
25
|
} finally { clearTimeout(timer); }
|
|
26
26
|
}
|
|
27
27
|
health() { return this.request("GET", "/global/health", undefined, { directory: null, timeoutMs: 5_000 }); }
|
|
@@ -752,15 +752,19 @@ async function forwardUsageBestEffort(options, providerSessionId, usage, toolCal
|
|
|
752
752
|
}
|
|
753
753
|
|
|
754
754
|
export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContract) {
|
|
755
|
-
// Behavioural evidence: dd-eval production-invocations
|
|
755
|
+
// Behavioural evidence: dd-eval production-invocations probes on 2026-09-13
|
|
756
|
+
// and 2026-09-22. The latter exercised public commands, file-backed JSON,
|
|
757
|
+
// concurrent children and continuation on bridge contract @2.
|
|
756
758
|
// Pin both providers of native event identity, not a string in the bundle.
|
|
757
759
|
// 60af0d3 adds retainedSubagents without changing native event forwarding.
|
|
758
760
|
const qualifiedTuples = new Map([
|
|
759
761
|
["e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8", new Set(["e0600fe3c46e215695f257e65aa6f674ced998d8", "60af0d31e13076a313d9770f10aa70f7c94742cf"])],
|
|
760
|
-
["500ae84fa2cb8dd74c1264e5f3d3709c004c59b58899f1b0c3e205137a0ef466", new Set(["8c0a893f3c26a0b96cb138acf762db84c800298b"])],
|
|
762
|
+
["500ae84fa2cb8dd74c1264e5f3d3709c004c59b58899f1b0c3e205137a0ef466", new Set(["8c0a893f3c26a0b96cb138acf762db84c800298b", "f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833"])],
|
|
761
763
|
]);
|
|
762
764
|
const qualified = qualifiedTuples.get(sha256)?.has(bridgeCommit) === true
|
|
763
|
-
&&
|
|
765
|
+
&& (bridgeCommit === "f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833"
|
|
766
|
+
? harnessContract === "dd-zcode-harness@2"
|
|
767
|
+
: harnessContract === ZCODE_HARNESS_CONTRACT);
|
|
764
768
|
return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
|
|
765
769
|
capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
|
|
766
770
|
native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
|
|
@@ -851,18 +855,36 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
851
855
|
inactivityMode: "diagnostic",
|
|
852
856
|
});
|
|
853
857
|
const stopReason = typeof turn?.stopReason === "string" ? turn.stopReason : null;
|
|
858
|
+
const completionEvidence = turn?._meta?.zcodeCompletionEvidence;
|
|
859
|
+
if (completionEvidence === "inferred" || completionEvidence === "failed") {
|
|
860
|
+
let observationError = null;
|
|
861
|
+
try { await bridge.flush(); } catch (error) { observationError = { code: error.code ?? "zcode_post_turn_observation_failed", message: error.message ?? String(error) }; }
|
|
862
|
+
const error = new Error(`ZCode completion is ${completionEvidence}, not a confirmed successful native turn`);
|
|
863
|
+
error.code = completionEvidence === "inferred" ? "native_outcome_unknown" : "zcode_turn_not_completed";
|
|
864
|
+
error.retryable = false;
|
|
865
|
+
error.details = { completion_evidence: completionEvidence, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...(observationError ? { observation_error: observationError } : {}) };
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
854
868
|
if (stopReason && stopReason !== "end_turn") {
|
|
855
869
|
// A close can settle the pending prompt before its terminal event. Do not
|
|
856
870
|
// collect live evidence first: ordinary inspection is allowed to recover
|
|
857
871
|
// an evicted Session, which would undo the just-confirmed close.
|
|
858
|
-
|
|
872
|
+
let observationError = null;
|
|
873
|
+
try { await bridge.flush(); } catch (error) { observationError = { code: error.code ?? "zcode_post_turn_observation_failed", message: error.message ?? String(error) }; }
|
|
859
874
|
const error = new Error(`ZCode ended the provider request with ${stopReason}, not end_turn`);
|
|
860
875
|
error.code = stopReason === "cancelled" ? "zcode_turn_cancelled" : "zcode_turn_not_completed";
|
|
861
|
-
error.details = { stop_reason: stopReason, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId };
|
|
876
|
+
error.details = { stop_reason: stopReason, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...(observationError ? { observation_error: observationError } : {}) };
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
let evidence;
|
|
880
|
+
try { await bridge.flush(); evidence = await inspect(bridge, identity.adapterSessionId); }
|
|
881
|
+
catch (cause) {
|
|
882
|
+
const error = new Error("ZCode completed the native turn, but post-turn observation failed");
|
|
883
|
+
error.code = "native_outcome_observation_failed";
|
|
884
|
+
error.retryable = false;
|
|
885
|
+
error.details = { native_outcome: { stop_reason: stopReason ?? "end_turn", completion_evidence: completionEvidence ?? "native" }, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, observation_error: { code: cause.code ?? "zcode_post_turn_observation_failed", message: cause.message ?? String(cause) } };
|
|
862
886
|
throw error;
|
|
863
887
|
}
|
|
864
|
-
await bridge.flush();
|
|
865
|
-
const evidence = await inspect(bridge, identity.adapterSessionId);
|
|
866
888
|
evidence.tool_calls = bridge.toolSummary(identity.adapterSessionId);
|
|
867
889
|
const usage_ingest_error = await forwardUsageBestEffort(options, identity.providerSessionId, evidence.usage, evidence.tool_calls);
|
|
868
890
|
if (initial_usage_error) evidence.initial_usage_error = initial_usage_error;
|
|
@@ -64,6 +64,13 @@ export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurre
|
|
|
64
64
|
assertCurrent();
|
|
65
65
|
if (result?.ok === true && result.admitted === true && result.process_id === record.id) return;
|
|
66
66
|
if (result?.ok !== true || result.reason !== "provider_capacity" || result.process_id !== record.id) throw Object.assign(new Error("productive runtime admission was not confirmed"), { code: "process_ownership_unknown" });
|
|
67
|
+
const predecessor = result.blocking_turns?.find(turn => turn?.process_id === record.id
|
|
68
|
+
&& turn?.session_id === dispatch["native-session-id"]
|
|
69
|
+
&& turn?.operation_id !== dispatch["operation-id"]);
|
|
70
|
+
if (predecessor) throw Object.assign(new Error("the same managed Session has an unsettled productive predecessor; reconcile it before dispatching another Turn"), {
|
|
71
|
+
code: "settlement_reconciliation_required",
|
|
72
|
+
details: { session_id: dispatch["native-session-id"], predecessor_operation_id: predecessor.operation_id, predecessor_operation: predecessor.operation }
|
|
73
|
+
});
|
|
67
74
|
// The daemon, not its disposable socket client, owns this queue. Recheck
|
|
68
75
|
// the RUN fence and process lease on every admission attempt.
|
|
69
76
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
@@ -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(value.operation, value);
|
|
15
|
+
latest.set(JSON.stringify([value.operation, value.session_id ?? null, 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))
|
package/dist/services/hooks.js
CHANGED
|
@@ -619,7 +619,8 @@ export function handleGrokEvent(context, input) {
|
|
|
619
619
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
620
620
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
621
621
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
622
|
-
harness: "grok-acp", providerSessionId,
|
|
622
|
+
harness: "grok-acp", providerSessionId, toolUseId: hook.tool_use_id ?? hook.toolUseId ?? null,
|
|
623
|
+
promptId: hook.prompt_id ?? hook.promptId ?? null, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
623
624
|
})).digest("hex");
|
|
624
625
|
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
625
626
|
const payload = {
|
|
@@ -740,6 +741,12 @@ export function handleAgyEvent(context, input) {
|
|
|
740
741
|
const toolName = stringValue(event.tool);
|
|
741
742
|
if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
|
|
742
743
|
throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
|
|
744
|
+
const rawInput = objectRecord(event.input);
|
|
745
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
746
|
+
if (event.phase === "before" && command && (lifecycleFacts(command) || continuationCommand(command))
|
|
747
|
+
&& (!Number.isInteger(event.execution_num) || !Number.isInteger(event.step_index))) {
|
|
748
|
+
throw new AppError("agy_native_call_identity_missing", "Participating Antigravity lifecycle calls require native execution and step identity", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
749
|
+
}
|
|
743
750
|
const translated = {
|
|
744
751
|
schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
|
|
745
752
|
daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
|
|
@@ -874,7 +874,11 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
|
874
874
|
return null;
|
|
875
875
|
const parsed = parseLifecycleCommand(command);
|
|
876
876
|
const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
|
|
877
|
-
const matches = context.db.all(
|
|
877
|
+
const matches = context.db.all(daemonId
|
|
878
|
+
? "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL"
|
|
879
|
+
: eventKey
|
|
880
|
+
? "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE event_key = ? AND status = 'observed' AND outcome_json IS NULL"
|
|
881
|
+
: "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE harness IN ('antigravity-cli','codex-desktop') AND status = 'observed' AND outcome_json IS NULL", daemonId || eventKey ? [daemonId ?? eventKey] : []).filter(row => {
|
|
878
882
|
if (eventKey && row.event_key !== eventKey)
|
|
879
883
|
return false;
|
|
880
884
|
try {
|
|
@@ -527,30 +527,40 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
527
527
|
if (failure)
|
|
528
528
|
throw failure.reason;
|
|
529
529
|
};
|
|
530
|
+
const reconcilePromptPredecessor = async (session) => {
|
|
531
|
+
const prior = state.last_receipt?.settlement;
|
|
532
|
+
if (!prior || typeof prior !== "object" || Array.isArray(prior) || !["pending", "blocked"].includes(String(prior.state)))
|
|
533
|
+
return;
|
|
534
|
+
const receipt = await call(session, ["session", "inspect", "--session-id", session.id, ...sessionArguments(context, manifest, session)], false);
|
|
535
|
+
state.last_receipt = receipt;
|
|
536
|
+
saveState(context, row, state);
|
|
537
|
+
const current = receipt.settlement;
|
|
538
|
+
if (current && typeof current === "object" && !Array.isArray(current) && ["pending", "blocked"].includes(String(current.state))) {
|
|
539
|
+
throw new AppError("settlement_reconciliation_required", "The managed Session still has an unsettled productive predecessor", 1, { session_id: session.id, settlement: current });
|
|
540
|
+
}
|
|
541
|
+
};
|
|
530
542
|
const prompt = async (session, text, operationId) => {
|
|
543
|
+
await reconcilePromptPredecessor(session);
|
|
531
544
|
const file = path.join(row.state_dir, `turn-${String(state.turns + 1).padStart(5, "0")}.md`);
|
|
532
545
|
fs.writeFileSync(file, `${text}\n`, { flag: "wx", mode: 0o600 });
|
|
533
546
|
state.turns += 1;
|
|
534
547
|
saveState(context, row, state);
|
|
535
548
|
let receipt;
|
|
549
|
+
const adapter = controllerAdapter(executionContext, row, manifest, session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], assertOwner, operationId, cancellation.signal);
|
|
536
550
|
try {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
await adapter.catch(() => { });
|
|
550
|
-
throw fatal ?? error;
|
|
551
|
-
}
|
|
551
|
+
receipt = await adapter;
|
|
552
|
+
checkOutcomes();
|
|
553
|
+
if (fatal)
|
|
554
|
+
throw fatal;
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
if (!fatal && error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale", "controller_owner_mismatch"].includes(error.code))
|
|
558
|
+
throw error;
|
|
559
|
+
await stopAfterFailure(error);
|
|
560
|
+
cancellation.abort();
|
|
561
|
+
await adapter.catch(() => { });
|
|
562
|
+
throw fatal ?? error;
|
|
552
563
|
}
|
|
553
|
-
finally { /* Adapter settlement is joined before returning. */ }
|
|
554
564
|
state.last_receipt = receipt;
|
|
555
565
|
saveState(context, row, state);
|
|
556
566
|
return receipt;
|
|
@@ -328,10 +328,15 @@ export function reserveProviderTurn(context, record, input) {
|
|
|
328
328
|
db.exec("COMMIT");
|
|
329
329
|
return { admitted: true, budget_scope: budget.scope_id, reused: true };
|
|
330
330
|
}
|
|
331
|
-
const
|
|
331
|
+
const activeTurns = db.all("SELECT process_id, metadata_json FROM managed_resources WHERE resource_kind = 'provider-turn' AND json_extract(metadata_json, '$.scope_id') = ? AND json_extract(metadata_json, '$.harness') = ? ORDER BY created_at, resource_key", [budget.scope_id, harness]);
|
|
332
|
+
const active = activeTurns.length;
|
|
332
333
|
if (active >= limit) {
|
|
334
|
+
const blocking_turns = activeTurns.map(turn => {
|
|
335
|
+
const claim = JSON.parse(turn.metadata_json);
|
|
336
|
+
return { process_id: turn.process_id, operation_id: claim.operation_id ?? null, operation: claim.operation ?? null, session_id: claim.session_id ?? null };
|
|
337
|
+
});
|
|
333
338
|
db.exec("COMMIT");
|
|
334
|
-
return { admitted: false, reason: "provider_capacity", budget_scope: budget.scope_id, harness, limit, active };
|
|
339
|
+
return { admitted: false, reason: "provider_capacity", budget_scope: budget.scope_id, harness, limit, active, blocking_turns };
|
|
335
340
|
}
|
|
336
341
|
db.run("INSERT INTO managed_resources (resource_kind, resource_key, owner_id, lease_token, lease_expires_at, process_id, metadata_json, created_at, updated_at) VALUES ('provider-turn', ?, ?, ?, ?, ?, ?, ?, ?)", [key, record.owner_id, record.lease_token, record.lease_expires_at, record.id, JSON.stringify({ scope_id: budget.scope_id, harness, operation_id: input.operationId, operation: input.operation, session_id: input.sessionId ?? null, state_dir: metadata.state_dir }), now, now]);
|
|
337
342
|
db.exec("COMMIT");
|
|
@@ -527,8 +527,9 @@ function coordinatorPrompt(context, input) {
|
|
|
527
527
|
"",
|
|
528
528
|
"<verification_contract>",
|
|
529
529
|
"```json",
|
|
530
|
-
JSON.stringify({ schema_id: "dd-flow/code-verification@2", verdict: "passed
|
|
530
|
+
JSON.stringify({ schema_id: "dd-flow/code-verification@2", verdict: "passed", summary: "Evidence-backed conclusion.", unresolved: [], deviations: [] }, null, 2),
|
|
531
531
|
"```",
|
|
532
|
+
"Allowed verdict values are passed, needs_repair, and blocked. The example is valid JSON using passed; choose exactly one value.",
|
|
532
533
|
"The CLI verifies graph coverage and executes all deterministic checks. Do not duplicate its report; record only your semantic conclusion and evidence.",
|
|
533
534
|
"</verification_contract>",
|
|
534
535
|
"",
|
|
@@ -481,7 +481,7 @@ function resultTemplate(obligations = []) {
|
|
|
481
481
|
}
|
|
482
482
|
function renderPrompt(input) {
|
|
483
483
|
const obligationList = input.obligations.map((obligation) => `- ${obligation.id}: ${obligation.statement}`).join("\n");
|
|
484
|
-
return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(), null, 2)}\n\`\`\``, "
|
|
484
|
+
return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(input.obligations), null, 2)}\n\`\`\``, "obligation_id values and their one-per-obligation entries are prefilled by the CLI. Preserve those IDs exactly; change only member_keys when semantic ownership requires it. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, "</output_contract>", ""].join("\n");
|
|
485
485
|
}
|
|
486
486
|
function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
|
|
487
487
|
const receiptPath = path.join(stageRoot, "workspace-route.json");
|