@deksden-com/dd-flow-cli 0.9.0-beta.14 → 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 +6 -0
- package/README.md +13 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +10 -5
- package/dist/services/managed-processes.js +52 -12
- package/dist/services/runs.js +1 -1
- package/dist/services/runtime-service.js +92 -0
- package/dist/services/work-registry.js +2 -0
- package/dist/services/workspace-bootstrap.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.9.0-beta.14
|
|
4
10
|
|
|
5
11
|
### 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,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.15",
|
|
4
|
+
"cli_commit": "e11a611fe87370d6203792e5c6a54d7205235a26",
|
|
5
|
+
"built_at": "2026-09-05T09:50:59.828Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.0.4",
|
|
8
8
|
"commit": "c8cae271f844fd3b3c7492f164e13a60eaba4839",
|
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,10 +1584,14 @@ 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, {
|
|
@@ -1618,7 +1623,7 @@ async function dispatchRuntime(context, command, parsed) {
|
|
|
1618
1623
|
return { ok: true, processes: managedProcessStatus(context) };
|
|
1619
1624
|
if (action === "reconcile")
|
|
1620
1625
|
return { ok: true, processes: await reconcileExpiredManagedProcesses(context, requiredOption(parsed, "owner"), optionalPositiveNumber(parsed, "grace-ms", true)) };
|
|
1621
|
-
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);
|
|
1622
1627
|
}
|
|
1623
1628
|
function dispatchStat(context, command, parsed) {
|
|
1624
1629
|
if (command === "usage") {
|
|
@@ -7,6 +7,26 @@ 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()}`;
|
|
@@ -40,6 +60,9 @@ export function heartbeatManagedProcess(context, input) {
|
|
|
40
60
|
}
|
|
41
61
|
export function finishManagedProcess(context, input) {
|
|
42
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;
|
|
43
66
|
const now = context.now();
|
|
44
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;
|
|
45
68
|
if (updated)
|
|
@@ -67,8 +90,8 @@ export function processTreeIsAlive(record) {
|
|
|
67
90
|
process.kill(-group, 0);
|
|
68
91
|
return true;
|
|
69
92
|
}
|
|
70
|
-
catch {
|
|
71
|
-
return
|
|
93
|
+
catch (error) {
|
|
94
|
+
return error.code !== "ESRCH";
|
|
72
95
|
}
|
|
73
96
|
}
|
|
74
97
|
return false;
|
|
@@ -111,6 +134,26 @@ export function claimExpiredManagedProcesses(context, ownerId) {
|
|
|
111
134
|
export function managedProcessStatus(context) {
|
|
112
135
|
return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
|
|
113
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
|
+
}
|
|
114
157
|
/** Reconcile only records the system owns and has atomically claimed. */
|
|
115
158
|
export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
|
|
116
159
|
const claimed = claimExpiredManagedProcesses(context, ownerId);
|
|
@@ -121,18 +164,15 @@ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs
|
|
|
121
164
|
outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
|
|
122
165
|
continue;
|
|
123
166
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
terminateOwnedProcess(processRecord, "SIGKILL");
|
|
128
|
-
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" });
|
|
129
170
|
}
|
|
130
|
-
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== "process_tree_not_settled")
|
|
173
|
+
throw error;
|
|
131
174
|
outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
|
|
132
|
-
continue;
|
|
133
175
|
}
|
|
134
|
-
finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
|
|
135
|
-
outcomes.push({ id: processRecord.id, outcome: "stopped" });
|
|
136
176
|
}
|
|
137
177
|
return outcomes;
|
|
138
178
|
}
|
|
@@ -172,7 +212,7 @@ function releasePortClaims(db, claims) { for (const claim of claims)
|
|
|
172
212
|
function terminateOwnedProcess(record, signal) {
|
|
173
213
|
// Never signal a group merely because its former leader PID was once ours.
|
|
174
214
|
// PID reuse or a departed leader makes group ownership unprovable.
|
|
175
|
-
if (!record.pid || !record.pid_started_at ||
|
|
215
|
+
if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
|
|
176
216
|
return;
|
|
177
217
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
178
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
|
+
}
|
|
@@ -494,6 +494,8 @@ function renderWorkerPrompt(context, work, run, dependencies) {
|
|
|
494
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
|
+
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>", "");
|
|
497
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
501
|
export function resultSchemaGuidance(work, runId) {
|
|
@@ -27,7 +27,7 @@ export async function runWorkspaceBootstrap(context, input) {
|
|
|
27
27
|
const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
|
|
28
28
|
fs.mkdirSync(directory, { recursive: true });
|
|
29
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, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
|
|
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
31
|
const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
|
|
32
32
|
try {
|
|
33
33
|
input.progress?.(`workspace bootstrap started: ${input.command}; logs: ${directory}`);
|