@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -2
- package/dist/harness-runtime/lib/dd-zcode.mjs +3 -1
- package/dist/services/code-checks.js +40 -0
- package/dist/services/managed-processes.js +42 -6
- package/dist/services/run-control.js +7 -1
- package/dist/services/runtime-scope-stop.js +23 -2
- package/dist/services/vnext-code.js +7 -1
- package/dist/services/vnext-plan.js +29 -14
- package/package.json +1 -1
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.121",
|
|
4
|
+
"cli_commit": "2e7bef9a7813bd003855d94b394e63d8aa5f6419",
|
|
5
|
+
"built_at": "2026-09-29T16:13:36.621Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
|
|
@@ -315,7 +315,7 @@ export class Runtime {
|
|
|
315
315
|
// creation/end_turn receipt can then establish an idle root. Dispatch
|
|
316
316
|
// clears that receipt before sending any subsequent productive request.
|
|
317
317
|
const receipt = this.sessions.get(result?.provider_session_id)?.native_root_receipt;
|
|
318
|
-
if (status === "unknown" && ["created", "end_turn", "cancelled"].includes(receipt?.kind)) return false;
|
|
318
|
+
if (status === "unknown" && ["created", "end_turn", "cancelled", "failed"].includes(receipt?.kind)) return false;
|
|
319
319
|
return !["completed", "failed", "cancelled", "stopped", "idle"].includes(status);
|
|
320
320
|
}
|
|
321
321
|
async refreshTree(cancel = false) {
|
|
@@ -356,7 +356,17 @@ export class Runtime {
|
|
|
356
356
|
const running = name === "session.archive" ? false : this.treeRunning(result);
|
|
357
357
|
await this.persist({ active_tree: running, active_operation: null });
|
|
358
358
|
return { ...result, descendants: this.topology(result.provider_session_id), settled: !running };
|
|
359
|
-
} catch (error) {
|
|
359
|
+
} catch (error) {
|
|
360
|
+
// A matched JSON-RPC error completes this native prompt request. It is
|
|
361
|
+
// root-turn proof, not proof that any descendants have stopped. A lost
|
|
362
|
+
// response, bridge error, or failure before dispatch earns no receipt.
|
|
363
|
+
const response = error?.details?.terminal_response;
|
|
364
|
+
if (name === "session.prompt" && response?.method === "session/prompt" && response.session_id === sessionId) {
|
|
365
|
+
const session = this.sessions.get(sessionId);
|
|
366
|
+
if (session) session.native_root_receipt = { kind: "failed", daemon_id: this.state.daemon_id, request_id: response.request_id };
|
|
367
|
+
}
|
|
368
|
+
await this.persist({ active_tree: true, active_operation: null }); throw error;
|
|
369
|
+
}
|
|
360
370
|
finally { this.active = null; }
|
|
361
371
|
}
|
|
362
372
|
async dispatch(operation, params) {
|
|
@@ -270,7 +270,9 @@ export class AcpBridge {
|
|
|
270
270
|
const unknown = ["native_timeout", "native_backend_dead", "native_backend_pipe_broken", "native_outcome_unknown"].includes(nativeCode);
|
|
271
271
|
error.code = unknown ? nativeCode : /payment required|quota|usage balance exhausted|insufficient.?credit|billing/i.test(messageText) ? "provider_quota_exhausted" : /rate.?limit|too many requests/i.test(messageText) ? "provider_rate_limited" : /\b429\b/.test(messageText) ? "provider_limit_unknown" : "acp_request_failed";
|
|
272
272
|
error.retryable = error.code === "provider_rate_limited";
|
|
273
|
-
error.details = { acp_code: message.error.code ?? null,
|
|
273
|
+
error.details = { acp_code: message.error.code ?? null,
|
|
274
|
+
terminal_response: { method: pending.method, session_id: pending.sessionId, request_id: message.id },
|
|
275
|
+
...(data ? { native: data } : {}), ...(details ? { provider_details: details } : {}), ...(providerDetails ? { provider_update: provider } : {}) };
|
|
274
276
|
pending.reject(error);
|
|
275
277
|
}
|
|
276
278
|
else { pending.resolve(message.result ?? {}); }
|
|
@@ -9,11 +9,51 @@ import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
|
9
9
|
import { AppError } from "../shared/errors.js";
|
|
10
10
|
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
11
11
|
import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
|
|
12
|
+
import { getResourceDatabase } from "../storage/database.js";
|
|
12
13
|
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
13
14
|
// Receipt reservation precedes child spawn so an interrupted CLI leaves an
|
|
14
15
|
// auditable attempt. A second caller must not mistake that small handoff window
|
|
15
16
|
// for a dead check and overwrite its outcome.
|
|
16
17
|
const receiptStartingGraceMs = 30_000;
|
|
18
|
+
/** The check writer proves an empty tree before marking its registration terminal. */
|
|
19
|
+
export function terminalCheckSettled(context, record) {
|
|
20
|
+
if (record.kind !== "check" || !record.check_id || record.owner_id !== `check:${record.check_id}` || !record.project_id || !record.run_id
|
|
21
|
+
|| !record.pid || !record.pid_started_at || !["stopped", "failed"].includes(record.state) || !record.finished_at)
|
|
22
|
+
return null;
|
|
23
|
+
let metadata;
|
|
24
|
+
try {
|
|
25
|
+
metadata = JSON.parse(record.metadata_json);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (metadata.role !== "check" || managedProcessRuntimeHome(record, context) !== context.ddFlowHome)
|
|
31
|
+
return null;
|
|
32
|
+
let resource;
|
|
33
|
+
try {
|
|
34
|
+
resource = getResourceDatabase(context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, "read_existing");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const current = resource.get("SELECT * FROM managed_processes WHERE id = ?", [record.id]);
|
|
41
|
+
if (!current || current.lease_token !== record.lease_token || current.owner_id !== record.owner_id || current.pid !== record.pid
|
|
42
|
+
|| current.pid_started_at !== record.pid_started_at || current.metadata_json !== record.metadata_json || current.state !== record.state
|
|
43
|
+
|| current.project_id !== record.project_id || current.run_id !== record.run_id || current.work_id !== record.work_id
|
|
44
|
+
|| current.check_id !== record.check_id || current.finished_at !== record.finished_at)
|
|
45
|
+
return null;
|
|
46
|
+
const receipt = context.db.get("SELECT status, finished_at, work_id FROM check_receipts WHERE id = ? AND project_id = ? AND run_id = ?", [record.check_id, record.project_id, record.run_id]);
|
|
47
|
+
return receipt && receipt.work_id === record.work_id && ["passed", "failed", "aborted"].includes(receipt.status) && receipt.finished_at
|
|
48
|
+
? { status: receipt.status, finished_at: receipt.finished_at } : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
resource.close?.();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
17
57
|
export function codeExecutionEnvironment(workspaceRoot) {
|
|
18
58
|
const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
|
|
19
59
|
// The managed CLI's Node must win over a provider app's PATH: a Rosetta
|
|
@@ -61,7 +61,12 @@ export function prepareManagedProcessRegistration(context, input) {
|
|
|
61
61
|
throw new AppError("validation", "Unique managed operation requires operationId", 2);
|
|
62
62
|
let metadataJson;
|
|
63
63
|
try {
|
|
64
|
-
|
|
64
|
+
const metadata = { ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) };
|
|
65
|
+
delete metadata.process_birth;
|
|
66
|
+
delete metadata.owner_birth;
|
|
67
|
+
if (input.ownerPid)
|
|
68
|
+
metadata.owner_birth = stableProcessBirth(input.ownerPid);
|
|
69
|
+
metadataJson = JSON.stringify(metadata);
|
|
65
70
|
}
|
|
66
71
|
catch {
|
|
67
72
|
throw new AppError("validation", "metadata must be JSON-serializable", 2);
|
|
@@ -86,7 +91,8 @@ export function registerManagedProcess(context, rawInput) {
|
|
|
86
91
|
if (!input.uniqueActiveOperation)
|
|
87
92
|
return insertManagedProcess(context, input, metadataJson);
|
|
88
93
|
const db = registry(context);
|
|
89
|
-
|
|
94
|
+
const observerProcessBirth = observer ? stableProcessBirth(input.pid) : null;
|
|
95
|
+
if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth, metadata_json: JSON.stringify({ process_birth: observerProcessBirth }) }))
|
|
90
96
|
throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
|
|
91
97
|
return db.writeTransaction(() => {
|
|
92
98
|
const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
|
|
@@ -97,6 +103,7 @@ export function registerManagedProcess(context, rawInput) {
|
|
|
97
103
|
const metadata = JSON.parse(metadataJson);
|
|
98
104
|
if (input.processGroupId)
|
|
99
105
|
metadata.process_group_id = input.processGroupId;
|
|
106
|
+
metadata.process_birth = observerProcessBirth;
|
|
100
107
|
const now = context.now();
|
|
101
108
|
db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
|
|
102
109
|
result = requireProcess(db, result.id);
|
|
@@ -137,9 +144,11 @@ export function confirmManagedProcess(context, input) {
|
|
|
137
144
|
const metadata = parseMetadata(current.metadata_json);
|
|
138
145
|
if (input.processGroupId)
|
|
139
146
|
metadata.process_group_id = input.processGroupId;
|
|
147
|
+
metadata.process_birth = stableProcessBirth(input.pid);
|
|
140
148
|
if (input.ownerPid) {
|
|
141
149
|
metadata.owner_pid = input.ownerPid;
|
|
142
150
|
metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
|
|
151
|
+
metadata.owner_birth = stableProcessBirth(input.ownerPid);
|
|
143
152
|
}
|
|
144
153
|
const result = db.run(`UPDATE managed_processes
|
|
145
154
|
SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
|
|
@@ -216,6 +225,11 @@ export function processIsAlive(record) {
|
|
|
216
225
|
catch (error) {
|
|
217
226
|
return error.code !== "ESRCH";
|
|
218
227
|
}
|
|
228
|
+
const metadata = record.metadata_json ? parseMetadata(record.metadata_json) : {};
|
|
229
|
+
if (Object.hasOwn(metadata, "process_birth")) {
|
|
230
|
+
const birth = parseBirth(metadata.process_birth), observed = stableProcessBirth(record.pid);
|
|
231
|
+
return !birth || !observed || observed === birth;
|
|
232
|
+
}
|
|
219
233
|
const started = processStartedAt(record.pid);
|
|
220
234
|
return !record.pid_started_at || !started || record.pid_started_at === started;
|
|
221
235
|
}
|
|
@@ -223,6 +237,17 @@ export function processTreeIsAlive(record) {
|
|
|
223
237
|
if (processIsAlive(record))
|
|
224
238
|
return true;
|
|
225
239
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
240
|
+
const birth = parseBirth(parseMetadata(record.metadata_json).process_birth);
|
|
241
|
+
// POSIX cannot reuse a leader PID while its old process group survives.
|
|
242
|
+
if (process.platform !== "win32" && group === record.pid && record.pid && birth) {
|
|
243
|
+
try {
|
|
244
|
+
process.kill(record.pid, 0);
|
|
245
|
+
}
|
|
246
|
+
catch { /* A missing leader can still have living children. */ }
|
|
247
|
+
const observed = stableProcessBirth(record.pid);
|
|
248
|
+
if (observed && observed !== birth)
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
226
251
|
if (process.platform !== "win32" && typeof group === "number") {
|
|
227
252
|
try {
|
|
228
253
|
process.kill(-group, 0);
|
|
@@ -238,7 +263,7 @@ export function managedOwnerIsAlive(record) {
|
|
|
238
263
|
const metadata = parseMetadata(record.metadata_json);
|
|
239
264
|
if (typeof metadata.owner_pid !== "number")
|
|
240
265
|
return null;
|
|
241
|
-
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
|
|
266
|
+
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null, metadata_json: JSON.stringify({ process_birth: metadata.owner_birth }) });
|
|
242
267
|
}
|
|
243
268
|
/**
|
|
244
269
|
* Expiry proves that observation stopped, not that the owner died. A live PID
|
|
@@ -413,16 +438,27 @@ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
|
|
|
413
438
|
return new Date(Date.parse(now) + leaseMs).toISOString();
|
|
414
439
|
}
|
|
415
440
|
export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
|
|
441
|
+
function stableProcessBirth(pid) {
|
|
442
|
+
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" } });
|
|
443
|
+
const value = result.status === 0 ? result.stdout.trim() : "";
|
|
444
|
+
return value ? `ps-lstart-utc-c@1:${value}` : null;
|
|
445
|
+
}
|
|
446
|
+
function parseBirth(value) { return typeof value === "string" && value.startsWith("ps-lstart-utc-c@1:") && value.length > "ps-lstart-utc-c@1:".length ? value : null; }
|
|
416
447
|
function releasePortClaims(db, claims) { for (const claim of claims)
|
|
417
448
|
db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
|
|
418
449
|
function terminateOwnedProcess(record, signal) {
|
|
419
450
|
// Never signal a group merely because its former leader PID was once ours.
|
|
420
451
|
// PID reuse or a departed leader makes group ownership unprovable.
|
|
421
|
-
if (!record.pid || !record.pid_started_at
|
|
452
|
+
if (!record.pid || !record.pid_started_at)
|
|
422
453
|
return;
|
|
423
|
-
const
|
|
454
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
455
|
+
const birth = parseBirth(metadata.process_birth);
|
|
456
|
+
if (Object.hasOwn(metadata, "process_birth") ? !birth || stableProcessBirth(record.pid) !== birth : processStartedAt(record.pid) !== record.pid_started_at)
|
|
457
|
+
return;
|
|
458
|
+
const group = metadata.process_group_id;
|
|
459
|
+
// A non-leader PID does not establish ownership of its whole process group.
|
|
424
460
|
try {
|
|
425
|
-
process.kill(process.platform === "win32" ||
|
|
461
|
+
process.kill(process.platform === "win32" || group !== record.pid ? record.pid : -group, signal);
|
|
426
462
|
}
|
|
427
463
|
catch { /* inspect again below */ }
|
|
428
464
|
}
|
|
@@ -11,7 +11,7 @@ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
|
11
11
|
import { runtimeHarness } from "./execution-policy.js";
|
|
12
12
|
import { harnessKey } from "./harness-config.js";
|
|
13
13
|
import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
14
|
-
import { reconcileUnfinishedChecks } from "./code-checks.js";
|
|
14
|
+
import { reconcileUnfinishedChecks, terminalCheckSettled } from "./code-checks.js";
|
|
15
15
|
import { controlStatusReceipt } from "./run-control-receipt.js";
|
|
16
16
|
import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
|
|
17
17
|
/** Durable projection only: status never probes a provider or acquires an owner. */
|
|
@@ -545,6 +545,12 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
545
545
|
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_lease_unsettled" };
|
|
546
546
|
if (!record.pid || !record.pid_started_at)
|
|
547
547
|
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_identity_unknown" };
|
|
548
|
+
// Code checks mark their process terminal only after proving its tree is
|
|
549
|
+
// empty, then publish the exact check receipt. A later PGID reuse cannot
|
|
550
|
+
// turn that already-settled check back into an owned live process.
|
|
551
|
+
const check = record.kind === "check" && record.project_id === project.id && record.run_id === input.runId ? terminalCheckSettled(context, record) : null;
|
|
552
|
+
if (check)
|
|
553
|
+
return { process_id: record.id, kind: record.kind, status: "completed", receipt: { source: "terminal_check_tree_settled", ...managedProcessBinding(record, home), check_id: record.check_id, finished_at: check.finished_at } };
|
|
548
554
|
if (!interrupt && processTreeIsAlive(record))
|
|
549
555
|
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_running" };
|
|
550
556
|
if (boundary)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { createContext } from "../runtime/context.js";
|
|
4
5
|
import { AppError } from "../shared/errors.js";
|
|
5
6
|
import { runHarnessAdapter } from "./harness-adapter.js";
|
|
6
|
-
import { managedLeaseReleased, managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
7
|
+
import { managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
8
|
+
import { terminalCheckSettled } from "./code-checks.js";
|
|
7
9
|
import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
|
|
8
10
|
import { retainedDaemonOperations } from "./run-control.js";
|
|
9
11
|
import { historicalDaemonSettlement } from "./native-daemon-history.js";
|
|
@@ -80,10 +82,29 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
|
|
|
80
82
|
const metadata = JSON.parse(record.metadata_json);
|
|
81
83
|
// RUN control owns active controllers/checks. Adopt only its already-finished
|
|
82
84
|
// physical registrations here; death alone is not a completed RUN operation.
|
|
85
|
+
const terminalCheck = record.kind === "check" && metadata.role === "check" && record.check_id && record.project_id && record.run_id
|
|
86
|
+
? (() => {
|
|
87
|
+
const home = managedProcessRuntimeHome(record, context);
|
|
88
|
+
if (!home)
|
|
89
|
+
return null;
|
|
90
|
+
let reader;
|
|
91
|
+
try {
|
|
92
|
+
reader = createContext({ ...context.env, DD_FLOW_HOME: home }, "read_existing");
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return terminalCheckSettled(reader, record);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
reader.db.close?.();
|
|
102
|
+
}
|
|
103
|
+
})() : null;
|
|
83
104
|
if ((record.kind === "run-controller" && metadata.role === "controller" && record.operation_id
|
|
84
105
|
|| record.kind === "check" && metadata.role === "check" && record.check_id)
|
|
85
106
|
&& record.project_id && record.run_id && ["stopped", "failed"].includes(record.state)
|
|
86
|
-
&& record.pid && record.pid_started_at && !processTreeIsAlive(record)) {
|
|
107
|
+
&& record.pid && record.pid_started_at && (terminalCheck || !processTreeIsAlive(record))) {
|
|
87
108
|
const operationId = record.operation_id ?? record.check_id;
|
|
88
109
|
nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
|
|
89
110
|
continue;
|
|
@@ -582,7 +582,13 @@ export function verificationProjection(works, receipts, options = {}) {
|
|
|
582
582
|
if (item.obligation_ref)
|
|
583
583
|
rawEvidence.set(item.obligation_ref, unique([...(rawEvidence.get(item.obligation_ref) ?? []), ...(item.refs ?? [])]));
|
|
584
584
|
}
|
|
585
|
-
const
|
|
585
|
+
const byCriterionGate = new Map();
|
|
586
|
+
for (const entry of packets.flatMap((value) => value.acceptance)) {
|
|
587
|
+
const key = JSON.stringify([entry.criterion_id, entry.gate]);
|
|
588
|
+
const previous = byCriterionGate.get(key);
|
|
589
|
+
byCriterionGate.set(key, { criterion_id: entry.criterion_id, gate: entry.gate, check_refs: unique([...(previous?.check_refs ?? []), ...entry.check_refs]) });
|
|
590
|
+
}
|
|
591
|
+
const acceptance = [...byCriterionGate.values()];
|
|
586
592
|
const historicalReceiptRefs = new Set((options.historicalReceipts ?? receipts).flatMap((receipt) => receiptReferences(receipt, options)));
|
|
587
593
|
const acceptanceEvidence = new Map(acceptance.map((item) => {
|
|
588
594
|
const reported = rawEvidence.get(item.criterion_id) ?? [];
|
|
@@ -201,6 +201,7 @@ export function prepareVnextPlanArtifacts(context, input) {
|
|
|
201
201
|
const batch = path.join(input.home, "03-plan", "code-work-batch.json");
|
|
202
202
|
const ownership = protocolOwnership(input.home, input.protocols);
|
|
203
203
|
const obligations = acceptedObligations(input.home);
|
|
204
|
+
const mergeEnding = runEndsAtMerge(context, input.projectRoot, input.runId);
|
|
204
205
|
const frozenDocumentBaselines = input.publishBatch === false && fs.existsSync(batch)
|
|
205
206
|
? new Map(readJson(batch).works?.flatMap((work) => typeof work.key === "string" ? (work.document_updates ?? []).flatMap((update) => typeof update.path === "string" && (typeof update.baseline_sha256 === "string" || update.baseline_sha256 === null)
|
|
206
207
|
? [[`${work.key}\u0000${update.path}`, update.baseline_sha256]]
|
|
@@ -214,7 +215,7 @@ export function prepareVnextPlanArtifacts(context, input) {
|
|
|
214
215
|
try {
|
|
215
216
|
const { value } = prepareJsonSchema(file, "PLAN", { schemaName: "vnext-protocol-plan", projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
|
|
216
217
|
assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
|
|
217
|
-
validatePlanSemantics(file, value, new Set(ownership.get(protocolId) ?? []), obligations);
|
|
218
|
+
validatePlanSemantics(file, value, new Set(ownership.get(protocolId) ?? []), obligations, mergeEnding);
|
|
218
219
|
validateCodeCheckCommands(workspaceRoot, value.checks.filter((check) => check.availability === "available").map((check) => check.command));
|
|
219
220
|
for (const check of value.checks.filter((item) => item.availability === "available"))
|
|
220
221
|
validateCheckDeclaration(workspaceRoot, check);
|
|
@@ -481,7 +482,7 @@ function normalizedAspectMapRefs(file, bytes, projectRoot, runHome, runId) {
|
|
|
481
482
|
aspect.planned_artifact_refs = aspect.planned_artifact_refs.map(normalize);
|
|
482
483
|
return changed ? `${JSON.stringify(map, null, 2)}\n` : bytes;
|
|
483
484
|
}
|
|
484
|
-
function projectCodeWorkBatch(input) {
|
|
485
|
+
export function projectCodeWorkBatch(input) {
|
|
485
486
|
const obligationMap = acceptedObligationMap(input.home);
|
|
486
487
|
const orientation = projectOrientation(input.workspaceRoot);
|
|
487
488
|
const works = input.plans.flatMap(({ protocolId, file, value }) => value.items.map((item) => {
|
|
@@ -516,10 +517,9 @@ function projectCodeWorkBatch(input) {
|
|
|
516
517
|
])],
|
|
517
518
|
discovery_boundary: item.execution_context.discovery_boundary,
|
|
518
519
|
planned_write_areas: [...new Set([...item.execution_context.planned_write_areas, ...documentPaths])],
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
checks: value.checks.filter((check) => itemCheckRefs(item, value.acceptance).includes(check.id)),
|
|
520
|
+
// CODE still needs acceptance-only aggregate declarations in its packets;
|
|
521
|
+
// work checks belong only to the item that explicitly verifies them.
|
|
522
|
+
checks: value.checks.filter((check) => itemCheckRefs(item, value.acceptance, value.checks).includes(check.id)),
|
|
523
523
|
provides_checks: value.checks.filter((check) => check.availability === "planned" && check.provided_by === item.id),
|
|
524
524
|
stop_conditions: item.execution_context.stop_conditions,
|
|
525
525
|
depends_on: item.depends_on.map((dependency) => `${protocolId}:${dependency}`),
|
|
@@ -548,10 +548,15 @@ function projectCodeWorkBatch(input) {
|
|
|
548
548
|
const ordered = [...works].sort((a, b) => a.key.localeCompare(b.key));
|
|
549
549
|
return { schema_id: "dd-flow/code-work-batch@5", sources: input.plans.map(({ protocolId, file, value }) => ({ plan_id: value.plan_id, protocol_id: protocolId, revision: value.revision, sha256: checksum(file) })), works: ordered };
|
|
550
550
|
}
|
|
551
|
-
function itemCheckRefs(item, acceptance) {
|
|
552
|
-
|
|
551
|
+
function itemCheckRefs(item, acceptance, catalog) {
|
|
552
|
+
const aggregate = new Set(catalog.filter((check) => check.run_at !== "work").map((check) => check.id));
|
|
553
|
+
return [...new Set([...item.verification.check_refs, ...acceptance.filter((entry) => entry.plan_item_ids.includes(item.id)).flatMap((entry) => entry.check_refs.filter((ref) => aggregate.has(ref)))])];
|
|
554
|
+
}
|
|
555
|
+
function renderCodeTask(item, catalog, acceptance = []) {
|
|
556
|
+
const local = catalog.filter((check) => check.run_at === "work" && item.verification.check_refs.includes(check.id));
|
|
557
|
+
const aggregate = catalog.filter((check) => check.run_at !== "work" && itemCheckRefs(item, acceptance, catalog).includes(check.id));
|
|
558
|
+
return [item.title, item.summary, item.details, `Preserve: ${item.semantic_spine.must_preserve.join("; ")}`, `Work checks: ${local.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Later aggregate checks (CODE/MERGE; do not run in this Work): ${aggregate.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Stop: ${item.execution_context.stop_conditions.join("; ")}`].join("\n\n");
|
|
553
559
|
}
|
|
554
|
-
function renderCodeTask(item, catalog, acceptance = []) { const checks = catalog.filter((check) => itemCheckRefs(item, acceptance).includes(check.id)); return [item.title, item.summary, item.details, `Preserve: ${item.semantic_spine.must_preserve.join("; ")}`, `Checks: ${checks.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Stop: ${item.execution_context.stop_conditions.join("; ")}`].join("\n\n"); }
|
|
555
560
|
function validateProjectedPaths(batch, workspaceRoot, home, runId) {
|
|
556
561
|
for (const work of batch.works) {
|
|
557
562
|
for (const write of work.planned_write_areas)
|
|
@@ -595,7 +600,7 @@ function resolvePortablePath(value, workspaceRoot, home, runId) {
|
|
|
595
600
|
}
|
|
596
601
|
function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
597
602
|
function runRef(runId, runHome, file) { return `run://${runId}/${path.relative(runHome, file).split(path.sep).join("/")}`; }
|
|
598
|
-
function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
|
|
603
|
+
function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs, mergeEnding) {
|
|
599
604
|
const items = plan.items ?? [];
|
|
600
605
|
const ids = new Set(items.map((item) => item.id).filter((id) => Boolean(id)));
|
|
601
606
|
if (ids.size !== items.length)
|
|
@@ -608,6 +613,8 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
|
|
|
608
613
|
for (const check of plan.checks ?? []) {
|
|
609
614
|
if (checks.has(check.id))
|
|
610
615
|
throw new AppError("validation", "PLAN check ids must be unique", 2, { file, check_id: check.id });
|
|
616
|
+
if (mergeEnding && check.run_at === "release")
|
|
617
|
+
throw new AppError("check_gate_unreachable", "A RUN ending at MERGE cannot execute RELEASE checks", 2, { file, check_id: check.id });
|
|
611
618
|
if (check.availability === "planned" && (!check.provided_by || !ids.has(check.provided_by)))
|
|
612
619
|
throw new AppError("planned_check_provider_missing", "A planned check must name a PLAN item that provides it", 2, { file, check_id: check.id, provided_by: check.provided_by ?? null });
|
|
613
620
|
if (check.availability === "planned" && !check.command.startsWith("@check/"))
|
|
@@ -616,6 +623,8 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
|
|
|
616
623
|
throw new AppError("planned_check_definition_missing", "A planned check must declare its exact alias definition", 2, { file, check_id: check.id });
|
|
617
624
|
checks.set(check.id, check);
|
|
618
625
|
}
|
|
626
|
+
const localChecks = new Set(items.flatMap((item) => item.verification.check_refs));
|
|
627
|
+
const acceptanceIds = new Set();
|
|
619
628
|
const ancestors = (itemId, seen = new Set()) => {
|
|
620
629
|
for (const dependency of items.find((item) => item.id === itemId)?.depends_on ?? [])
|
|
621
630
|
if (!seen.has(dependency)) {
|
|
@@ -639,7 +648,7 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
|
|
|
639
648
|
const check = checks.get(checkId);
|
|
640
649
|
if (!check)
|
|
641
650
|
throw new AppError("check_reference_unknown", "PLAN item references an unknown check", 2, { file, item: item.id, check_id: checkId });
|
|
642
|
-
if (check.availability === "planned" && check.provided_by !== item.id && !ancestors(item.id).has(check.provided_by))
|
|
651
|
+
if (check.run_at === "work" && check.availability === "planned" && check.provided_by !== item.id && !ancestors(item.id).has(check.provided_by))
|
|
643
652
|
throw new AppError("check_consumer_not_ordered_after_provider", "A Work may consume a planned check only after its provider", 2, { file, item: item.id, check_id: checkId, provider: check.provided_by });
|
|
644
653
|
}
|
|
645
654
|
}
|
|
@@ -655,6 +664,11 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
|
|
|
655
664
|
for (const acceptance of plan.acceptance ?? []) {
|
|
656
665
|
if (!acceptance.criterion_id || !acceptance.criterion_id.startsWith("AC-") || !ownedRefs.has(acceptance.criterion_id))
|
|
657
666
|
throw new AppError("validation", "PLAN acceptance references an AC-* owned by this protocol", 2, { file, criterion_id: acceptance.criterion_id });
|
|
667
|
+
if (acceptanceIds.has(acceptance.criterion_id))
|
|
668
|
+
throw new AppError("duplicate_plan_criterion", "PLAN acceptance criterion ids must be unique within a protocol", 2, { file, criterion_id: acceptance.criterion_id });
|
|
669
|
+
acceptanceIds.add(acceptance.criterion_id);
|
|
670
|
+
if (mergeEnding && acceptance.gate === "release")
|
|
671
|
+
throw new AppError("check_gate_unreachable", "A RUN ending at MERGE cannot prove RELEASE acceptance", 2, { file, criterion_id: acceptance.criterion_id });
|
|
658
672
|
for (const id of acceptance.plan_item_ids ?? [])
|
|
659
673
|
if (!ids.has(id))
|
|
660
674
|
throw new AppError("validation", "PLAN acceptance references an unknown item", 2, { file, criterion_id: acceptance.criterion_id, plan_item_id: id });
|
|
@@ -662,9 +676,10 @@ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
|
|
|
662
676
|
const check = checks.get(id);
|
|
663
677
|
if (!check)
|
|
664
678
|
throw new AppError("check_reference_unknown", "PLAN acceptance references an unknown check", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
679
|
+
if ((acceptance.gate !== "release" && check.run_at === "release") || (!["merge", "release"].includes(acceptance.gate) && check.run_at === "merge"))
|
|
680
|
+
throw new AppError("check_gate_unreachable", "PLAN acceptance references a check after its gate", 2, { file, criterion_id: acceptance.criterion_id, check_id: id, gate: acceptance.gate, run_at: check.run_at });
|
|
681
|
+
if (check.run_at === "work" && !localChecks.has(id))
|
|
682
|
+
throw new AppError("work_check_unassigned", "A work check referenced by acceptance needs an explicit verifying PLAN item", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
|
|
668
683
|
}
|
|
669
684
|
}
|
|
670
685
|
for (const obligation of ownedRefs)
|