@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.15
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/README.md +13 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/run-cli.js +12 -6
- package/dist/services/code-checks.js +68 -22
- package/dist/services/managed-processes.js +70 -18
- package/dist/services/runs.js +1 -1
- package/dist/services/runtime-service.js +92 -0
- package/dist/services/vnext-code.js +6 -34
- package/dist/services/vnext-merge.js +10 -4
- package/dist/services/work-registry.js +19 -11
- package/dist/services/workspace-bootstrap.js +50 -0
- 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.15
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add owned temporary HTTP service supervision with allocated ports and readiness receipts. Prevent duplicate workspace bootstrap processes, retain resources when process ownership is uncertain, and share safe stop behavior between explicit stops and orphan reconciliation.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.14
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Audit runtime recovery: distinguish process owners from children, retain live check resources, await failed check startup cleanup, share managed workspace bootstrap between CODE/MERGE, correct assigned repair examples, and reject publication without canonical build provenance.
|
|
14
|
+
|
|
3
15
|
## 0.9.0-beta.13
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## Managed temporary HTTP services
|
|
4
|
+
|
|
5
|
+
For an interactive scenario, `runtime process start --run <RUN-ID>
|
|
6
|
+
--project-root <absolute-project> --command '<project command>' --ports api
|
|
7
|
+
--ready-port api --ready-path /health --json --progress-jsonl` runs a foreground
|
|
8
|
+
supervisor. The command receives `DD_FLOW_PORT_API`; use it instead of a fixed
|
|
9
|
+
port. Additional comma-separated names receive equivalent environment variables.
|
|
10
|
+
The ready event points to a receipt and logs under the RUN's `runtime/` folder.
|
|
11
|
+
Keep the tool invocation alive, run the scenario using the allocated ports,
|
|
12
|
+
then execute the exact `stop_command` in that receipt and await the supervisor.
|
|
13
|
+
Readiness is not a scenario pass. Keep semantic/scenario evidence separately.
|
|
14
|
+
Never use a broad `pkill`/`killall`. A failed owned stop retains resources.
|
|
15
|
+
|
|
3
16
|
`dd-flow-cli` is the mechanical control layer for `dd-flow` workflows.
|
|
4
17
|
|
|
5
18
|
It does not replace Memory Bank prompts and does not make product, design, merge, or verification judgments. Prompts own intent, route selection, planning depth, evidence meaning, and semantic readiness. The CLI owns explicit local state: projects, protocols, Codex session bindings, transitions, worktree records, lanes, locks, merge queue jobs, hook records, and audit events.
|
package/dist/build-info.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
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.15",
|
|
4
|
+
"cli_commit": "e11a611fe87370d6203792e5c6a54d7205235a26",
|
|
5
|
+
"built_at": "2026-09-05T09:50:59.828Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
|
-
"version":
|
|
8
|
-
"commit":
|
|
9
|
-
"flow_contract":
|
|
10
|
-
"repo_root":
|
|
11
|
-
"memorybank_root":
|
|
12
|
-
"flow_root":
|
|
13
|
-
"layout":
|
|
7
|
+
"version": "4.0.4",
|
|
8
|
+
"commit": "c8cae271f844fd3b3c7492f164e13a60eaba4839",
|
|
9
|
+
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
|
+
"repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
|
|
11
|
+
"memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
|
|
12
|
+
"flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
|
|
13
|
+
"layout": "dot_memory_bank"
|
|
14
14
|
}
|
|
15
15
|
}
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -50,7 +50,8 @@ import { finishStage, startStage } from "../services/stage-lifecycle.js";
|
|
|
50
50
|
import { addWorkBatch, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
|
|
51
51
|
import { createEvalBootstrapSnapshot, createEvalRunSnapshot, prepareVnextSpecifyRun, restoreEvalBootstrapSnapshot, restoreEvalRunSnapshot } from "../services/eval-snapshots.js";
|
|
52
52
|
import { loadExternalStageContext } from "../services/stage-context.js";
|
|
53
|
-
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
|
|
53
|
+
import { stopManagedProcess, confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
|
|
54
|
+
import { startRuntimeService } from "../services/runtime-service.js";
|
|
54
55
|
const defaultIo = {
|
|
55
56
|
stdout: process.stdout,
|
|
56
57
|
stderr: process.stderr,
|
|
@@ -517,7 +518,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
517
518
|
return getCliVersionReport();
|
|
518
519
|
}
|
|
519
520
|
if (family === "runtime") {
|
|
520
|
-
return await dispatchRuntime(context, command, parsed);
|
|
521
|
+
return await dispatchRuntime(context, command, parsed, commandProgress(io, output));
|
|
521
522
|
}
|
|
522
523
|
if (family === "canon") {
|
|
523
524
|
return dispatchCanon(context, command, parsed);
|
|
@@ -1583,14 +1584,19 @@ function dispatchRun(context, command, parsed) {
|
|
|
1583
1584
|
}
|
|
1584
1585
|
throw new AppError("usage", `Unknown run command: ${command ?? "<empty>"}`, 2);
|
|
1585
1586
|
}
|
|
1586
|
-
async function dispatchRuntime(context, command, parsed) {
|
|
1587
|
+
async function dispatchRuntime(context, command, parsed, progress) {
|
|
1587
1588
|
if (command !== "process")
|
|
1588
|
-
throw new AppError("usage", "Usage: dd-flow runtime process <register|confirm|heartbeat|finish|status|reconcile>", 2);
|
|
1589
|
+
throw new AppError("usage", "Usage: dd-flow runtime process <start|stop|register|confirm|heartbeat|finish|status|reconcile>", 2);
|
|
1589
1590
|
const action = requiredPosition(parsed, 0, "runtime process action");
|
|
1591
|
+
if (action === "start")
|
|
1592
|
+
return await startRuntimeService(context, { projectRoot: requiredOption(parsed, "project-root"), runId: requiredOption(parsed, "run"), command: requiredOption(parsed, "command"), ports: requiredOption(parsed, "ports").split(","), readyPort: requiredOption(parsed, "ready-port"), readyPath: optionalOption(parsed, "ready-path") ?? "/", timeoutMs: optionalPositiveNumber(parsed, "ready-timeout-ms", true) ?? 30_000, progress });
|
|
1593
|
+
if (action === "stop")
|
|
1594
|
+
return { ok: true, process: await stopManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), ...(optionalPositiveNumber(parsed, "grace-ms", true) ? { graceMs: optionalPositiveNumber(parsed, "grace-ms", true) } : {}) }) };
|
|
1590
1595
|
if (action === "register") {
|
|
1591
1596
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
1592
1597
|
const process = registerManagedProcess(context, {
|
|
1593
1598
|
kind: requiredOption(parsed, "kind"), ownerId: requiredOption(parsed, "owner"),
|
|
1599
|
+
ownerPid: optionalPositiveNumber(parsed, "owner-pid", true),
|
|
1594
1600
|
projectId: optionalOption(parsed, "project-id") ?? null, runId: optionalOption(parsed, "run") ?? null,
|
|
1595
1601
|
workId: optionalOption(parsed, "work") ?? null, checkId: optionalOption(parsed, "check") ?? null,
|
|
1596
1602
|
operationId: optionalOption(parsed, "operation") ?? null, stdoutPath: optionalOption(parsed, "stdout") ?? null,
|
|
@@ -1601,7 +1607,7 @@ async function dispatchRuntime(context, command, parsed) {
|
|
|
1601
1607
|
if (action === "confirm") {
|
|
1602
1608
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
1603
1609
|
const processGroupId = optionalPositiveNumber(parsed, "process-group-id", true);
|
|
1604
|
-
return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
|
|
1610
|
+
return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), ownerPid: optionalPositiveNumber(parsed, "owner-pid", true), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
|
|
1605
1611
|
}
|
|
1606
1612
|
if (action === "heartbeat") {
|
|
1607
1613
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
@@ -1617,7 +1623,7 @@ async function dispatchRuntime(context, command, parsed) {
|
|
|
1617
1623
|
return { ok: true, processes: managedProcessStatus(context) };
|
|
1618
1624
|
if (action === "reconcile")
|
|
1619
1625
|
return { ok: true, processes: await reconcileExpiredManagedProcesses(context, requiredOption(parsed, "owner"), optionalPositiveNumber(parsed, "grace-ms", true)) };
|
|
1620
|
-
throw new AppError("usage", "Usage: dd-flow runtime process <register|confirm|heartbeat|finish|status|reconcile>", 2);
|
|
1626
|
+
throw new AppError("usage", "Usage: dd-flow runtime process <start|stop|register|confirm|heartbeat|finish|status|reconcile>", 2);
|
|
1621
1627
|
}
|
|
1622
1628
|
function dispatchStat(context, command, parsed) {
|
|
1623
1629
|
if (command === "usage") {
|
|
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
-
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus,
|
|
6
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
7
7
|
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
8
8
|
// Receipt reservation precedes child spawn so an interrupted CLI leaves an
|
|
9
9
|
// auditable attempt. A second caller must not mistake that small handoff window
|
|
@@ -99,7 +99,7 @@ export async function runCodeChecks(context, input) {
|
|
|
99
99
|
try {
|
|
100
100
|
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
101
101
|
writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
|
|
102
|
-
const managed = registerManagedProcess(context, { kind: "check", ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
|
|
102
|
+
const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
|
|
103
103
|
const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
|
|
104
104
|
resources = allocation.ports;
|
|
105
105
|
input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
|
|
@@ -109,13 +109,18 @@ export async function runCodeChecks(context, input) {
|
|
|
109
109
|
result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
110
110
|
}
|
|
111
111
|
finally {
|
|
112
|
-
allocation.release();
|
|
113
112
|
fs.closeSync(stdout);
|
|
114
113
|
fs.closeSync(stderr);
|
|
115
|
-
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
116
114
|
}
|
|
115
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
116
|
+
if (processTreeIsAlive(current))
|
|
117
|
+
throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
|
|
118
|
+
allocation.release();
|
|
119
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
117
120
|
}
|
|
118
121
|
catch (error) {
|
|
122
|
+
if (error instanceof AppError && error.code === "check_in_progress")
|
|
123
|
+
throw error;
|
|
119
124
|
result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
|
|
120
125
|
}
|
|
121
126
|
const after = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
@@ -158,7 +163,7 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
158
163
|
// A stale lease after host sleep is not permission to duplicate a living
|
|
159
164
|
// check. Its durable completion marker or explicit reconciliation decides
|
|
160
165
|
// the outcome before another invocation may start.
|
|
161
|
-
if (process && ["starting", "running", "stopping"].includes(process.state) && (process.state === "starting" ||
|
|
166
|
+
if (process && ["starting", "running", "stopping"].includes(process.state) && (processTreeIsAlive(process) || (process.state === "starting" && (managedOwnerIsAlive(process) !== false || Date.parse(process.lease_expires_at) > Date.parse(context.now()))))) {
|
|
162
167
|
throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
|
|
163
168
|
}
|
|
164
169
|
if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
|
|
@@ -269,24 +274,65 @@ function collectRequiredArtifacts(root, required) { const missing = []; const it
|
|
|
269
274
|
} return { complete: missing.length === 0, missing, items }; }
|
|
270
275
|
function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
|
|
271
276
|
return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
|
|
272
|
-
function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
+
export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
const started = Date.now();
|
|
280
|
+
const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" });
|
|
281
|
+
let error = null;
|
|
282
|
+
let stopping = false;
|
|
283
|
+
let progress;
|
|
284
|
+
let escalation;
|
|
285
|
+
const stop = (cause) => {
|
|
286
|
+
if (stopping)
|
|
287
|
+
return;
|
|
288
|
+
stopping = true;
|
|
289
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
290
|
+
if (progress) {
|
|
291
|
+
clearInterval(progress);
|
|
292
|
+
progress = undefined;
|
|
293
|
+
}
|
|
294
|
+
if (child.pid) {
|
|
295
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
296
|
+
escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
// Register handlers before checking pid or calling persistence callbacks.
|
|
300
|
+
// Failure is not completion until the spawned process has actually closed.
|
|
301
|
+
child.once("error", (value) => { error = value.message; });
|
|
302
|
+
child.once("close", (exitCode, signal) => {
|
|
303
|
+
if (progress)
|
|
304
|
+
clearInterval(progress);
|
|
305
|
+
if (escalation)
|
|
306
|
+
clearTimeout(escalation);
|
|
307
|
+
if (stopping)
|
|
308
|
+
terminateProcessGroup(child.pid, "SIGKILL");
|
|
309
|
+
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error) });
|
|
310
|
+
});
|
|
311
|
+
if (!child.pid) {
|
|
312
|
+
error = "check process did not provide a PID";
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
onSpawn(child.pid);
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
stop(cause);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
progress = setInterval(() => {
|
|
323
|
+
try {
|
|
324
|
+
if (!renewLease()) {
|
|
325
|
+
stop("managed process lease lost");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
heartbeat(Math.floor((Date.now() - started) / 1000));
|
|
329
|
+
}
|
|
330
|
+
catch (cause) {
|
|
331
|
+
stop(cause);
|
|
332
|
+
}
|
|
333
|
+
}, 15_000);
|
|
334
|
+
});
|
|
277
335
|
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
terminateProcessGroup(child.pid, "SIGTERM");
|
|
280
|
-
resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
|
|
281
|
-
return;
|
|
282
|
-
} let leaseLost = false; let error = null; let escalation; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
|
|
283
|
-
error = "managed process lease lost";
|
|
284
|
-
leaseLost = true;
|
|
285
|
-
terminateProcessGroup(child.pid, "SIGTERM");
|
|
286
|
-
escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
|
|
287
|
-
return;
|
|
288
|
-
} heartbeat(elapsed); }, 15_000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (escalation)
|
|
289
|
-
clearTimeout(escalation); resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: leaseLost || Boolean(signal) || Boolean(error) }); }); }); }
|
|
290
336
|
function checkShellScript() {
|
|
291
337
|
return [
|
|
292
338
|
'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
|
|
@@ -7,13 +7,33 @@ export function resourceHome(context) {
|
|
|
7
7
|
return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
|
|
8
8
|
}
|
|
9
9
|
export function registerManagedProcess(context, input) {
|
|
10
|
+
if (!input.uniqueActiveOperation)
|
|
11
|
+
return insertManagedProcess(context, input);
|
|
12
|
+
if (!input.operationId)
|
|
13
|
+
throw new Error("Unique managed operation requires operationId");
|
|
14
|
+
const db = registry(context);
|
|
15
|
+
db.exec("BEGIN IMMEDIATE");
|
|
16
|
+
try {
|
|
17
|
+
const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
|
|
18
|
+
if (existing)
|
|
19
|
+
throw Object.assign(new Error("The operation already owns a managed process; observe it instead of spawning a duplicate"), { code: "managed_operation_in_progress", details: { process_id: existing.id, operation_id: input.operationId } });
|
|
20
|
+
const result = insertManagedProcess(context, input);
|
|
21
|
+
db.exec("COMMIT");
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
db.exec("ROLLBACK");
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function insertManagedProcess(context, input) {
|
|
10
30
|
const db = registry(context);
|
|
11
31
|
const now = context.now();
|
|
12
32
|
const id = input.id ?? `PROC-${crypto.randomUUID()}`;
|
|
13
33
|
const token = crypto.randomUUID();
|
|
14
34
|
db.run(`INSERT INTO managed_processes
|
|
15
35
|
(id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
|
|
16
|
-
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify(input.metadata
|
|
36
|
+
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify({ ...input.metadata, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) })]);
|
|
17
37
|
return requireProcess(db, id);
|
|
18
38
|
}
|
|
19
39
|
export function confirmManagedProcess(context, input) {
|
|
@@ -23,6 +43,10 @@ export function confirmManagedProcess(context, input) {
|
|
|
23
43
|
const metadata = parseMetadata(current.metadata_json);
|
|
24
44
|
if (input.processGroupId)
|
|
25
45
|
metadata.process_group_id = input.processGroupId;
|
|
46
|
+
if (input.ownerPid) {
|
|
47
|
+
metadata.owner_pid = input.ownerPid;
|
|
48
|
+
metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
|
|
49
|
+
}
|
|
26
50
|
const result = db.run(`UPDATE managed_processes
|
|
27
51
|
SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
|
|
28
52
|
WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
|
|
@@ -36,6 +60,9 @@ export function heartbeatManagedProcess(context, input) {
|
|
|
36
60
|
}
|
|
37
61
|
export function finishManagedProcess(context, input) {
|
|
38
62
|
const db = registry(context);
|
|
63
|
+
const existing = requireProcess(db, input.id);
|
|
64
|
+
if (existing.lease_token === input.leaseToken && existing.state === input.state)
|
|
65
|
+
return true;
|
|
39
66
|
const now = context.now();
|
|
40
67
|
const updated = db.run("UPDATE managed_processes SET state = ?, termination_reason = ?, finished_at = ?, updated_at = ?, lease_expires_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned')", [input.state, input.reason ?? null, now, now, now, input.id, input.leaseToken]).changes === 1;
|
|
41
68
|
if (updated)
|
|
@@ -48,12 +75,13 @@ export function processIsAlive(record) {
|
|
|
48
75
|
try {
|
|
49
76
|
process.kill(record.pid, 0);
|
|
50
77
|
}
|
|
51
|
-
catch {
|
|
52
|
-
return
|
|
78
|
+
catch (error) {
|
|
79
|
+
return error.code !== "ESRCH";
|
|
53
80
|
}
|
|
54
|
-
|
|
81
|
+
const started = processStartedAt(record.pid);
|
|
82
|
+
return !record.pid_started_at || !started || record.pid_started_at === started;
|
|
55
83
|
}
|
|
56
|
-
function processTreeIsAlive(record) {
|
|
84
|
+
export function processTreeIsAlive(record) {
|
|
57
85
|
if (processIsAlive(record))
|
|
58
86
|
return true;
|
|
59
87
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
@@ -62,12 +90,18 @@ function processTreeIsAlive(record) {
|
|
|
62
90
|
process.kill(-group, 0);
|
|
63
91
|
return true;
|
|
64
92
|
}
|
|
65
|
-
catch {
|
|
66
|
-
return
|
|
93
|
+
catch (error) {
|
|
94
|
+
return error.code !== "ESRCH";
|
|
67
95
|
}
|
|
68
96
|
}
|
|
69
97
|
return false;
|
|
70
98
|
}
|
|
99
|
+
export function managedOwnerIsAlive(record) {
|
|
100
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
101
|
+
if (typeof metadata.owner_pid !== "number")
|
|
102
|
+
return null;
|
|
103
|
+
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
|
|
104
|
+
}
|
|
71
105
|
/**
|
|
72
106
|
* Expiry proves that observation stopped, not that the owner died. A live PID
|
|
73
107
|
* remains owned until its result is reconciled; only a dead process can be
|
|
@@ -81,7 +115,8 @@ export function claimExpiredManagedProcesses(context, ownerId) {
|
|
|
81
115
|
const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
|
|
82
116
|
const claimed = [];
|
|
83
117
|
for (const candidate of candidates) {
|
|
84
|
-
|
|
118
|
+
const ownerAlive = managedOwnerIsAlive(candidate);
|
|
119
|
+
if (ownerAlive === true || (ownerAlive === null && processTreeIsAlive(candidate)))
|
|
85
120
|
continue;
|
|
86
121
|
const token = crypto.randomUUID();
|
|
87
122
|
const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
|
|
@@ -99,6 +134,26 @@ export function claimExpiredManagedProcesses(context, ownerId) {
|
|
|
99
134
|
export function managedProcessStatus(context) {
|
|
100
135
|
return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
|
|
101
136
|
}
|
|
137
|
+
export async function stopManagedProcess(context, input) {
|
|
138
|
+
const db = registry(context), record = requireProcess(db, input.id);
|
|
139
|
+
if (record.lease_token !== input.leaseToken)
|
|
140
|
+
throw new Error("Managed process lease does not match");
|
|
141
|
+
if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
|
|
142
|
+
return record;
|
|
143
|
+
db.run("UPDATE managed_processes SET state = 'stopping', updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','orphaned')", [context.now(), record.id, record.lease_token]);
|
|
144
|
+
if (processTreeIsAlive(record)) {
|
|
145
|
+
terminateOwnedProcess(record, "SIGTERM");
|
|
146
|
+
await delay(input.graceMs ?? 1_000);
|
|
147
|
+
if (processTreeIsAlive(record)) {
|
|
148
|
+
terminateOwnedProcess(record, "SIGKILL");
|
|
149
|
+
await delay(250);
|
|
150
|
+
}
|
|
151
|
+
if (processTreeIsAlive(record))
|
|
152
|
+
throw Object.assign(new Error("Owned process tree could not be safely stopped; resources retained"), { code: "process_tree_not_settled", details: { process_id: record.id } });
|
|
153
|
+
}
|
|
154
|
+
finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" });
|
|
155
|
+
return requireProcess(db, record.id);
|
|
156
|
+
}
|
|
102
157
|
/** Reconcile only records the system owns and has atomically claimed. */
|
|
103
158
|
export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
|
|
104
159
|
const claimed = claimExpiredManagedProcesses(context, ownerId);
|
|
@@ -109,18 +164,15 @@ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs
|
|
|
109
164
|
outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
|
|
110
165
|
continue;
|
|
111
166
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
terminateOwnedProcess(processRecord, "SIGKILL");
|
|
116
|
-
await delay(Math.min(graceMs, 250));
|
|
167
|
+
try {
|
|
168
|
+
await stopManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, graceMs, reason: "orphan_reconciled" });
|
|
169
|
+
outcomes.push({ id: processRecord.id, outcome: "stopped" });
|
|
117
170
|
}
|
|
118
|
-
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== "process_tree_not_settled")
|
|
173
|
+
throw error;
|
|
119
174
|
outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
|
|
120
|
-
continue;
|
|
121
175
|
}
|
|
122
|
-
finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
|
|
123
|
-
outcomes.push({ id: processRecord.id, outcome: "stopped" });
|
|
124
176
|
}
|
|
125
177
|
return outcomes;
|
|
126
178
|
}
|
|
@@ -160,7 +212,7 @@ function releasePortClaims(db, claims) { for (const claim of claims)
|
|
|
160
212
|
function terminateOwnedProcess(record, signal) {
|
|
161
213
|
// Never signal a group merely because its former leader PID was once ours.
|
|
162
214
|
// PID reuse or a departed leader makes group ownership unprovable.
|
|
163
|
-
if (!record.pid || !record.pid_started_at ||
|
|
215
|
+
if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
|
|
164
216
|
return;
|
|
165
217
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
166
218
|
try {
|
package/dist/services/runs.js
CHANGED
|
@@ -1263,7 +1263,7 @@ function upsertStage(index, stageRun) {
|
|
|
1263
1263
|
}
|
|
1264
1264
|
index.stage_runs.sort((a, b) => a.order - b.order);
|
|
1265
1265
|
}
|
|
1266
|
-
function resolveRun(context, projectId, idOrAlias) {
|
|
1266
|
+
export function resolveRun(context, projectId, idOrAlias) {
|
|
1267
1267
|
if (isFullEntityId(idOrAlias)) {
|
|
1268
1268
|
return requireRunById(context, projectId, idOrAlias);
|
|
1269
1269
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
6
|
+
import { resolveRun } from "./runs.js";
|
|
7
|
+
import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
|
|
8
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processTreeIsAlive, registerManagedProcess, reservePorts, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
9
|
+
/** Foreground supervisor: the tool may yield while this command owns the service.
|
|
10
|
+
* Its ready receipt is emitted immediately; a separate stop ends this command.
|
|
11
|
+
* Readiness is infrastructure evidence, never a successful scenario verdict. */
|
|
12
|
+
export async function startRuntimeService(context, input) {
|
|
13
|
+
if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0)
|
|
14
|
+
throw new AppError("validation", "Readiness timeout must be positive and finite", 2);
|
|
15
|
+
if (!input.command.trim() || !input.ports.length || input.ports.some(name => !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
|
|
16
|
+
throw new AppError("validation", "Declare unique lowercase port names, a ready-port from that list and a local /ready-path", 2);
|
|
17
|
+
const project = requireProjectByRoot(context, path.resolve(input.projectRoot));
|
|
18
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
19
|
+
if (!run?.run_root || !run.workspace_root)
|
|
20
|
+
throw new AppError("not_found", "Registered RUN with a materialized workspace is required", 2);
|
|
21
|
+
input = { ...input, runId: run.id };
|
|
22
|
+
const id = `PROC-${crypto.randomUUID()}`;
|
|
23
|
+
const directory = path.join(run.run_root, "runtime", id);
|
|
24
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
25
|
+
const record = registerManagedProcess(context, { id, kind: "runtime-service", ownerId: `run:${input.runId}`, ownerPid: process.pid, runId: input.runId, projectId: project.id, stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), metadata: { command: input.command } });
|
|
26
|
+
const receiptPath = path.join(directory, "service.json");
|
|
27
|
+
let stdout, stderr;
|
|
28
|
+
let settled = false, ready = false;
|
|
29
|
+
const receipt = { schema_id: "dd-flow/runtime-service@1", process_id: record.id, run_id: input.runId, status: "starting", started_at: context.now(), receipt_path: receiptPath, stdout_path: path.join(directory, "stdout.log"), stderr_path: path.join(directory, "stderr.log") };
|
|
30
|
+
const save = () => { const temporary = `${receiptPath}.${process.pid}.tmp`; fs.writeFileSync(temporary, JSON.stringify(receipt, null, 2)); fs.renameSync(temporary, receiptPath); };
|
|
31
|
+
try {
|
|
32
|
+
const allocation = await reservePorts(context, { ownerId: record.owner_id, processId: record.id, names: input.ports });
|
|
33
|
+
const environment = { ...codeExecutionEnvironment(run.workspace_root), ...Object.fromEntries(Object.entries(allocation.ports).map(([name, port]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(port)])), DD_FLOW_EVIDENCE_DIR: directory, DD_FLOW_CHECK_COMPLETION_FILE: path.join(directory, "completion.json") };
|
|
34
|
+
const quoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
35
|
+
Object.assign(receipt, { ports: allocation.ports, environment: Object.fromEntries(Object.entries(environment).filter(([key]) => key.startsWith("DD_FLOW_PORT_"))), stop_command: `DD_FLOW_HOME=${quoted(context.ddFlowHome)} DD_FLOW_RESOURCE_HOME=${quoted(resourceHome(context))} dd-flow runtime process stop --id ${record.id} --lease-token ${record.lease_token} --json` });
|
|
36
|
+
save();
|
|
37
|
+
stdout = fs.openSync(String(receipt.stdout_path), "a");
|
|
38
|
+
stderr = fs.openSync(String(receipt.stderr_path), "a");
|
|
39
|
+
const running = runCheck(input.command, run.workspace_root, environment, stdout, stderr, elapsed => input.progress?.(`service ${record.id} alive (${elapsed}s); receipt: ${receiptPath}`), pid => confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }));
|
|
40
|
+
void running.then(() => { settled = true; });
|
|
41
|
+
const deadline = performance.now() + input.timeoutMs;
|
|
42
|
+
while (!settled && performance.now() < deadline) {
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(`http://127.0.0.1:${allocation.ports[input.readyPort]}${input.readyPath}`, { signal: AbortSignal.timeout(1_000), redirect: "error" });
|
|
45
|
+
await response.body?.cancel();
|
|
46
|
+
ready = response.ok;
|
|
47
|
+
}
|
|
48
|
+
catch { /* Startup is observed until the declared deadline. */ }
|
|
49
|
+
if (ready)
|
|
50
|
+
break;
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
52
|
+
}
|
|
53
|
+
if (!ready) {
|
|
54
|
+
await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token });
|
|
55
|
+
await running;
|
|
56
|
+
throw new AppError("service_not_ready", "Service did not pass its declared HTTP readiness check; inspect retained logs", 2, { receipt_path: receiptPath });
|
|
57
|
+
}
|
|
58
|
+
Object.assign(receipt, { status: "ready", ready_at: context.now() });
|
|
59
|
+
save();
|
|
60
|
+
input.progress?.(`service ready: ${JSON.stringify(receipt)}; keep this supervisor running, use its exact stop command when finished`);
|
|
61
|
+
const outcome = await running;
|
|
62
|
+
const current = managedProcessStatus(context).find(item => item.id === record.id);
|
|
63
|
+
if (current.state === "stopping") {
|
|
64
|
+
const settleBy = performance.now() + 5_000;
|
|
65
|
+
while (processTreeIsAlive(current) && performance.now() < settleBy)
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
67
|
+
}
|
|
68
|
+
if (processTreeIsAlive(current))
|
|
69
|
+
throw new AppError("process_tree_not_settled", "Service still owns live children; retain resources", 2, { process_id: record.id });
|
|
70
|
+
if (!["stopped", "failed"].includes(current.state))
|
|
71
|
+
finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: current.state === "stopping" || outcome.exitCode === 0 ? "stopped" : "failed", reason: current.state === "stopping" ? "requested_stop" : outcome.error });
|
|
72
|
+
Object.assign(receipt, { status: managedProcessStatus(context).find(item => item.id === record.id).state, finished_at: context.now(), exit_code: outcome.exitCode });
|
|
73
|
+
save();
|
|
74
|
+
if (receipt.status === "failed")
|
|
75
|
+
throw new AppError("service_failed", "Service exited unsuccessfully; inspect retained logs", 2, receipt);
|
|
76
|
+
return receipt;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const current = managedProcessStatus(context).find(item => item.id === record.id);
|
|
80
|
+
if (!processTreeIsAlive(current))
|
|
81
|
+
finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "failed", reason: error instanceof Error ? error.message : String(error) });
|
|
82
|
+
Object.assign(receipt, { status: "failed", error: error instanceof Error ? error.message : String(error), cleanup_pending: processTreeIsAlive(current) });
|
|
83
|
+
save();
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
if (stdout !== undefined)
|
|
88
|
+
fs.closeSync(stdout);
|
|
89
|
+
if (stderr !== undefined)
|
|
90
|
+
fs.closeSync(stderr);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import { aggregateCheckDeclarations, checkReceipts,
|
|
7
|
+
import { aggregateCheckDeclarations, checkReceipts, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
9
|
import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
10
10
|
import { validateSchema } from "./schema-validation.js";
|
|
@@ -653,50 +653,22 @@ async function ensureCodeWorkspaceReady(context, projectId, run, root, readiness
|
|
|
653
653
|
throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
|
|
654
654
|
}
|
|
655
655
|
const receiptPath = path.join(root, "workspace-readiness.json");
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
return { ...prior, reused: true };
|
|
656
|
+
// Re-run the project bootstrap on stage entry: a prior receipt cannot prove
|
|
657
|
+
// that ignored dependencies or local services still exist.
|
|
659
658
|
const startedAt = new Date().toISOString();
|
|
660
659
|
progress?.(`workspace bootstrap started: ${bootstrap.command}`);
|
|
661
|
-
const result = await
|
|
660
|
+
const result = await runWorkspaceBootstrap(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, command: bootstrap.command, artifactDir: "05-code/readiness", progress });
|
|
662
661
|
const bootstrapPassed = result.exit_code === 0 && !result.error;
|
|
663
662
|
const checks = bootstrapPassed && readinessChecks.length
|
|
664
663
|
? await runCodeChecks(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, scope: "aggregate", artifactDir: "05-code/readiness", checks: readinessChecks, ...(progress ? { progress } : {}) })
|
|
665
664
|
: [];
|
|
666
|
-
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
665
|
+
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
667
666
|
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
668
667
|
if (receipt.status !== "passed")
|
|
669
668
|
throw new AppError("workspace_readiness_failed", "CODE workspace bootstrap failed", 1, receipt);
|
|
670
669
|
progress?.("workspace bootstrap passed");
|
|
671
670
|
return receipt;
|
|
672
671
|
}
|
|
673
|
-
function runBootstrap(command, cwd, environment, progress) {
|
|
674
|
-
return new Promise((resolve) => {
|
|
675
|
-
const started = Date.now();
|
|
676
|
-
const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
677
|
-
let stdout = "";
|
|
678
|
-
let stderr = "";
|
|
679
|
-
let error = null;
|
|
680
|
-
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
|
|
681
|
-
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
|
|
682
|
-
const heartbeat = setInterval(() => progress?.(`workspace bootstrap still running (${Math.floor((Date.now() - started) / 1000)}s)`), 15_000);
|
|
683
|
-
const timeout = setTimeout(() => child.kill("SIGTERM"), 15 * 60 * 1000);
|
|
684
|
-
child.once("error", (value) => { error = value.message; });
|
|
685
|
-
child.once("close", (exitCode, signal) => {
|
|
686
|
-
clearInterval(heartbeat);
|
|
687
|
-
clearTimeout(timeout);
|
|
688
|
-
resolve({ exit_code: exitCode, stdout, stderr, error: error ?? (signal ? `terminated by ${signal}` : null) });
|
|
689
|
-
});
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
function readReadiness(file) {
|
|
693
|
-
try {
|
|
694
|
-
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
695
|
-
}
|
|
696
|
-
catch {
|
|
697
|
-
return null;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
672
|
function requireHome(run) {
|
|
701
673
|
if (!run.run_root)
|
|
702
674
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -220,7 +221,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
220
221
|
throw new AppError("merge_semantic_blocked", "A blocked semantic result cannot complete MERGE", 2, { result_path: resultPath });
|
|
221
222
|
if (request.checkpoint === "apply_recorded") {
|
|
222
223
|
input.progress?.("bootstrapping integrated target");
|
|
223
|
-
runBootstrap(run, request.target_workspace);
|
|
224
|
+
await runBootstrap(context, run, request.target_workspace, request.merge_request_id, input.progress);
|
|
224
225
|
context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
|
|
225
226
|
request = requireRequest(context, request.merge_request_id);
|
|
226
227
|
}
|
|
@@ -360,9 +361,14 @@ function protocolIds(home) { const root = path.join(home, "03-plan"); if (!fs.ex
|
|
|
360
361
|
function workspaceRoute(home) { const file = path.join(home, "02-protocolize", "workspace-route.json"); const value = JSON.parse(fs.readFileSync(file, "utf8")); if (!value.policy?.integration_branch)
|
|
361
362
|
throw new AppError("workspace_route_missing", "MERGE requires the frozen integration branch", 1, { file }); return { integration_branch: value.policy.integration_branch }; }
|
|
362
363
|
function executionSettings(run) { return JSON.parse(run.index_json).execution_profile?.settings ?? {}; }
|
|
363
|
-
function runBootstrap(run, cwd
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
async function runBootstrap(context, run, cwd, mergeId, progress) {
|
|
365
|
+
const command = executionSettings(run).code_bootstrap?.command;
|
|
366
|
+
if (!command)
|
|
367
|
+
throw new AppError("execution_profile_invalid", "MERGE requires a frozen workspace bootstrap command", 2);
|
|
368
|
+
const result = await runWorkspaceBootstrap(context, { projectId: run.project_id, runId: run.id, runHome: requireHome(run), workspaceRoot: cwd, command, artifactDir: `${stageDir}/${mergeId}/readiness`, progress });
|
|
369
|
+
if (result.exit_code !== 0 || result.error)
|
|
370
|
+
throw new AppError("merge_bootstrap_failed", "Integrated target bootstrap failed; inspect its retained logs", 2, { command, ...result });
|
|
371
|
+
}
|
|
366
372
|
function verifyLocalDelivery(request) { const head = gitValue(request.target_workspace, ["rev-parse", request.target_branch]); if (!request.integration_commit || head !== request.integration_commit)
|
|
367
373
|
throw new AppError("merge_delivery_unproven", "Local integration branch does not resolve to the accepted integration commit", 1, { target_branch: request.target_branch, expected: request.integration_commit, actual: head }); }
|
|
368
374
|
function cleanupReceiptPath(run) { return path.join(requireHome(run), stageDir, "cleanup-receipt.json"); }
|
|
@@ -464,10 +464,10 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
|
|
|
464
464
|
if (unchangedDocuments.length)
|
|
465
465
|
throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
|
|
466
466
|
const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
|
|
467
|
+
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
467
468
|
if (assigned.length) {
|
|
468
469
|
if ((result.changed_paths?.length ?? 0) === 0)
|
|
469
470
|
throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
|
|
470
|
-
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
471
471
|
for (const resolution of result.resolutions ?? [])
|
|
472
472
|
for (const ref of resolution.evidence_refs ?? [])
|
|
473
473
|
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
@@ -491,28 +491,36 @@ function renderWorkerPrompt(context, work, run, dependencies) {
|
|
|
491
491
|
const completionRepair = mergeWork
|
|
492
492
|
? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
|
|
493
493
|
: ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output."];
|
|
494
|
-
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies
|
|
494
|
+
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies materialization at Work finish. Execution occurs at its declared run_at gate, not necessarily in this Work.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
|
|
495
495
|
if (packet)
|
|
496
496
|
codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
|
|
497
|
-
|
|
497
|
+
if (packet)
|
|
498
|
+
codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
|
|
499
|
+
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
|
|
498
500
|
}
|
|
499
|
-
function resultSchemaGuidance(work, runId) {
|
|
501
|
+
export function resultSchemaGuidance(work, runId) {
|
|
500
502
|
const schema = work.result_schema;
|
|
501
503
|
const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
|
|
502
504
|
if (schema === "dd-flow/code-work-result@3") {
|
|
503
505
|
const packet = codePacket(work);
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
506
|
+
const obligations = [...new Set([
|
|
507
|
+
...(packet?.acceptance ?? []).map((item) => item.criterion_id).filter((id) => Boolean(id)),
|
|
508
|
+
...(packet?.checks ?? []).map((item) => item.id),
|
|
509
|
+
...(packet?.repair?.review_findings ?? []).flatMap((finding) => finding.obligation_refs ?? [])
|
|
510
|
+
])];
|
|
511
|
+
const findings = packet?.repair?.review_findings ?? [];
|
|
512
|
+
return [refs, `Assigned obligation refs: ${JSON.stringify(obligations)}. Cite only these in evidence.obligation_ref.`,
|
|
513
|
+
"This is a shape template, not a completed result. Populate changed_paths and evidence from the actual work and existing files; empty arrays below do not establish completion.",
|
|
514
|
+
"For a gate repair, cite the assigned check id. For a review repair, replace each resolution summary and fill evidence_refs with existing evidence proving its required outcome. Ordinary Work has resolutions: [].",
|
|
515
|
+
"```json", JSON.stringify({ schema_id: schema, summary: "Describe the actual implementation.", changed_paths: [], evidence: [], deviations: [], blockers: [], resolutions: findings.map((finding) => ({ finding_ref: finding.finding_ref, summary: "Describe how the assigned required outcome was achieved.", evidence_refs: [] })) }, null, 2), "```",
|
|
516
|
+
"Each evidence entry has exactly obligation_ref (one assigned ref) and refs (nonempty list of existing evidence paths). Each resolution has finding_ref, summary and nonempty evidence_refs. Do not invent file names or finding ids."];
|
|
509
517
|
}
|
|
510
518
|
if (schema === "dd-flow/code-review-result@1") {
|
|
511
|
-
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "
|
|
519
|
+
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: [] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
|
|
512
520
|
}
|
|
513
521
|
if (schema !== "dd-flow/plan-review-result@1")
|
|
514
522
|
return [];
|
|
515
|
-
return [refs, "
|
|
523
|
+
return [refs, "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: [], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
|
|
516
524
|
}
|
|
517
525
|
export function validateCodeReviewResultIdentity(work, value) {
|
|
518
526
|
const group = codeReviewGroup(work);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
|
|
6
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
|
|
7
|
+
/** CODE and MERGE prepare their own checkout through the same managed executor.
|
|
8
|
+
* Bootstrap is a project operation, not an alias classified by the engine as a test. */
|
|
9
|
+
export async function runWorkspaceBootstrap(context, input) {
|
|
10
|
+
const operationId = `bootstrap:${crypto.createHash("sha256").update(JSON.stringify([input.runId, input.workspaceRoot, input.artifactDir, input.command])).digest("hex")}`;
|
|
11
|
+
const pending = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
|
|
12
|
+
if (pending) {
|
|
13
|
+
if (processTreeIsAlive(pending) || (pending.state === "starting" && managedOwnerIsAlive(pending) !== false)) {
|
|
14
|
+
throw new AppError("bootstrap_in_progress", "Workspace preparation is still running; inspect its logs and wait for the existing process, do not launch another copy", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
|
|
15
|
+
}
|
|
16
|
+
const completionPath = path.join(path.dirname(pending.stdout_path), "completion.json");
|
|
17
|
+
let completion = null;
|
|
18
|
+
try {
|
|
19
|
+
completion = JSON.parse(fs.readFileSync(completionPath, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch { /* Interrupted shell has no terminal marker. */ }
|
|
22
|
+
const exitCode = typeof completion?.exit_code === "number" ? completion.exit_code : null;
|
|
23
|
+
const error = exitCode === null ? "bootstrap ended without a completion marker; inspect retained logs before retrying" : null;
|
|
24
|
+
finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
|
|
25
|
+
return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
|
|
26
|
+
}
|
|
27
|
+
const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
|
|
28
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
29
|
+
const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
|
|
30
|
+
const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: operationId, ownerPid: process.pid, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
|
|
31
|
+
const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
|
|
32
|
+
try {
|
|
33
|
+
input.progress?.(`workspace bootstrap started: ${input.command}; logs: ${directory}`);
|
|
34
|
+
const completed = await runCheck(input.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_CHECK_COMPLETION_FILE: completionPath }, stdout, stderr, elapsed => input.progress?.(`workspace bootstrap still running (${elapsed}s); logs: ${directory}; next report in 15s`), pid => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
35
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
36
|
+
if (processTreeIsAlive(current))
|
|
37
|
+
throw new AppError("bootstrap_tree_not_settled", "Bootstrap shell exited but still owns live processes; resources were retained for inspection", 2, { process_id: managed.id, stdout_path: stdoutPath, stderr_path: stderrPath });
|
|
38
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: completed.exitCode === 0 && !completed.error ? "stopped" : "failed", reason: completed.error });
|
|
39
|
+
return result(managed.id, stdoutPath, stderrPath, completionPath, completed.exitCode, completed.error);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
fs.closeSync(stdout);
|
|
43
|
+
fs.closeSync(stderr);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function result(id, stdout, stderr, completion, exitCode, error) {
|
|
47
|
+
// Full logs remain on disk; keep the returned prompt bounded.
|
|
48
|
+
const excerpt = (file) => fs.existsSync(file) ? fs.readFileSync(file, "utf8").slice(-64_000) : "";
|
|
49
|
+
return { exit_code: exitCode, stdout: excerpt(stdout), stderr: excerpt(stderr), error, process_id: id, stdout_path: stdout, stderr_path: stderr, completion_path: completion };
|
|
50
|
+
}
|