parallel-codex-tui 0.4.0 → 0.4.2
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 +50 -4
- package/dist/cli-args.js +76 -2
- package/dist/cli-help.js +5 -0
- package/dist/cli.js +30 -0
- package/dist/supervisor/operations.js +201 -0
- package/dist/supervisor/store.js +4 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@ Built with Codex-assisted development.
|
|
|
6
6
|
|
|
7
7
|
## Current Release
|
|
8
8
|
|
|
9
|
-
`v0.4.
|
|
9
|
+
`v0.4.2` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.4.2) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.4.2). It extends the non-interactive Supervisor control plane with `--wait-run [id]` and `--wait-timeout <seconds>`. A second terminal or automation can now wait for a detached run, receive a versioned JSON result, and distinguish completion, failure, cancellation, stale ownership, and local wait timeout without taking the TUI control lease or cancelling the underlying task.
|
|
10
|
+
|
|
11
|
+
Main and parallel executions run in a detached, per-request Supervisor after the Router resolves in the foreground. `Ctrl+C` closes the outer TUI without stopping an attached run, reopening the same workspace restores its live events or terminal result, and `Esc` remains the explicit cancellation command. One TUI owns the control lease while additional TUIs observe the same run; an observer takes control automatically after the prior controller detaches or exits.
|
|
10
12
|
|
|
11
13
|
Supervisor requests, state, events, commands, controller ownership, acknowledgements, and diagnostic output are retained under `.parallel-codex/supervisor/runs/<run-id>/`. The Supervisor writes the final chat result itself, so completion survives a closed terminal without duplicate history when the TUI is reopened. Unexpected Supervisor exit is converted into a durable failed state instead of leaving an indefinitely running UI.
|
|
12
14
|
|
|
@@ -37,7 +39,7 @@ Highlights:
|
|
|
37
39
|
- Named Worker Providers support Codex-compatible, Claude-compatible, OpenAI-compatible, Anthropic-compatible, and custom generic commands with independent role, model, environment, permission, resume, and interactive settings.
|
|
38
40
|
- Worker overview, Feature board, collaboration timeline, Task and Main conversation centers, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
|
|
39
41
|
|
|
40
|
-
Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. Supervisor PTY coverage proves detached Main and complex execution, restart recovery, controller/observer takeover, explicit cancellation,
|
|
42
|
+
Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. Supervisor PTY coverage proves detached Main and complex execution, restart recovery, controller/observer takeover, explicit cancellation, process-owner crash recovery, out-of-process status/cancellation, and detached wait/timeout commands. The deterministic repository suite contains 1,368 tests across 143 files: 1,367 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
|
|
41
43
|
|
|
42
44
|
Real Provider probes depend on valid local CLI credentials. In particular, authenticate the Claude CLI before selecting a Claude-compatible Worker, then run `parallel-codex-tui --doctor --probe-agents` to prove fresh and resumed calls on that machine.
|
|
43
45
|
|
|
@@ -65,6 +67,11 @@ parallel-codex-tui --doctor --probe-agents
|
|
|
65
67
|
parallel-codex-tui --doctor --probe-router
|
|
66
68
|
parallel-codex-tui --diagnostics
|
|
67
69
|
parallel-codex-tui --diagnostics ./support-bundle
|
|
70
|
+
parallel-codex-tui --workspace /path/to/project --runs
|
|
71
|
+
parallel-codex-tui --workspace /path/to/project --runs --json
|
|
72
|
+
parallel-codex-tui --workspace /path/to/project --cancel-run
|
|
73
|
+
parallel-codex-tui --workspace /path/to/project --wait-run
|
|
74
|
+
parallel-codex-tui --workspace /path/to/project --wait-run --wait-timeout 600 --json
|
|
68
75
|
parallel-codex-tui --workspace /path/to/project
|
|
69
76
|
parallel-codex-tui --theme aurora --workspace /path/to/project
|
|
70
77
|
parallel-codex-tui --theme studio --workspace /path/to/project
|
|
@@ -107,6 +114,12 @@ parallel-codex-tui --doctor --probe-agents
|
|
|
107
114
|
parallel-codex-tui --doctor --probe-router
|
|
108
115
|
parallel-codex-tui --diagnostics
|
|
109
116
|
parallel-codex-tui --diagnostics ./support-bundle
|
|
117
|
+
parallel-codex-tui --workspace /path/to/project --runs
|
|
118
|
+
parallel-codex-tui --workspace /path/to/project --runs --json
|
|
119
|
+
parallel-codex-tui --workspace /path/to/project --cancel-run
|
|
120
|
+
parallel-codex-tui --workspace /path/to/project --cancel-run run-20260721T000000Z-deadbeef
|
|
121
|
+
parallel-codex-tui --workspace /path/to/project --wait-run
|
|
122
|
+
parallel-codex-tui --workspace /path/to/project --wait-run run-20260721T000000Z-deadbeef --wait-timeout 600 --json
|
|
110
123
|
parallel-codex-tui --version
|
|
111
124
|
```
|
|
112
125
|
|
|
@@ -125,6 +138,39 @@ Without an explicit destination, the bundle is created under `.parallel-codex/di
|
|
|
125
138
|
|
|
126
139
|
The bundle contains `manifest.json`, `report.md`, `report.json`, `doctor.txt`, `tasks.json`, `workers.json`, `router-audit.jsonl`, and bounded Worker log tails. It exports at most the latest 20 tasks, 200 Workers, 100 Router rows, and 200 lines or 64 KiB per Worker log. Workspace, app-root, and home paths are aliased; URL credentials and paths, authorization values, secret assignments, common token formats, and environment-variable values are redacted. Prompts, role instructions, command arguments, source files, and lifetime logs are excluded. Review the bundle before sharing it, because application output can still contain project-specific text that no automatic redactor can fully understand.
|
|
127
140
|
|
|
141
|
+
## Background Runs
|
|
142
|
+
|
|
143
|
+
Inspect a Workspace without opening the TUI:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
parallel-codex-tui --workspace /path/to/project --runs
|
|
147
|
+
parallel-codex-tui --workspace /path/to/project --runs --json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Runs are ordered newest first. Text and JSON output include the run and Task ids, operation kind, lifecycle status, control state, timestamps, Supervisor PID, process liveness, controller PID/liveness, and whether the terminal result has been acknowledged by a TUI. Control states distinguish `starting`, `controlled`, `detached`, `settled`, and `stale`. The JSON document is versioned as `1` and intentionally excludes the original request, prompts, Worker logs, command arguments, and environment values.
|
|
151
|
+
|
|
152
|
+
Cancel the latest active run, or address one explicitly:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
parallel-codex-tui --workspace /path/to/project --cancel-run
|
|
156
|
+
parallel-codex-tui --workspace /path/to/project --cancel-run run-20260721T000000Z-deadbeef
|
|
157
|
+
parallel-codex-tui --workspace /path/to/project --cancel-run run-20260721T000000Z-deadbeef --json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Cancellation appends a checked command to the run's existing Supervisor command stream. It does not signal Worker PIDs directly, so the normal orchestrator cancellation, process-tree cleanup, terminal state, chat persistence, and retry evidence remain authoritative. An explicit administrative cancellation can be sent while a TUI is observing or controlling the run. Missing, stale, and already-terminal targets fail without creating a new Workspace or changing historical run evidence.
|
|
161
|
+
|
|
162
|
+
Wait for the latest unfinished run, or address one explicitly:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
parallel-codex-tui --workspace /path/to/project --wait-run
|
|
166
|
+
parallel-codex-tui --workspace /path/to/project --wait-run run-20260721T000000Z-deadbeef
|
|
167
|
+
parallel-codex-tui --workspace /path/to/project --wait-run run-20260721T000000Z-deadbeef --wait-timeout 600 --json
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
When no unfinished run exists, `--wait-run` returns the newest persisted terminal run immediately. The waiter is read-only: it does not claim `controller.json`, append to `commands.jsonl`, acknowledge the result, read the original request, or inspect Worker output. `--wait-timeout` accepts positive decimal seconds and stops only the waiting CLI; the Supervisor and Workers continue normally. `Ctrl+C` likewise exits the waiter without cancelling the run.
|
|
171
|
+
|
|
172
|
+
Wait results use exit code `0` for `completed`, `1` for `failed`, `2` for `cancelled`, `3` for `stale`, and `4` for `timeout`. Text and JSON results contain only the same bounded lifecycle metadata as `--runs`; JSON uses schema version `1`. This makes shell automation able to distinguish task outcomes while keeping requests, prompts, logs, command arguments, and environment values out of its output.
|
|
173
|
+
|
|
128
174
|
## Quick Start
|
|
129
175
|
|
|
130
176
|
Create a local config in the app root:
|
|
@@ -549,12 +595,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
|
|
|
549
595
|
To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
|
|
550
596
|
|
|
551
597
|
```bash
|
|
552
|
-
VERSION=0.4.
|
|
598
|
+
VERSION=0.4.2
|
|
553
599
|
git tag "v$VERSION"
|
|
554
600
|
git push origin "v$VERSION"
|
|
555
601
|
```
|
|
556
602
|
|
|
557
|
-
You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.4.
|
|
603
|
+
You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.4.2` requires tag `v0.4.2`. Published tags such as `v0.2.10` are immutable and must not be moved or reused.
|
|
558
604
|
|
|
559
605
|
## Publishing Hygiene
|
|
560
606
|
|
package/dist/cli-args.js
CHANGED
|
@@ -2,8 +2,31 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { normalizeTuiThemeName, TUI_THEME_NAMES } from "./tui/theme.js";
|
|
4
4
|
import { TaskIdSchema } from "./domain/schemas.js";
|
|
5
|
-
const allowedValueOptions = new Set([
|
|
6
|
-
|
|
5
|
+
const allowedValueOptions = new Set([
|
|
6
|
+
"--app-root",
|
|
7
|
+
"--workspace",
|
|
8
|
+
"-w",
|
|
9
|
+
"--task",
|
|
10
|
+
"-t",
|
|
11
|
+
"--theme",
|
|
12
|
+
"--diagnostics",
|
|
13
|
+
"--cancel-run",
|
|
14
|
+
"--wait-run",
|
|
15
|
+
"--wait-timeout"
|
|
16
|
+
]);
|
|
17
|
+
const allowedBooleanOptions = new Set([
|
|
18
|
+
"--doctor",
|
|
19
|
+
"--help",
|
|
20
|
+
"-h",
|
|
21
|
+
"--init",
|
|
22
|
+
"--json",
|
|
23
|
+
"--probe-agents",
|
|
24
|
+
"--probe-router",
|
|
25
|
+
"--runs",
|
|
26
|
+
"--themes",
|
|
27
|
+
"--version",
|
|
28
|
+
"-v"
|
|
29
|
+
]);
|
|
7
30
|
export function parseCliArgs(args, cwd) {
|
|
8
31
|
const optionArgs = argsBeforeTerminator(args);
|
|
9
32
|
const appRootFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--app-root" || arg.startsWith("--app-root="));
|
|
@@ -11,12 +34,25 @@ export function parseCliArgs(args, cwd) {
|
|
|
11
34
|
const taskFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--task" || arg.startsWith("--task=") || arg === "-t" || arg.startsWith("-t="));
|
|
12
35
|
const themeFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--theme" || arg.startsWith("--theme="));
|
|
13
36
|
const diagnosticsFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--diagnostics" || arg.startsWith("--diagnostics="));
|
|
37
|
+
const cancelRunFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--cancel-run" || arg.startsWith("--cancel-run="));
|
|
38
|
+
const cancelRun = cancelRunFlagIndex >= 0;
|
|
39
|
+
const cancelRunId = flagValue(optionArgs, cancelRunFlagIndex);
|
|
40
|
+
const waitRunFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--wait-run" || arg.startsWith("--wait-run="));
|
|
41
|
+
const waitTimeoutFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--wait-timeout" || arg.startsWith("--wait-timeout="));
|
|
42
|
+
const waitRun = waitRunFlagIndex >= 0;
|
|
43
|
+
const waitRunId = flagValue(optionArgs, waitRunFlagIndex);
|
|
44
|
+
const waitTimeoutSeconds = Number(flagValue(optionArgs, waitTimeoutFlagIndex));
|
|
45
|
+
const waitTimeoutMs = waitTimeoutFlagIndex >= 0 && Number.isFinite(waitTimeoutSeconds)
|
|
46
|
+
? waitTimeoutSeconds * 1000
|
|
47
|
+
: null;
|
|
14
48
|
const diagnostics = diagnosticsFlagIndex >= 0;
|
|
15
49
|
const doctor = optionArgs.includes("--doctor");
|
|
16
50
|
const help = optionArgs.includes("--help") || optionArgs.includes("-h");
|
|
17
51
|
const init = optionArgs.includes("--init");
|
|
52
|
+
const json = optionArgs.includes("--json");
|
|
18
53
|
const probeAgents = optionArgs.includes("--probe-agents");
|
|
19
54
|
const probeRouter = optionArgs.includes("--probe-router");
|
|
55
|
+
const runs = optionArgs.includes("--runs");
|
|
20
56
|
const themes = optionArgs.includes("--themes");
|
|
21
57
|
const version = optionArgs.includes("--version") || optionArgs.includes("-v");
|
|
22
58
|
const appRootValue = flagValue(optionArgs, appRootFlagIndex);
|
|
@@ -29,14 +65,21 @@ export function parseCliArgs(args, cwd) {
|
|
|
29
65
|
const diagnosticsPath = diagnosticsValue ? resolvePathArg(cwd, diagnosticsValue) : null;
|
|
30
66
|
return {
|
|
31
67
|
appRoot,
|
|
68
|
+
cancelRun,
|
|
69
|
+
cancelRunId,
|
|
32
70
|
diagnostics,
|
|
33
71
|
diagnosticsPath,
|
|
34
72
|
doctor,
|
|
35
73
|
explicitWorkspace,
|
|
36
74
|
help,
|
|
37
75
|
init,
|
|
76
|
+
json,
|
|
38
77
|
probeAgents,
|
|
39
78
|
probeRouter,
|
|
79
|
+
runs,
|
|
80
|
+
waitRun,
|
|
81
|
+
waitRunId,
|
|
82
|
+
waitTimeoutMs,
|
|
40
83
|
workspaceRoot,
|
|
41
84
|
taskId,
|
|
42
85
|
theme,
|
|
@@ -86,6 +129,37 @@ export function validateCliArgs(args) {
|
|
|
86
129
|
if (optionArgs.includes("--probe-agents") && !optionArgs.includes("--doctor")) {
|
|
87
130
|
errors.push("--probe-agents requires --doctor");
|
|
88
131
|
}
|
|
132
|
+
const runs = optionArgs.includes("--runs");
|
|
133
|
+
const cancelRunFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--cancel-run" || arg.startsWith("--cancel-run="));
|
|
134
|
+
const cancelRun = cancelRunFlagIndex >= 0;
|
|
135
|
+
const cancelRunId = flagValue(optionArgs, cancelRunFlagIndex);
|
|
136
|
+
const waitRunFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--wait-run" || arg.startsWith("--wait-run="));
|
|
137
|
+
const waitRun = waitRunFlagIndex >= 0;
|
|
138
|
+
const waitRunId = flagValue(optionArgs, waitRunFlagIndex);
|
|
139
|
+
const supervisorCommandCount = [runs, cancelRun, waitRun].filter(Boolean).length;
|
|
140
|
+
if (supervisorCommandCount > 1) {
|
|
141
|
+
errors.push("Only one of --runs, --cancel-run, or --wait-run may be used");
|
|
142
|
+
}
|
|
143
|
+
if (optionArgs.includes("--json") && supervisorCommandCount === 0) {
|
|
144
|
+
errors.push("--json requires --runs, --cancel-run, or --wait-run");
|
|
145
|
+
}
|
|
146
|
+
if (cancelRunId && !/^run-[A-Za-z0-9._-]+$/.test(cancelRunId)) {
|
|
147
|
+
errors.push("Invalid --cancel-run: expected run- followed by letters, numbers, dot, underscore, or hyphen");
|
|
148
|
+
}
|
|
149
|
+
if (waitRunId && !/^run-[A-Za-z0-9._-]+$/.test(waitRunId)) {
|
|
150
|
+
errors.push("Invalid --wait-run: expected run- followed by letters, numbers, dot, underscore, or hyphen");
|
|
151
|
+
}
|
|
152
|
+
const waitTimeoutFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--wait-timeout" || arg.startsWith("--wait-timeout="));
|
|
153
|
+
if (waitTimeoutFlagIndex >= 0) {
|
|
154
|
+
const rawWaitTimeout = flagValue(optionArgs, waitTimeoutFlagIndex);
|
|
155
|
+
const waitTimeoutSeconds = Number(rawWaitTimeout);
|
|
156
|
+
if (!rawWaitTimeout || !Number.isFinite(waitTimeoutSeconds) || waitTimeoutSeconds <= 0) {
|
|
157
|
+
errors.push("Invalid --wait-timeout: expected a positive number of seconds");
|
|
158
|
+
}
|
|
159
|
+
if (!waitRun) {
|
|
160
|
+
errors.push("--wait-timeout requires --wait-run");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
89
163
|
return errors;
|
|
90
164
|
}
|
|
91
165
|
function argsBeforeTerminator(args) {
|
package/dist/cli-help.js
CHANGED
|
@@ -11,6 +11,11 @@ Options:
|
|
|
11
11
|
--init Write .parallel-codex/config.toml if missing
|
|
12
12
|
--doctor Check config, agent commands, and theme palette preview
|
|
13
13
|
--diagnostics [dir] Export a sanitized support bundle; defaults inside the workspace
|
|
14
|
+
--runs List persisted Supervisor runs without opening the TUI
|
|
15
|
+
--cancel-run [id] Cancel a run by id, or the latest active run when omitted
|
|
16
|
+
--wait-run [id] Wait for a run by id, or the latest unfinished run
|
|
17
|
+
--wait-timeout <s> Stop waiting after this many seconds; never cancels the run
|
|
18
|
+
--json Emit machine-readable Supervisor command output
|
|
14
19
|
--probe-agents With --doctor, run fresh + resume probes (uses model quota)
|
|
15
20
|
--probe-router With --doctor, run one live Codex Router request
|
|
16
21
|
-v, --version Print the current version
|
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { configureTuiTheme } from "./tui/theme.js";
|
|
|
28
28
|
import { routerDiagnosticsPolicy } from "./tui/RouterDiagnosticsView.js";
|
|
29
29
|
import { version } from "./version.js";
|
|
30
30
|
import { SupervisorOrchestrator } from "./supervisor/client.js";
|
|
31
|
+
import { formatSupervisorCancellation, formatSupervisorRuns, formatSupervisorWait, inspectSupervisorRuns, requestSupervisorRunCancellation, supervisorWaitExitCode, waitForSupervisorRun } from "./supervisor/operations.js";
|
|
31
32
|
import { runSupervisorJob } from "./supervisor/runner.js";
|
|
32
33
|
main().catch((error) => {
|
|
33
34
|
if (error instanceof WorkspaceSelectionCancelledError) {
|
|
@@ -99,6 +100,35 @@ async function main() {
|
|
|
99
100
|
runtime.index.close();
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
else if (cliArgs.runs || cliArgs.cancelRun || cliArgs.waitRun) {
|
|
104
|
+
const workspaceRoot = await selectWorkspaceForCli({
|
|
105
|
+
appRoot: cliArgs.appRoot,
|
|
106
|
+
cwd: process.cwd(),
|
|
107
|
+
explicitWorkspace: cliArgs.explicitWorkspace,
|
|
108
|
+
interactive: false
|
|
109
|
+
});
|
|
110
|
+
const config = await loadConfig(cliArgs.appRoot);
|
|
111
|
+
try {
|
|
112
|
+
if (cliArgs.runs) {
|
|
113
|
+
const report = await inspectSupervisorRuns(workspaceRoot, config.dataDir);
|
|
114
|
+
console.log(cliArgs.json ? JSON.stringify(report, null, 2) : formatSupervisorRuns(report));
|
|
115
|
+
}
|
|
116
|
+
else if (cliArgs.cancelRun) {
|
|
117
|
+
const result = await requestSupervisorRunCancellation(workspaceRoot, config.dataDir, cliArgs.cancelRunId);
|
|
118
|
+
console.log(cliArgs.json ? JSON.stringify(result, null, 2) : formatSupervisorCancellation(result));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const result = await waitForSupervisorRun(workspaceRoot, config.dataDir, cliArgs.waitRunId, { timeoutMs: cliArgs.waitTimeoutMs });
|
|
122
|
+
console.log(cliArgs.json ? JSON.stringify(result, null, 2) : formatSupervisorWait(result));
|
|
123
|
+
process.exitCode = supervisorWaitExitCode(result.outcome);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
process.stderr.write(`Supervisor error: ${message}\n`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
102
132
|
else if (cliArgs.init) {
|
|
103
133
|
if (await pathExists(localConfigPath)) {
|
|
104
134
|
console.log(`Config already exists: ${localConfigPath}`);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendSupervisorCommand, listSupervisorRuns, readSupervisorController, readSupervisorRunState, supervisorControllerIsActive, supervisorRunIsAcknowledged, supervisorRunIsTerminal, supervisorRunProcessIsActive } from "./store.js";
|
|
3
|
+
const QUEUED_START_GRACE_MS = 5000;
|
|
4
|
+
export async function inspectSupervisorRuns(workspaceRoot, dataDir, now = new Date()) {
|
|
5
|
+
const records = await listSupervisorRuns(workspaceRoot, dataDir);
|
|
6
|
+
const runs = await Promise.all(records.map((record) => inspectSupervisorRun(record, now)));
|
|
7
|
+
return {
|
|
8
|
+
version: 1,
|
|
9
|
+
workspace_root: workspaceRoot,
|
|
10
|
+
generated_at: now.toISOString(),
|
|
11
|
+
runs: runs.reverse()
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function requestSupervisorRunCancellation(workspaceRoot, dataDir, runId, now = new Date()) {
|
|
15
|
+
const records = await listSupervisorRuns(workspaceRoot, dataDir);
|
|
16
|
+
const inspected = await Promise.all(records.map(async (record) => ({
|
|
17
|
+
record,
|
|
18
|
+
view: await inspectSupervisorRun(record, now)
|
|
19
|
+
})));
|
|
20
|
+
const newestFirst = inspected.reverse();
|
|
21
|
+
const selected = runId
|
|
22
|
+
? newestFirst.find(({ view }) => view.run_id === runId)
|
|
23
|
+
: newestFirst.find(({ view }) => !isTerminalStatus(view.status) && view.control !== "stale");
|
|
24
|
+
if (!selected) {
|
|
25
|
+
if (runId) {
|
|
26
|
+
throw new Error(`Supervisor run not found: ${runId}`);
|
|
27
|
+
}
|
|
28
|
+
const stale = newestFirst.find(({ view }) => !isTerminalStatus(view.status));
|
|
29
|
+
if (stale) {
|
|
30
|
+
throw new Error(`Supervisor run is not active: ${stale.view.run_id} (${stale.view.status})`);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`No active Supervisor run in workspace ${workspaceRoot}`);
|
|
33
|
+
}
|
|
34
|
+
if (isTerminalStatus(selected.view.status)) {
|
|
35
|
+
throw new Error(`Supervisor run is already ${selected.view.status}: ${selected.view.run_id}`);
|
|
36
|
+
}
|
|
37
|
+
if (selected.view.control === "stale") {
|
|
38
|
+
throw new Error(`Supervisor run is not active: ${selected.view.run_id} (${selected.view.status})`);
|
|
39
|
+
}
|
|
40
|
+
const requestedAt = now.toISOString();
|
|
41
|
+
const commandId = randomUUID();
|
|
42
|
+
await appendSupervisorCommand(selected.record.files, {
|
|
43
|
+
version: 1,
|
|
44
|
+
id: commandId,
|
|
45
|
+
at: requestedAt,
|
|
46
|
+
type: "cancel-run"
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
version: 1,
|
|
50
|
+
command_id: commandId,
|
|
51
|
+
requested_at: requestedAt,
|
|
52
|
+
run: selected.view
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export async function waitForSupervisorRun(workspaceRoot, dataDir, runId, options = {}) {
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
const now = options.now ?? (() => new Date());
|
|
58
|
+
const requestedPollIntervalMs = options.pollIntervalMs ?? 100;
|
|
59
|
+
if (!Number.isFinite(requestedPollIntervalMs) || requestedPollIntervalMs <= 0) {
|
|
60
|
+
throw new Error("Supervisor wait poll interval must be a positive number of milliseconds");
|
|
61
|
+
}
|
|
62
|
+
const pollIntervalMs = Math.max(10, requestedPollIntervalMs);
|
|
63
|
+
const timeoutMs = options.timeoutMs ?? null;
|
|
64
|
+
if (timeoutMs !== null && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
65
|
+
throw new Error("Supervisor wait timeout must be a positive number of milliseconds");
|
|
66
|
+
}
|
|
67
|
+
const records = await listSupervisorRuns(workspaceRoot, dataDir);
|
|
68
|
+
const inspected = await Promise.all(records.map(async (record) => ({
|
|
69
|
+
record,
|
|
70
|
+
view: await inspectSupervisorRun(record, now())
|
|
71
|
+
})));
|
|
72
|
+
const newestFirst = inspected.reverse();
|
|
73
|
+
const selected = runId
|
|
74
|
+
? newestFirst.find(({ view }) => view.run_id === runId)
|
|
75
|
+
: newestFirst.find(({ view }) => !isTerminalStatus(view.status)) ?? newestFirst[0];
|
|
76
|
+
if (!selected) {
|
|
77
|
+
throw new Error(runId
|
|
78
|
+
? `Supervisor run not found: ${runId}`
|
|
79
|
+
: `No Supervisor runs in workspace ${workspaceRoot}`);
|
|
80
|
+
}
|
|
81
|
+
while (true) {
|
|
82
|
+
const view = await inspectSupervisorRun(selected.record, now());
|
|
83
|
+
const waitedMs = Math.max(0, Date.now() - startedAt);
|
|
84
|
+
if (isTerminalStatus(view.status)) {
|
|
85
|
+
return {
|
|
86
|
+
version: 1,
|
|
87
|
+
outcome: view.status,
|
|
88
|
+
waited_ms: waitedMs,
|
|
89
|
+
run: view
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (view.control === "stale") {
|
|
93
|
+
return {
|
|
94
|
+
version: 1,
|
|
95
|
+
outcome: "stale",
|
|
96
|
+
waited_ms: waitedMs,
|
|
97
|
+
run: view
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (timeoutMs !== null && waitedMs >= timeoutMs) {
|
|
101
|
+
return {
|
|
102
|
+
version: 1,
|
|
103
|
+
outcome: "timeout",
|
|
104
|
+
waited_ms: waitedMs,
|
|
105
|
+
run: view
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const remainingTimeoutMs = timeoutMs === null ? pollIntervalMs : Math.max(1, timeoutMs - waitedMs);
|
|
109
|
+
await delay(Math.min(pollIntervalMs, remainingTimeoutMs));
|
|
110
|
+
selected.record.state = await readSupervisorRunState(selected.record.files);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function formatSupervisorRuns(report) {
|
|
114
|
+
if (report.runs.length === 0) {
|
|
115
|
+
return `No Supervisor runs in ${report.workspace_root}.`;
|
|
116
|
+
}
|
|
117
|
+
const lines = [`Supervisor runs · ${report.workspace_root}`];
|
|
118
|
+
for (const run of report.runs) {
|
|
119
|
+
const acknowledgement = run.acknowledged ? "seen" : "unread";
|
|
120
|
+
lines.push(`${run.status} · ${run.control} · ${acknowledgement} · ${run.run_id}`);
|
|
121
|
+
lines.push([
|
|
122
|
+
run.task_id ? `task ${run.task_id}` : `kind ${run.kind}`,
|
|
123
|
+
`updated ${run.updated_at}`,
|
|
124
|
+
...(run.pid ? [`pid ${run.pid}`] : [])
|
|
125
|
+
].join(" · "));
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
export function formatSupervisorCancellation(result) {
|
|
130
|
+
const target = result.run.task_id ? `task ${result.run.task_id}` : result.run.kind;
|
|
131
|
+
return `Cancellation requested · ${result.run.run_id} · ${target}`;
|
|
132
|
+
}
|
|
133
|
+
export function formatSupervisorWait(result) {
|
|
134
|
+
const target = result.run.task_id ? `task ${result.run.task_id}` : result.run.kind;
|
|
135
|
+
return `Run ${result.outcome} · ${result.run.run_id} · ${target} · waited ${formatDuration(result.waited_ms)}`;
|
|
136
|
+
}
|
|
137
|
+
export function supervisorWaitExitCode(outcome) {
|
|
138
|
+
switch (outcome) {
|
|
139
|
+
case "completed":
|
|
140
|
+
return 0;
|
|
141
|
+
case "failed":
|
|
142
|
+
return 1;
|
|
143
|
+
case "cancelled":
|
|
144
|
+
return 2;
|
|
145
|
+
case "stale":
|
|
146
|
+
return 3;
|
|
147
|
+
case "timeout":
|
|
148
|
+
return 4;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function inspectSupervisorRun(record, now) {
|
|
152
|
+
const controller = await readSupervisorController(record.files);
|
|
153
|
+
const [processActive, controllerActive, acknowledged] = await Promise.all([
|
|
154
|
+
supervisorRunProcessIsActive(record.state),
|
|
155
|
+
controller ? supervisorControllerIsActive(controller) : Promise.resolve(false),
|
|
156
|
+
supervisorRunIsAcknowledged(record.files)
|
|
157
|
+
]);
|
|
158
|
+
return {
|
|
159
|
+
run_id: record.state.run_id,
|
|
160
|
+
kind: record.state.kind,
|
|
161
|
+
status: record.state.status,
|
|
162
|
+
control: controlState(record, processActive, controllerActive, now),
|
|
163
|
+
task_id: record.state.task_id ?? null,
|
|
164
|
+
created_at: record.state.created_at,
|
|
165
|
+
updated_at: record.state.updated_at,
|
|
166
|
+
finished_at: record.state.finished_at ?? null,
|
|
167
|
+
pid: record.state.pid ?? null,
|
|
168
|
+
process_active: processActive,
|
|
169
|
+
controller_pid: controller?.pid ?? null,
|
|
170
|
+
controller_active: controllerActive,
|
|
171
|
+
acknowledged
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function controlState(record, processActive, controllerActive, now) {
|
|
175
|
+
if (supervisorRunIsTerminal(record.state)) {
|
|
176
|
+
return "settled";
|
|
177
|
+
}
|
|
178
|
+
if (controllerActive) {
|
|
179
|
+
return "controlled";
|
|
180
|
+
}
|
|
181
|
+
if (processActive) {
|
|
182
|
+
return "detached";
|
|
183
|
+
}
|
|
184
|
+
if (record.state.status === "queued"
|
|
185
|
+
&& now.getTime() - Date.parse(record.state.updated_at) <= QUEUED_START_GRACE_MS) {
|
|
186
|
+
return "starting";
|
|
187
|
+
}
|
|
188
|
+
return "stale";
|
|
189
|
+
}
|
|
190
|
+
function isTerminalStatus(status) {
|
|
191
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
192
|
+
}
|
|
193
|
+
function formatDuration(milliseconds) {
|
|
194
|
+
if (milliseconds < 1000) {
|
|
195
|
+
return `${milliseconds}ms`;
|
|
196
|
+
}
|
|
197
|
+
return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)}s`;
|
|
198
|
+
}
|
|
199
|
+
function delay(milliseconds) {
|
|
200
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
201
|
+
}
|
package/dist/supervisor/store.js
CHANGED
|
@@ -91,6 +91,9 @@ export async function acknowledgeSupervisorRun(files) {
|
|
|
91
91
|
export async function supervisorRunIsAcknowledged(files) {
|
|
92
92
|
return pathExists(files.acknowledgedPath);
|
|
93
93
|
}
|
|
94
|
+
export async function readSupervisorController(files) {
|
|
95
|
+
return readValidJson(files.controllerPath, SupervisorControllerSchema);
|
|
96
|
+
}
|
|
94
97
|
export async function listSupervisorRuns(workspaceRoot, dataDir) {
|
|
95
98
|
const root = supervisorRunsRoot(workspaceRoot, dataDir);
|
|
96
99
|
if (!(await pathExists(root))) {
|
|
@@ -158,7 +161,7 @@ async function removeControllerIfOwned(path, controllerId) {
|
|
|
158
161
|
}
|
|
159
162
|
await removeIfExists(path);
|
|
160
163
|
}
|
|
161
|
-
async function supervisorControllerIsActive(controller) {
|
|
164
|
+
export async function supervisorControllerIsActive(controller) {
|
|
162
165
|
if (!processIsAlive(controller.pid)) {
|
|
163
166
|
return false;
|
|
164
167
|
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.4.
|
|
1
|
+
export const version = "0.4.2";
|