@nodii/dev 0.6.39 → 0.6.41
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/README.md +26 -1
- package/dist/cli.js +12 -4
- package/dist/core.js +1 -1
- package/dist/freeze.js +4 -1
- package/dist/lane-supervisor.js +222 -0
- package/dist/lanes.js +46 -5
- package/dist/runtime-launcher.js +83 -47
- package/dist/starting.js +11 -3
- package/dist/watchdog.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -417,6 +417,9 @@ without ending their run, `/tmp/cc-socks/<pid>.sock` sockets whose process is
|
|
|
417
417
|
gone, and watched worktrees whose HEAD has commits absent from its configured
|
|
418
418
|
remote branch. Each observation is posted to the watched run by the machine
|
|
419
419
|
coordinator; `process_dead` transitions it to explicit `orphan` immediately.
|
|
420
|
+
A sweep leaves a dead lane runtime alone while its lane supervisor still lives
|
|
421
|
+
(the supervisor reports the exit itself) and skips it once the supervisor has
|
|
422
|
+
reported.
|
|
420
423
|
Run launchers and wrappers persist one owner-only record per process under
|
|
421
424
|
`~/.config/nodii-dev/watchdog/runs/`; the plist itself contains only absolute
|
|
422
425
|
command/config paths, its schedule, and log paths, while OAuth credentials
|
|
@@ -916,6 +919,22 @@ The launched process uses the already-provisioned lowercase identity (for
|
|
|
916
919
|
example `~/.config/nodii-dev/agents/lane-ah-12.json`), never the coordinator's
|
|
917
920
|
Dev identity.
|
|
918
921
|
|
|
922
|
+
A launched or resumed runtime runs under a detached lane supervisor (an
|
|
923
|
+
internal `nodii-dev __lane-supervisor` process), which sees its exit code and
|
|
924
|
+
reports it to Dev as the lane's identity (NODII-DEV-CLI-1). It first reads the
|
|
925
|
+
lane's status. A lane whose done check is recorded (its `dev_check` step done,
|
|
926
|
+
or the lane `on_dev`, `non_issue` or `stopped`) has finished. On it, exit 0 with a clean worktree ends the run
|
|
927
|
+
`done`, exit 0 with uncommitted or untracked files ends it `handed_off` and names
|
|
928
|
+
them, and any other exit ends it `failed`. None of these counts as a death. A
|
|
929
|
+
process that stops before the done check is recorded is unfinished, whatever
|
|
930
|
+
its exit code (a lane that exits 0 after Gather included). It is reported as a
|
|
931
|
+
`process-exit` with its code, fingerprint (`lane-unfinished-exit-0`,
|
|
932
|
+
`lane-exit-N` or `lane-signal-S`) and redacted log tail. Dev orphans that run,
|
|
933
|
+
so `lane resume` relaunches it with `recoveryOf`. A lane status Dev can't serve
|
|
934
|
+
counts as unfinished. Every report carries `exit_code`, `worktree` and
|
|
935
|
+
`lane_status` evidence. `process.pid` still holds the runtime's own PID, and the
|
|
936
|
+
supervisor appends its report line to `process.log`.
|
|
937
|
+
|
|
919
938
|
A lane's Figma scope is held by published key only (MW8-20): `--figma
|
|
920
939
|
FILE_KEY` for a whole file, or `--figma FILE_KEY=COMPONENT_SET_KEY[,KEY]` for
|
|
921
940
|
its component sets. A node id is refused. `--route /app/...` (repeatable)
|
|
@@ -938,7 +957,13 @@ messages. Every CLI call from a launched worktree checks the current watched
|
|
|
938
957
|
run's acknowledgement version. A pending change is printed and blocks the
|
|
939
958
|
call until `lane gather --delta` serves the current rules and acknowledges the
|
|
940
959
|
newest displayed version. `NODII_DEV_RUN_ID` makes this check per subagent run,
|
|
941
|
-
so one child cannot acknowledge for its siblings.
|
|
960
|
+
so one child cannot acknowledge for its siblings. Help and version
|
|
961
|
+
(`--help`, `-h`, `help`, `--version`) skip the check, so a blocked lane can
|
|
962
|
+
still read how to unblock itself. With the agent picker, `lane gather --delta`
|
|
963
|
+
first prints a Trigger shortlist and acknowledges nothing; its `--json` names
|
|
964
|
+
the open amendment and carries the record command as `data.next.command`
|
|
965
|
+
(`--picker agent --shortlist ID --documents CODE[,CODE] --delta`). Running that
|
|
966
|
+
records the pick and acknowledges the amendment (AGENT-HARNESS-14).
|
|
942
967
|
|
|
943
968
|
Gather also persists the lane's bounded Freeze: the objective and done check,
|
|
944
969
|
strict repository/Figma boundaries, the current pinned clauses, and the lane's
|
package/dist/cli.js
CHANGED
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
showWaitingResults,
|
|
96
96
|
superviseRun,
|
|
97
97
|
} from "./runs.js";
|
|
98
|
+
import { superviseLane } from "./lane-supervisor.js";
|
|
98
99
|
import { awsCli, superviseRemoteRun } from "./remote.js";
|
|
99
100
|
|
|
100
101
|
const HELP = `nodii-dev ${VERSION}
|
|
@@ -199,7 +200,9 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
199
200
|
lane gather [<key>] --picker agent [--shortlist ID --documents CODE[,CODE]]
|
|
200
201
|
# the lane agent picks instead of local Gemma: alone it prints the
|
|
201
202
|
# recorded trigger shortlist; with --shortlist/--documents it records
|
|
202
|
-
# the pick (picker: agent) and the Clause map
|
|
203
|
+
# the pick (picker: agent) and the Clause map; --json carries that
|
|
204
|
+
# record command as data.next.command, and with --delta it also
|
|
205
|
+
# acknowledges a pending amendment (help stays readable meanwhile)
|
|
203
206
|
lane gather show [<key>] [--gather ID] # the lane's Clause map (the Freeze references it, not its clauses)
|
|
204
207
|
run [--kind build|test_run|subagent|deploy] [--name TEXT] [--artifact PATH] -- <command> [args...]
|
|
205
208
|
# watched command: registers with NODII_DEV_RUN_ID,
|
|
@@ -496,12 +499,17 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
|
|
|
496
499
|
}
|
|
497
500
|
}
|
|
498
501
|
|
|
502
|
+
// Help and version only print text, so they stay readable while an amendment
|
|
503
|
+
// blocks the lane: a blocked lane needs them to unblock itself (AGENT-HARNESS-14).
|
|
504
|
+
const readsHelp = (argv) => argv[0] === "help" || argv.some((token) => ["--help", "-h"].includes(token)) || (argv.length === 1 && ["--version", "-v"].includes(argv[0]));
|
|
505
|
+
|
|
499
506
|
export async function main(argv = process.argv.slice(2)) {
|
|
500
|
-
//
|
|
501
|
-
|
|
507
|
+
// A lane's own exit reporter runs after its runtime ends (NODII-DEV-CLI-1).
|
|
508
|
+
if (argv[0] === "__lane-supervisor") return superviseLane();
|
|
509
|
+
// Amendment acknowledgement is a lane-wide backstop for every other command.
|
|
502
510
|
// A nudge hook is fail-open and writes only its own JSON: it cannot run a
|
|
503
511
|
// Delta gather, and a waiting-results banner would corrupt its output.
|
|
504
|
-
if (argv[0] !== "__run-supervisor" && !(argv[0] === "hook" && argv[1]?.startsWith("nudge-"))) {
|
|
512
|
+
if (argv[0] !== "__run-supervisor" && !readsHelp(argv) && !(argv[0] === "hook" && argv[1]?.startsWith("nudge-"))) {
|
|
505
513
|
await amendmentGuard(argv);
|
|
506
514
|
await showWaitingResults({ acknowledge: argv[0] !== "hook" });
|
|
507
515
|
}
|
package/dist/core.js
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const VERSION = "0.6.
|
|
7
|
+
export const VERSION = "0.6.41";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
package/dist/freeze.js
CHANGED
|
@@ -21,6 +21,7 @@ export function shouldReground(argv) {
|
|
|
21
21
|
"-h",
|
|
22
22
|
"help",
|
|
23
23
|
"hook",
|
|
24
|
+
"__lane-supervisor",
|
|
24
25
|
"login",
|
|
25
26
|
"logout",
|
|
26
27
|
].includes(command),
|
|
@@ -109,6 +110,7 @@ export const GATHER_LOOKUP_IDS = ["figma-guidelines"];
|
|
|
109
110
|
/** Build the stored contract; Delta Gather keeps lane-authored sections. */
|
|
110
111
|
export function freezeFromGather({
|
|
111
112
|
gather,
|
|
113
|
+
ask,
|
|
112
114
|
boundaries,
|
|
113
115
|
issue,
|
|
114
116
|
laneNotes,
|
|
@@ -131,7 +133,8 @@ export function freezeFromGather({
|
|
|
131
133
|
);
|
|
132
134
|
return {
|
|
133
135
|
objective: {
|
|
134
|
-
ask
|
|
136
|
+
// Dev's Lane contract names the ask; without one, Gather's objective.
|
|
137
|
+
ask: ask ?? gather.objective,
|
|
135
138
|
issue: issue === undefined ? (current?.objective?.issue ?? null) : issue,
|
|
136
139
|
laneNotes:
|
|
137
140
|
laneNotes === undefined
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { CliError, devRequest, encode } from "./core.js";
|
|
6
|
+
import { redactRunText } from "./runs.js";
|
|
7
|
+
import { saveWatchedRun, watchdogRunDir } from "./watchdog.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* NODII-DEV-CLI-1: a launched or resumed lane's runtime is the child of this
|
|
11
|
+
* supervisor, so its exit code is known. The supervisor reports it to Dev
|
|
12
|
+
* with the worktree's clean/dirty state and the lane's status. A lane whose
|
|
13
|
+
* done check is recorded (its dev_check step done, or the lane on_dev,
|
|
14
|
+
* non_issue or stopped) has finished: its run
|
|
15
|
+
* ends, never as a death. A process that stops before that is unfinished,
|
|
16
|
+
* whatever its exit code: it is reported as a process exit, which Dev orphans
|
|
17
|
+
* so `lane resume` relaunches it (hub-58 scope add). Before this, the
|
|
18
|
+
* watchdog could only see a vanished PID and counted every exit as
|
|
19
|
+
* process_dead.
|
|
20
|
+
*
|
|
21
|
+
* Launcher protocol (IPC): launch -> spawned {pid} -> start {prompt} ->
|
|
22
|
+
* started; or abort, when the launcher could not record the runtime's PID.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const execFile = promisify(execFileCallback);
|
|
26
|
+
const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
27
|
+
const LOG_TAIL_BYTES = 8 * 1024;
|
|
28
|
+
// Dev's terminal lane statuses: verified on dev (lane verify), or closed.
|
|
29
|
+
const FINISHED_LANE = new Set(["on_dev", "non_issue", "stopped"]);
|
|
30
|
+
|
|
31
|
+
/** Each worktree's uncommitted and untracked paths; null when git cannot read it. */
|
|
32
|
+
export async function worktreeState(worktrees, { execute = execFile } = {}) {
|
|
33
|
+
const dirty = [];
|
|
34
|
+
const unreadable = [];
|
|
35
|
+
for (const worktree of worktrees) {
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execute("git", ["-C", worktree, "status", "--porcelain"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
38
|
+
for (const line of stdout.split("\n").filter(Boolean)) dirty.push(`${worktree}: ${line.trim()}`);
|
|
39
|
+
} catch {
|
|
40
|
+
unreadable.push(worktree);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { state: unreadable.length ? "unknown" : dirty.length ? "dirty" : "clean", dirty, unreadable };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readTail(path, bytes = LOG_TAIL_BYTES) {
|
|
47
|
+
if (!path) return "";
|
|
48
|
+
const handle = await open(path, "r");
|
|
49
|
+
try {
|
|
50
|
+
const { size } = await handle.stat();
|
|
51
|
+
const length = Math.min(size, bytes);
|
|
52
|
+
const buffer = Buffer.alloc(length);
|
|
53
|
+
await handle.read(buffer, 0, length, size - length);
|
|
54
|
+
return buffer.toString("utf8");
|
|
55
|
+
} finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const describeWorktree = ({ state, dirty, unreadable }) => [
|
|
61
|
+
...dirty.slice(0, 50),
|
|
62
|
+
...(dirty.length > 50 ? [`... ${dirty.length - 50} more`] : []),
|
|
63
|
+
...unreadable.map((worktree) => `${worktree}: git status failed`),
|
|
64
|
+
].join("\n") || state;
|
|
65
|
+
|
|
66
|
+
async function send(request, path, body, attempts = 3) {
|
|
67
|
+
for (let attempt = 1; ; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
return await request("POST", path, body);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// Dev's answer is final; only a transport failure is retried.
|
|
72
|
+
if (error instanceof CliError || attempt >= attempts) throw error;
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The lane's Dev status and whether its done check is recorded: a dev_check
|
|
80
|
+
* step done, or a terminal lane. Unreadable reads as unfinished.
|
|
81
|
+
*/
|
|
82
|
+
async function laneProgress(request, laneKey) {
|
|
83
|
+
try {
|
|
84
|
+
const lane = (await request("GET", `/api/v1/lanes/${encode(laneKey)}`)).data;
|
|
85
|
+
const devChecked = (lane?.steps ?? []).some((step) => step.stepKey === "dev_check" && step.state === "done");
|
|
86
|
+
return { status: lane?.status ?? null, finished: devChecked || FINISHED_LANE.has(lane?.status) };
|
|
87
|
+
} catch {
|
|
88
|
+
return { status: null, finished: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Report one lane process exit. On a finished lane, exit 0 with a clean
|
|
94
|
+
* worktree ends the run done, exit 0 with uncommitted work hands it off
|
|
95
|
+
* naming that work, and any other exit ends it failed. On an unfinished lane
|
|
96
|
+
* every exit, exit 0 included, is a process exit: Dev orphans the run and
|
|
97
|
+
* routes it for recovery (`lane resume`).
|
|
98
|
+
*/
|
|
99
|
+
export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktrees, logPath }, {
|
|
100
|
+
request = devRequest,
|
|
101
|
+
inspect = worktreeState,
|
|
102
|
+
tail = readTail,
|
|
103
|
+
markTerminal = markWatchedTerminal,
|
|
104
|
+
} = {}) {
|
|
105
|
+
const worktree = await inspect(worktrees);
|
|
106
|
+
const { status, finished } = await laneProgress(request, laneKey);
|
|
107
|
+
const clean = exitCode === 0 && !signal;
|
|
108
|
+
const exitRef = signal ? `signal ${signal}` : String(exitCode ?? -1);
|
|
109
|
+
const exited = signal ? `was ended by ${signal}` : `exited with code ${exitCode ?? -1}`;
|
|
110
|
+
const evidence = [
|
|
111
|
+
{ kind: "exit_code", ref: exitRef },
|
|
112
|
+
{ kind: "worktree", ref: worktree.state, note: describeWorktree(worktree) },
|
|
113
|
+
{ kind: "lane_status", ref: status ?? "unknown" },
|
|
114
|
+
];
|
|
115
|
+
let outcome;
|
|
116
|
+
let body;
|
|
117
|
+
if (finished && clean) {
|
|
118
|
+
outcome = worktree.state === "clean" ? "done" : "handed_off";
|
|
119
|
+
body = outcome === "done"
|
|
120
|
+
? { status: "done", evidence }
|
|
121
|
+
: { status: "handed_off", handoff: `The lane process exited 0 and left its worktree ${worktree.state}:\n${describeWorktree(worktree)}`, evidence };
|
|
122
|
+
} else if (finished) {
|
|
123
|
+
// A finished lane has nothing to resume, so a failed exit is not a death.
|
|
124
|
+
outcome = "failed";
|
|
125
|
+
body = { status: "failed", reason: `The lane process ${exited} after lane ${laneKey} reached ${status}.`, evidence };
|
|
126
|
+
} else {
|
|
127
|
+
outcome = "process_exit";
|
|
128
|
+
const logTail = redactRunText(await tail(logPath).catch(() => "")).trim();
|
|
129
|
+
body = {
|
|
130
|
+
reason: `The lane process ${exited} before lane ${laneKey}'s done check was recorded (lane ${status ?? "status unknown"}), so the run is unfinished; its worktree is ${worktree.state}.`,
|
|
131
|
+
deathFingerprint: signal ? `lane-signal-${signal}` : clean ? "lane-unfinished-exit-0" : `lane-exit-${exitCode ?? -1}`,
|
|
132
|
+
exitCode: Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255 ? exitCode : -1,
|
|
133
|
+
...(logTail ? { logTail: logTail.slice(-50_000) } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await send(request, `/api/v1/runs/${encode(runId)}/${outcome === "process_exit" ? "process-exit" : "end"}`, body);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// The agent may have ended its own run already; that is not a failure.
|
|
140
|
+
if (error?.code !== "RUN_ALREADY_TERMINAL") throw error;
|
|
141
|
+
outcome = "already_terminal";
|
|
142
|
+
}
|
|
143
|
+
await markTerminal(runId).catch(() => {});
|
|
144
|
+
return { outcome, exitCode: exitCode ?? null, signal: signal ?? null, worktree: worktree.state, laneStatus: status };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The sweep skips a run whose exit was reported (it would otherwise report process_dead). */
|
|
148
|
+
export async function markWatchedTerminal(runId, { home } = {}) {
|
|
149
|
+
const registryPath = join(watchdogRunDir(home), `${runId}.json`);
|
|
150
|
+
const handle = await open(registryPath, "r");
|
|
151
|
+
let record;
|
|
152
|
+
try { record = JSON.parse(await handle.readFile("utf8")); }
|
|
153
|
+
finally { await handle.close(); }
|
|
154
|
+
if (record.terminalSeenAt || record.orphanReportedAt) return;
|
|
155
|
+
await saveWatchedRun({ ...record, terminalSeenAt: new Date().toISOString(), registryPath });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The supervisor process: spawn the runtime, hand its PID back, report its exit. */
|
|
159
|
+
export async function superviseLane({
|
|
160
|
+
channel = process,
|
|
161
|
+
spawn = nodeSpawn,
|
|
162
|
+
report = reportLaneExit,
|
|
163
|
+
exit = (code) => process.exit(code),
|
|
164
|
+
} = {}) {
|
|
165
|
+
if (typeof channel.send !== "function")
|
|
166
|
+
throw new CliError("USAGE", "__lane-supervisor is started by lane launch and lane resume only.", 2);
|
|
167
|
+
const launch = await new Promise((resolve) => channel.once("message", resolve));
|
|
168
|
+
if (launch?.type !== "launch") return exit(2);
|
|
169
|
+
const { invocation, runId, laneKey, worktrees, logPath } = launch;
|
|
170
|
+
// The launcher may already be gone; a reply it cannot read is dropped.
|
|
171
|
+
const tell = (message) => {
|
|
172
|
+
try { if (channel.connected !== false) channel.send(message); } catch {}
|
|
173
|
+
};
|
|
174
|
+
let child;
|
|
175
|
+
try {
|
|
176
|
+
child = spawn(invocation.command, invocation.args, { cwd: invocation.cwd, env: invocation.env, shell: false, stdio: ["pipe", "inherit", "inherit"] });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
tell({ type: "error", message: error.message });
|
|
179
|
+
return exit(1);
|
|
180
|
+
}
|
|
181
|
+
let state = "spawning";
|
|
182
|
+
const closed = new Promise((resolve) => child.once("close", (exitCode, signal) => resolve({ exitCode, signal })));
|
|
183
|
+
child.once("error", (error) => {
|
|
184
|
+
if (state !== "spawning") return;
|
|
185
|
+
state = "failed";
|
|
186
|
+
tell({ type: "error", message: error.message });
|
|
187
|
+
});
|
|
188
|
+
child.once("spawn", () => {
|
|
189
|
+
if (state !== "spawning") return;
|
|
190
|
+
state = "waiting";
|
|
191
|
+
tell({ type: "spawned", pid: child.pid });
|
|
192
|
+
});
|
|
193
|
+
channel.on("message", (message) => {
|
|
194
|
+
if (message?.type === "start" && state === "waiting") {
|
|
195
|
+
state = "running";
|
|
196
|
+
child.stdin.end(message.prompt);
|
|
197
|
+
tell({ type: "started" });
|
|
198
|
+
} else if (message?.type === "abort" && state !== "running") {
|
|
199
|
+
state = "aborted";
|
|
200
|
+
child.kill("SIGTERM");
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
// A launcher that went away before start never handed over a prompt.
|
|
204
|
+
channel.once("disconnect", () => {
|
|
205
|
+
if (state === "waiting" || state === "spawning") {
|
|
206
|
+
state = "aborted";
|
|
207
|
+
child.kill("SIGTERM");
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
// A signal sent to the supervisor ends the runtime too, and is reported.
|
|
211
|
+
for (const name of SIGNALS) process.on(name, () => child.kill(name));
|
|
212
|
+
const { exitCode, signal } = await closed;
|
|
213
|
+
if (state !== "running") return exit(state === "aborted" ? 0 : 1);
|
|
214
|
+
try {
|
|
215
|
+
const result = await report({ runId, laneKey, exitCode, signal, worktrees, logPath });
|
|
216
|
+
process.stderr.write(`nodii-dev: lane process exit reported to Dev (${result.outcome}, exit ${signal ?? exitCode}, lane ${result.laneStatus ?? "status unknown"}, worktree ${result.worktree}).\n`);
|
|
217
|
+
return exit(0);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
process.stderr.write(`nodii-dev: could not report the lane process exit to Dev (${error?.code ?? error?.message ?? error}); the watchdog reports it as process_dead.\n`);
|
|
220
|
+
return exit(1);
|
|
221
|
+
}
|
|
222
|
+
}
|
package/dist/lanes.js
CHANGED
|
@@ -98,7 +98,31 @@ const agentFollowUp = (ref, shortlistId, flags) => [
|
|
|
98
98
|
.filter((flag) => typeof flags[flag] === "string")
|
|
99
99
|
.map((flag) => `--${flag} ${shellQuote(flags[flag])}`),
|
|
100
100
|
].join(" ");
|
|
101
|
+
// The record step, in --json too: an agent reads the payload, not the text
|
|
102
|
+
// (AGENT-HARNESS-14). With --delta it also acknowledges a pending amendment.
|
|
103
|
+
const agentNext = (ref, shortlistId, triggers, flags) => ({
|
|
104
|
+
command: agentFollowUp(ref, shortlistId, flags),
|
|
105
|
+
fill: "--documents: the codes of every document that applies, from triggers; forced ones are added for you.",
|
|
106
|
+
forced: triggers.filter((trigger) => trigger.forcedBy?.length).map((trigger) => trigger.code),
|
|
107
|
+
acknowledgesAmendment: Boolean(flags.delta),
|
|
108
|
+
});
|
|
109
|
+
// Dev's Lane contract (hub-58): objective, issue body, the coordinator's lane
|
|
110
|
+
// notes, done check and boundaries, printed before anything else.
|
|
111
|
+
const contractLines = (data) => (data.laneContract ? ["Lane contract", data.laneContract, ""] : []);
|
|
112
|
+
async function laneContract(id, request, options) {
|
|
113
|
+
try {
|
|
114
|
+
return (await request("GET", `/api/v1/lanes/${encode(id)}/contract`, undefined, options)).data?.text ?? null;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// A Dev from before the Lane contract route: say so, never hide it.
|
|
117
|
+
if (error?.code === "HTTP_404" || error?.code === "NOT_FOUND") return "(this Dev does not serve the Lane contract yet; read the lane with `nodii-dev lane show`)";
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Gather never ends on a bare "finished" (hub-58): the next step is the task.
|
|
122
|
+
const nextStepText = (ref, doneCheck) =>
|
|
123
|
+
`Gather is finished. Next: Task. Work the objective until ${doneCheck} is met, then run \`nodii-dev lane land ${ref}\` (or the lane's finish command). Don't stop before that.`;
|
|
101
124
|
const agentShortlistText = (ref, data, flags) => [
|
|
125
|
+
...contractLines(data),
|
|
102
126
|
`Trigger shortlist ${data.shortlistId} · ${JSON.stringify(data.objective)}`,
|
|
103
127
|
"You are Gather's picker (picker: agent). Nothing is recorded yet.",
|
|
104
128
|
...data.triggers.map((trigger) => ` ${trigger.code}\t${trigger.title ?? ""}: ${trigger.sentence ?? ""}${trigger.forcedBy?.length ? ` [forced by ${trigger.forcedBy.join(", ")}]` : ""}`),
|
|
@@ -387,8 +411,10 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
387
411
|
: await request("POST", "/api/v1/rules/shortlist", shortlistBody, { signal });
|
|
388
412
|
const shortlistId = shortlist.data?.shortlistId;
|
|
389
413
|
const triggers = shortlist.data?.triggers ?? [];
|
|
390
|
-
if (byAgent && !byAgent.shortlist)
|
|
391
|
-
|
|
414
|
+
if (byAgent && !byAgent.shortlist) {
|
|
415
|
+
const contract = await laneContract(id, request, { signal });
|
|
416
|
+
return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers, laneContract: contract, next: agentNext(ref, shortlistId, triggers, flags) } }, human: (p) => agentShortlistText(ref, p.data, flags) };
|
|
417
|
+
}
|
|
392
418
|
// Only the local model pick takes the per-lane pick lock; an agent pick
|
|
393
419
|
// records a choice already made (AGENT-HARNESS-19 + GATHER-NA).
|
|
394
420
|
const pickInput = {
|
|
@@ -409,7 +435,8 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
409
435
|
// No picker on this Mac: the agent picks from the same shortlist (hub-58).
|
|
410
436
|
const fallback = agentFallbackReason(error);
|
|
411
437
|
if (!fallback) throw error;
|
|
412
|
-
|
|
438
|
+
const contract = await laneContract(id, request, { signal });
|
|
439
|
+
return { payload: { data: { recorded: false, picker: "agent", fallback, shortlistId, objective, triggers, laneContract: contract, next: agentNext(ref, shortlistId, triggers, flags) } }, human: (p) => `picker: agent (${p.data.fallback})\n${agentShortlistText(ref, p.data, flags)}` };
|
|
413
440
|
}
|
|
414
441
|
}
|
|
415
442
|
const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
|
|
@@ -421,6 +448,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
421
448
|
...(sectionLimit !== undefined ? { sectionLimitPerDocument: sectionLimit } : {}),
|
|
422
449
|
shortlistId,
|
|
423
450
|
picker: byAgent ? "agent" : picked.model ?? PICKER_MODEL,
|
|
451
|
+
doneCheck,
|
|
424
452
|
}, { signal });
|
|
425
453
|
const figma = figmaScope.map((file) => ({
|
|
426
454
|
fileKey: file.fileKey,
|
|
@@ -434,7 +462,19 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
434
462
|
lane.launch?.narrowingRemainder && `Narrowing remainder: ${lane.launch.narrowingRemainder}`,
|
|
435
463
|
].filter(Boolean);
|
|
436
464
|
const freezeRequest = (method, path, body, options = {}) => request(method, path, body, { ...options, signal });
|
|
437
|
-
|
|
465
|
+
// Dev's Lane contract is what Gather printed; the Freeze stores it word for word.
|
|
466
|
+
const contract = payload.data?.contract;
|
|
467
|
+
const contractRepos = contract?.boundaries?.repos;
|
|
468
|
+
const fromContract = Boolean(contractRepos?.length && contractRepos.every((repository) => repository.repo && repository.branch && repository.worktree));
|
|
469
|
+
await storeFreeze(id, fromContract ? {
|
|
470
|
+
gather: payload.data,
|
|
471
|
+
ask: contract.objective.ask,
|
|
472
|
+
boundaries: contract.boundaries,
|
|
473
|
+
issue: contract.objective.issue,
|
|
474
|
+
laneNotes: contract.objective.laneNotes,
|
|
475
|
+
doneCheck: contract.objective.doneCheck ?? doneCheck,
|
|
476
|
+
lookups: lookup ? [lookup] : [],
|
|
477
|
+
} : {
|
|
438
478
|
gather: payload.data,
|
|
439
479
|
boundaries: { repos: repositories, ...(declaredFigma ? { figma } : {}) },
|
|
440
480
|
issue,
|
|
@@ -453,7 +493,8 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
453
493
|
// The pick's model and latency ride along in --json (morning check records them).
|
|
454
494
|
const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null, ...(picked.timing ? { timing: picked.timing, stats: picked.stats } : {}) } };
|
|
455
495
|
const timingLine = picked.timing ? `\n${pickTimingLine(picked.timing, picked.stats)}` : "";
|
|
456
|
-
|
|
496
|
+
const doneText = contract?.objective?.doneCheck ?? doneCheck;
|
|
497
|
+
return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}${timingLine}\n\n${nextStepText(ref, doneText)}` };
|
|
457
498
|
} catch (error) {
|
|
458
499
|
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather's Dev requests exceeded their ${Math.round(deadline.ms / 1000)}-second deadline (the local pick runs on its own budget); nothing was retried.`, 1);
|
|
459
500
|
throw error;
|
package/dist/runtime-launcher.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import * as fsPromises from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { configPath } from "./core.js";
|
|
6
7
|
import { recordWatchedRun } from "./watchdog.js";
|
|
7
8
|
|
|
@@ -141,12 +142,23 @@ export function recoveryStateRoot(path = configPath()) {
|
|
|
141
142
|
return join(nodiiDir, "runs");
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
// The supervisor is this package's own entrypoint (src/ in a checkout, dist/ when packed).
|
|
146
|
+
const SUPERVISOR_ENTRY = fileURLToPath(new URL("nodii-dev.js", import.meta.url));
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Spawn the runtime under a detached lane supervisor (NODII-DEV-CLI-1): the
|
|
150
|
+
* supervisor is the runtime's parent, so it sees the exit code and reports it
|
|
151
|
+
* to Dev. The recorded PID stays the runtime's own; the prompt is sent only
|
|
152
|
+
* after that PID is saved and watched.
|
|
153
|
+
*/
|
|
154
|
+
async function launchDetached({ invocation, prompt, runId, laneKey, devConfigPath, addDirs = [] }, {
|
|
145
155
|
spawnImpl,
|
|
146
156
|
fs,
|
|
147
157
|
stateRoot,
|
|
148
158
|
watch,
|
|
149
159
|
terminate,
|
|
160
|
+
execPath = process.execPath,
|
|
161
|
+
supervisorEntry = SUPERVISOR_ENTRY,
|
|
150
162
|
}) {
|
|
151
163
|
absolutePath(stateRoot, "stateRoot");
|
|
152
164
|
const runDir = join(stateRoot, runId);
|
|
@@ -162,63 +174,83 @@ async function launchDetached({ invocation, prompt, runId, laneKey, devConfigPat
|
|
|
162
174
|
throw error;
|
|
163
175
|
}
|
|
164
176
|
|
|
165
|
-
let
|
|
177
|
+
let supervisor;
|
|
166
178
|
try {
|
|
167
|
-
|
|
179
|
+
supervisor = spawnImpl(execPath, [supervisorEntry, "__lane-supervisor"], {
|
|
168
180
|
cwd: invocation.cwd,
|
|
169
181
|
env: invocation.env,
|
|
170
182
|
shell: false,
|
|
171
183
|
detached: true,
|
|
172
|
-
stdio: ["
|
|
184
|
+
stdio: ["ignore", log.fd, log.fd, "ipc"],
|
|
173
185
|
});
|
|
174
186
|
} catch (error) {
|
|
175
187
|
await closeQuietly(log);
|
|
176
188
|
throw error;
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
await closeQuietly(log);
|
|
205
|
-
child.unref();
|
|
206
|
-
resolveLaunch({
|
|
207
|
-
runId,
|
|
208
|
-
laneKey,
|
|
209
|
-
pid: child.pid,
|
|
210
|
-
logPath,
|
|
211
|
-
pidPath,
|
|
212
|
-
command: invocation.command,
|
|
213
|
-
args: invocation.args,
|
|
214
|
-
cwd: invocation.cwd,
|
|
215
|
-
});
|
|
216
|
-
})().catch(async (error) => {
|
|
217
|
-
await closeQuietly(log);
|
|
218
|
-
rejectLaunch(error);
|
|
191
|
+
// One reply from the supervisor, or its early end as an error.
|
|
192
|
+
const reply = (type) => new Promise((resolveReply, rejectReply) => {
|
|
193
|
+
const onMessage = (message) => {
|
|
194
|
+
if (message?.type === "error") return settle(() => rejectReply(new Error(message.message)));
|
|
195
|
+
if (message?.type === type) settle(() => resolveReply(message));
|
|
196
|
+
};
|
|
197
|
+
const onExit = (code, signal) => settle(() => rejectReply(new Error(`lane supervisor exited before ${type} (${signal ?? code})`)));
|
|
198
|
+
const onError = (error) => settle(() => rejectReply(error));
|
|
199
|
+
const settle = (done) => {
|
|
200
|
+
supervisor.off("message", onMessage);
|
|
201
|
+
supervisor.off("exit", onExit);
|
|
202
|
+
supervisor.off("error", onError);
|
|
203
|
+
done();
|
|
204
|
+
};
|
|
205
|
+
supervisor.on("message", onMessage);
|
|
206
|
+
supervisor.once("exit", onExit);
|
|
207
|
+
supervisor.once("error", onError);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await new Promise((resolveSpawn, rejectSpawn) => {
|
|
212
|
+
supervisor.once("error", rejectSpawn);
|
|
213
|
+
supervisor.once("spawn", () => {
|
|
214
|
+
supervisor.off("error", rejectSpawn);
|
|
215
|
+
resolveSpawn();
|
|
219
216
|
});
|
|
220
217
|
});
|
|
221
|
-
|
|
218
|
+
const spawned = reply("spawned");
|
|
219
|
+
supervisor.send({ type: "launch", invocation, runId, laneKey, worktrees: [invocation.cwd, ...addDirs], logPath });
|
|
220
|
+
const { pid } = await spawned;
|
|
221
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
222
|
+
throw new Error("runtime process spawned without a PID");
|
|
223
|
+
await fs.writeFile(pidPath, `${pid}\n`, { mode: 0o600, flag: "w" });
|
|
224
|
+
try {
|
|
225
|
+
await watch({ runId, pid, supervisorPid: supervisor.pid, configPath: devConfigPath, worktree: invocation.cwd });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// This exact runtime has already been recorded in its private PID file.
|
|
228
|
+
// Do not leave an untracked runtime alive when registry persistence fails.
|
|
229
|
+
try { supervisor.send({ type: "abort" }); } catch {}
|
|
230
|
+
await terminate(supervisor);
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
const started = reply("started");
|
|
234
|
+
supervisor.send({ type: "start", prompt });
|
|
235
|
+
await started;
|
|
236
|
+
supervisor.disconnect();
|
|
237
|
+
await closeQuietly(log);
|
|
238
|
+
supervisor.unref();
|
|
239
|
+
return {
|
|
240
|
+
runId,
|
|
241
|
+
laneKey,
|
|
242
|
+
pid,
|
|
243
|
+
supervisorPid: supervisor.pid,
|
|
244
|
+
logPath,
|
|
245
|
+
pidPath,
|
|
246
|
+
command: invocation.command,
|
|
247
|
+
args: invocation.args,
|
|
248
|
+
cwd: invocation.cwd,
|
|
249
|
+
};
|
|
250
|
+
} catch (error) {
|
|
251
|
+
await closeQuietly(log);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
222
254
|
}
|
|
223
255
|
|
|
224
256
|
const terminateChild = async (child) => {
|
|
@@ -234,6 +266,7 @@ export async function launchRuntime(options, {
|
|
|
234
266
|
env = process.env,
|
|
235
267
|
watch = recordWatchedRun,
|
|
236
268
|
terminate = terminateChild,
|
|
269
|
+
supervisorEntry,
|
|
237
270
|
} = {}) {
|
|
238
271
|
const launch = validateLaunch(options);
|
|
239
272
|
const invocation = buildInvocation(launch, { env, home });
|
|
@@ -243,6 +276,7 @@ export async function launchRuntime(options, {
|
|
|
243
276
|
stateRoot: stateRoot ?? recoveryStateRoot(launch.devConfigPath),
|
|
244
277
|
watch,
|
|
245
278
|
terminate,
|
|
279
|
+
...(supervisorEntry ? { supervisorEntry } : {}),
|
|
246
280
|
});
|
|
247
281
|
}
|
|
248
282
|
|
|
@@ -260,17 +294,19 @@ export async function launchRecovery(payload, {
|
|
|
260
294
|
env = process.env,
|
|
261
295
|
watch = recordWatchedRun,
|
|
262
296
|
terminate = terminateChild,
|
|
297
|
+
supervisorEntry,
|
|
263
298
|
} = {}) {
|
|
264
299
|
const data = dataFrom(payload);
|
|
265
|
-
const { runId, laneKey } = validateData(data);
|
|
300
|
+
const { runId, laneKey, addDirs } = validateData(data);
|
|
266
301
|
const devConfigPath = absolutePath(data?.launch?.devConfigPath, "launch.devConfigPath");
|
|
267
302
|
const prompt = buildRecoveryPrompt(data);
|
|
268
303
|
const invocation = buildRecoveryInvocation(data, { env, home });
|
|
269
|
-
return launchDetached({ invocation, prompt, runId, laneKey, devConfigPath }, {
|
|
304
|
+
return launchDetached({ invocation, prompt, runId, laneKey, devConfigPath, addDirs }, {
|
|
270
305
|
spawnImpl,
|
|
271
306
|
fs,
|
|
272
307
|
stateRoot: stateRoot ?? recoveryStateRoot(devConfigPath),
|
|
273
308
|
watch,
|
|
274
309
|
terminate,
|
|
310
|
+
...(supervisorEntry ? { supervisorEntry } : {}),
|
|
275
311
|
});
|
|
276
312
|
}
|
package/dist/starting.js
CHANGED
|
@@ -538,7 +538,8 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
|
|
|
538
538
|
const pending = await request("GET", `/api/v1/lanes/${encode(lane.laneKey)}/amendments${query({ runId: lane.runId })}`);
|
|
539
539
|
if (!pending.data.length) return lane;
|
|
540
540
|
const detail = pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n");
|
|
541
|
-
|
|
541
|
+
const next = `nodii-dev lane gather ${lane.laneKey} --delta`;
|
|
542
|
+
throw new CliError("LANE_AMENDMENT_ACK_REQUIRED", `Lane ${lane.laneKey} changed:\n${detail}\nDelta gather required: run ${next}. If it prints a Trigger shortlist (picker: agent), run the record command it prints (data.next.command in --json): that records your pick and acknowledges the amendment. Help stays readable: nodii-dev --help.`, 1, { amendments: pending.data, next });
|
|
542
543
|
}
|
|
543
544
|
|
|
544
545
|
// Gather's human output is a renderer of its payload; Delta gather prints the
|
|
@@ -569,8 +570,15 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
|
|
|
569
570
|
}
|
|
570
571
|
const version = Math.max(...pending.data.map((item) => item.version));
|
|
571
572
|
const gathered = await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
572
|
-
// An agent picker's shortlist step records nothing, so it acknowledges
|
|
573
|
-
|
|
573
|
+
// An agent picker's shortlist step records nothing, so it acknowledges
|
|
574
|
+
// nothing: it names the amendment still open and the command that closes it.
|
|
575
|
+
if (gathered.payload?.data?.recorded === false) {
|
|
576
|
+
const open = `Amendment version ${version} is not acknowledged yet: run the record command above.`;
|
|
577
|
+
return {
|
|
578
|
+
payload: { ...gathered.payload, amendments: pending.data, acknowledgedVersion: null },
|
|
579
|
+
human: (p) => `${typeof gathered.human === "function" ? gathered.human(p) : gathered.human}\n${open}`,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
574
582
|
const acknowledgement = await withSignal(acknowledge(key, {
|
|
575
583
|
...flags,
|
|
576
584
|
deltaContext: { runId: lane.runId, amendmentVersion: version },
|
package/dist/watchdog.js
CHANGED
|
@@ -17,6 +17,8 @@ function validateRecord(record) {
|
|
|
17
17
|
if (!record || typeof record !== "object") throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record must be an object.", 2);
|
|
18
18
|
if (!RUN_ID.test(record.runId ?? "")) throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record needs its Dev run UUID.", 2);
|
|
19
19
|
if (!Number.isSafeInteger(record.pid) || record.pid < 1) throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record needs a positive process id.", 2);
|
|
20
|
+
if (record.supervisorPid !== undefined && (!Number.isSafeInteger(record.supervisorPid) || record.supervisorPid < 1))
|
|
21
|
+
throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run supervisor id must be a positive process id.", 2);
|
|
20
22
|
if (record.configPath !== undefined && (typeof record.configPath !== "string" || !isAbsolute(record.configPath)))
|
|
21
23
|
throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run config path must be absolute.", 2);
|
|
22
24
|
if (record.worktree !== undefined && (typeof record.worktree !== "string" || !isAbsolute(record.worktree)))
|
|
@@ -38,6 +40,7 @@ async function atomicWrite(path, contents, mode = 0o600) {
|
|
|
38
40
|
export async function recordWatchedRun(input, { home = homedir(), now = () => new Date(), processStart = processStartedAt } = {}) {
|
|
39
41
|
const record = validateRecord({ ...input, version: 1, recordedAt: now().toISOString() });
|
|
40
42
|
if (!record.processStartedAt) record.processStartedAt = await processStart(record.pid).catch(() => null);
|
|
43
|
+
if (record.supervisorPid && !record.supervisorStartedAt) record.supervisorStartedAt = await processStart(record.supervisorPid).catch(() => null);
|
|
41
44
|
const dir = watchdogRunDir(home);
|
|
42
45
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
43
46
|
await chmod(dir, 0o700);
|
|
@@ -140,6 +143,8 @@ export async function collectWatchdogFacts(records, {
|
|
|
140
143
|
} = {}) {
|
|
141
144
|
const deadRuns = [];
|
|
142
145
|
for (const record of records) {
|
|
146
|
+
// A live lane supervisor is reporting this exit itself (NODII-DEV-CLI-1).
|
|
147
|
+
if (record.supervisorPid && await isPidAlive(record.supervisorPid, record.supervisorStartedAt)) continue;
|
|
143
148
|
if (!(await isPidAlive(record.pid, record.processStartedAt)))
|
|
144
149
|
deadRuns.push({ runId: record.runId, pid: record.pid, ...(record.worktree ? { worktree: record.worktree } : {}) });
|
|
145
150
|
}
|