parallel-codex-tui 0.1.8 → 0.2.0
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/.parallel-codex/config.example.toml +2 -0
- package/README.md +61 -13
- package/dist/cli-args.js +7 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +20 -1
- package/dist/core/config.js +4 -2
- package/dist/core/diagnostics.js +394 -0
- package/dist/core/task-state-machine.js +1 -1
- package/dist/orchestrator/collaboration-channel.js +3 -3
- package/dist/orchestrator/orchestrator.js +170 -7
- package/dist/orchestrator/prompts.js +14 -0
- package/dist/orchestrator/workspace-sandbox.js +39 -3
- package/dist/tui/App.js +51 -2
- package/dist/tui/InputBar.js +2 -1
- package/dist/tui/keyboard.js +3 -0
- package/dist/version.js +1 -1
- package/package.json +7 -1
|
@@ -32,6 +32,8 @@ fallback = "simple"
|
|
|
32
32
|
maxParallelFeatures = 3
|
|
33
33
|
# Maximum Actor/Critic revision rounds before the task is stopped for manual intervention (1..10).
|
|
34
34
|
maxRevisionRounds = 3
|
|
35
|
+
# Maximum automatic Judge DAG replans after isolated Feature merge conflicts (0..8).
|
|
36
|
+
maxConflictReplans = 2
|
|
35
37
|
|
|
36
38
|
[workers.codex]
|
|
37
39
|
command = "codex"
|
package/README.md
CHANGED
|
@@ -6,22 +6,23 @@ Built with Codex-assisted development.
|
|
|
6
6
|
|
|
7
7
|
## Current Release
|
|
8
8
|
|
|
9
|
-
`v0.
|
|
9
|
+
`v0.2.0` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.0) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.0). It adds automatic Judge-guided recovery for isolated Feature merge conflicts, a sanitized diagnostics bundle, longer restart soak coverage, and real multi-workspace Agent acceptance.
|
|
10
10
|
|
|
11
|
-
The release
|
|
11
|
+
The release keeps terminal scrolling and copying available at the same time without requiring Shift. Ordinary left-drag selection remains owned by the terminal while alternate-scroll delivers the wheel to chat, Worker logs, and structured views. `Ctrl+Y` copies the visible view as a fallback. It preserves the embedded native Agent scrollback across status-detail round trips and real PTY resizes.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Highlights:
|
|
14
14
|
|
|
15
|
+
- Judge produces validated requirements, acceptance criteria, and a dependency-aware Feature DAG. Actor/Critic pairs exchange checked JSONL findings and replies, followed by Wave Critic and final Judge acceptance.
|
|
16
|
+
- A conflicting parallel wave is archived with its merge evidence and returned to the same Judge native session for a bounded DAG replan. The replacement plan must retain the same Feature IDs and serialize every conflicting pair; a deterministic serialized fallback is used when the Judge response is invalid.
|
|
17
|
+
- Tasks retain every Turn, historical Worker log, native session binding, model snapshot, and collaboration artifact across follow-ups and restarts. A 20-turn soak verifies 80 ordered Workers before and after restart.
|
|
18
|
+
- `--diagnostics` and `Ctrl+X` export bounded, redacted support evidence without prompts, role instructions, command arguments, source files, or environment-variable values.
|
|
15
19
|
- Runtime preflight checks Codex, Claude, named third-party Providers, proxy reachability, workspace access, CLI capabilities, and native attach policy before work starts.
|
|
16
|
-
- Judge produces validated requirements, acceptance criteria, and a dependency-aware Feature plan. Actor/Critic pairs exchange checked JSONL findings and replies across revision rounds, followed by Wave Critic and final Judge acceptance.
|
|
17
|
-
- Tasks retain every Turn, Worker log, native session binding, model snapshot, and collaboration artifact across follow-ups and restarts. Sessions can be inspected, continued, forked, renamed, archived, exported, restored, or deleted from the TUI.
|
|
18
20
|
- 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.
|
|
19
|
-
- Worker overview, Feature board, collaboration timeline, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
|
|
20
|
-
- SQLite migrations and recovery backups, owned-process cleanup, ten-turn Worker-history recovery, packaged CLI installation, and cross-platform CI are covered by automated tests.
|
|
21
|
+
- Worker overview, Feature board, collaboration timeline, Task center, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
|
|
21
22
|
|
|
22
|
-
Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review.
|
|
23
|
+
Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. Real Codex and Claude probes both proved fresh and same-session resume calls, 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 and preserving the native output tail across status-detail round trips. The deterministic repository suite passed 1,256 tests across 122 files; one quota-consuming real-Agent test is skipped by default and passes through `npm run test:real-agents`.
|
|
23
24
|
|
|
24
|
-
Real Provider probes
|
|
25
|
+
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.
|
|
25
26
|
|
|
26
27
|
## Requirements
|
|
27
28
|
|
|
@@ -45,6 +46,8 @@ parallel-codex-tui --init
|
|
|
45
46
|
parallel-codex-tui --doctor
|
|
46
47
|
parallel-codex-tui --doctor --probe-agents
|
|
47
48
|
parallel-codex-tui --doctor --probe-router
|
|
49
|
+
parallel-codex-tui --diagnostics
|
|
50
|
+
parallel-codex-tui --diagnostics ./support-bundle
|
|
48
51
|
parallel-codex-tui --workspace /path/to/project
|
|
49
52
|
parallel-codex-tui --theme aurora --workspace /path/to/project
|
|
50
53
|
parallel-codex-tui --theme studio --workspace /path/to/project
|
|
@@ -85,11 +88,26 @@ parallel-codex-tui --help
|
|
|
85
88
|
parallel-codex-tui --doctor
|
|
86
89
|
parallel-codex-tui --doctor --probe-agents
|
|
87
90
|
parallel-codex-tui --doctor --probe-router
|
|
91
|
+
parallel-codex-tui --diagnostics
|
|
92
|
+
parallel-codex-tui --diagnostics ./support-bundle
|
|
88
93
|
parallel-codex-tui --version
|
|
89
94
|
```
|
|
90
95
|
|
|
91
96
|
`--doctor` checks configured automated and interactive commands, `{env:NAME}` references, and the CLI help surfaces required for Codex exec/resume sandboxing and Claude print/resume permissions before workers start. Recognized standard CLI help that lacks a required option fails the check; an opaque wrapper keeps the compatible built-in profile's unverified warning, while an explicit `generic` capability contract is validated without guessing vendor-specific help. Doctor performs these checks for every named Worker Provider used by the active route, rejects an explicitly read-only Codex-compatible interactive sandbox when feature attach needs writable roots, and reminds you that native workspace trust remains an interactive decision. It reports proxy host/port reachability as a local-endpoint check, not as proof that the proxy upstream or model API is healthy. Add `--probe-agents` to make one minimal fresh request and, when configured, one same-session resume request through every active process Worker Provider. This explicit probe uses model quota, removes successful probe artifacts, preserves failed artifacts under `.parallel-codex/probes/`, and exits non-zero when a fresh or resumed request cannot be proven. Fresh sessions use the configured `freshSessionArgs`; Claude-compatible profiles default to a generated native `--session-id`, persisted only after the process emits output, so a silent failed launch cannot create a false resumable session. Add `--probe-router` to run one real classification through the configured Codex Router; the command exits non-zero when that live request falls back or fails. Doctor also reports the loaded TUI theme, core palette values, ANSI swatch previews, and color override values, including any temporary `--theme` override.
|
|
92
97
|
|
|
98
|
+
## Diagnostics
|
|
99
|
+
|
|
100
|
+
Export a support bundle without starting the TUI:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
parallel-codex-tui --workspace /path/to/project --diagnostics
|
|
104
|
+
parallel-codex-tui --workspace /path/to/project --diagnostics ./support-bundle
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Without an explicit destination, the bundle is created under `.parallel-codex/diagnostics/<timestamp>/`. An explicit destination must not already exist. While the TUI is open, `Ctrl+X` creates the same bundle without interrupting chat, logs, Task center, or an active Worker; native attach keeps `Ctrl+X` available to the attached Agent.
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
93
111
|
## Quick Start
|
|
94
112
|
|
|
95
113
|
Create a local config in the app root:
|
|
@@ -128,12 +146,15 @@ Limit how many feature-level Actor or Critic agents may run at once:
|
|
|
128
146
|
[orchestration]
|
|
129
147
|
maxParallelFeatures = 3 # 1..8
|
|
130
148
|
maxRevisionRounds = 3 # 1..10
|
|
149
|
+
maxConflictReplans = 2 # 0..8
|
|
131
150
|
```
|
|
132
151
|
|
|
133
152
|
Judge may plan up to eight dependency-aware features. The orchestrator keeps dependency order while running at most this many agents concurrently. When one worker fails, already-running peers are allowed to finish cleanly and queued features are not started.
|
|
134
153
|
|
|
135
154
|
Actor and Critic reuse their native sessions across revision rounds. Blocking findings and Actor replies remain in the Feature JSONL mailbox; the task stops with an explicit error when `maxRevisionRounds` is exhausted.
|
|
136
155
|
|
|
156
|
+
When isolated Features conflict during wave staging, `maxConflictReplans` controls how many times the same Judge native session may replace the remaining dependency DAG. Conflict evidence and every Judge response are archived under the turn workspace. Completed earlier waves remain integrated; only the conflicting and later waves are discarded and rerun. Old configs inherit the default value `2`, so no SQLite migration is required, but adding the field makes the policy explicit.
|
|
157
|
+
|
|
137
158
|
Queued Feature workers remain `queued` until they acquire a concurrency slot. Their persisted state changes to `actor_running` or `critic_running` only after the Worker is registered as cancellable, then to `actor_done` or `critic_done` before that active registration is removed. As a result, only `actor_running` and `critic_running` expose Feature cancellation; queued and role-complete Features cannot present a cancel action that has no live process behind it.
|
|
138
159
|
|
|
139
160
|
## Theme
|
|
@@ -208,7 +229,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
|
|
|
208
229
|
- Every staged wave gets a combined Wave Critic run in a disposable verification copy. It checks the full Judge acceptance criteria, cross-feature behavior, tests, and builds. A missing `APPROVED`/`REVISION_REQUIRED` decision fails safely instead of being treated as approval.
|
|
209
230
|
- When combined verification requests changes, a session-backed Wave Actor fixes the integration workspace and the same Wave Critic native session reviews it again. Only an explicit final `APPROVED` allows the wave to reach the live workspace.
|
|
210
231
|
- Wave Actor/Critic prompts, logs, immutable `verification-review-01.md`/`02.md` rounds, native session ids, minimized additional-directory permissions, and `verification.json` audit evidence are stored with the task and remain available through worker logs and native attach.
|
|
211
|
-
- Overlapping edits
|
|
232
|
+
- Overlapping edits never partially change the live workspace. The orchestrator archives the conflicting paths and merge evidence, returns to `judging`, and asks the same Judge native session to serialize the affected Feature DAG. The replanned run keeps integrated earlier waves and every historical Worker log. If the bounded replan budget is exhausted or the replacement plan is invalid, the task fails with the evidence path and remains retryable through `Ctrl+R`.
|
|
212
233
|
- If the live workspace changes after a wave baseline is captured, staging or commit stops with the changed paths instead of silently absorbing an escaped worker edit or overwriting concurrent user work.
|
|
213
234
|
- Feature workspaces persist with the task so native attach can reopen the exact worker cwd. Delete an old task session when its audit trail and attachable workspaces are no longer needed.
|
|
214
235
|
- Complex follow-ups stay in the active task, append a numbered turn, restore the same Judge session to re-clarify the new direction, and save a turn-local Judge snapshot. A new multi-feature plan can start parallel workers immediately.
|
|
@@ -227,7 +248,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
|
|
|
227
248
|
- Terminal completion is evidence-guarded. The latest `supervisor-summary.md`, feature `decisions.md`, and approved feature states are published before task status can become `done`; duplicate task and feature state writes are idempotent, every real task transition records its `from` and `to` state, and a complete `done` task cannot regress unless a new follow-up turn has first been created. Each committed task state change carries a unique transition marker in `meta.json`; same-process retries repair missing projections immediately, and startup repairs a missing event or SQLite projection from that marker before another transition can replace it. Startup also audits legacy `done` tasks that have an integrated latest-turn checkpoint: missing summaries or unfinished feature states are recovered to `cancelled`, then `Ctrl+R` rebuilds final evidence without rerunning completed workers. Legacy log-only `done` sessions without integration proof remain untouched.
|
|
228
249
|
- Simple follow-up questions run through the persistent Main native session with the active task directory, original request, up to five recent turn summaries, valid worker statuses, and log tails as file-backed context. They hold the active task lease while committing the route, taking that context snapshot, and running Main, so the answer cannot race an in-progress complex turn. A failed or cancelled Main answer replaces the transient `running` state with its real terminal state and releases both task and Main leases, so the next question can proceed. They do not start another Judge, Actor, or Critic turn.
|
|
229
250
|
- Worker prompts, logs, status, and outputs are written to disk.
|
|
230
|
-
- The bottom status line shows the active task state and feature progress such as `wave 1/2 · actor 2/3`, `wave 1/2 · integration 0/1`, and `wave 1/2 · verification 0/1`. While classification is running, it follows the real subprocess through `starting`, `waiting output`, `diagnostics`, `receiving`, `parsing`, and `stopping`, keeps live elapsed/limit progress, identifies a non-default Router runner, and identifies the path as `direct` or `via <proxy-host:port>`. Before output arrives it shows the active first-output deadline and total ceiling; after activity begins it shows total elapsed progress and the idle watchdog. As soon as a fresh initial or follow-up route settles, chat temporarily inserts a themed route rail with `route · <mode> · <source>` followed by the indented Router reason before Main or parallel execution finishes, then replaces it with the final answer or error without adding noise to persisted chat history. Task retry restores its saved route without announcing it as a new Router decision. The status line then replaces the wait state with the final route source, duration, or fallback cause.
|
|
251
|
+
- The bottom status line shows the active task state and feature progress such as `wave 1/2 · actor 2/3`, `wave 1/2 · integration 0/1`, and `wave 1/2 · verification 0/1`. Chat, Worker logs, status details, and native attach derive this summary from the same persisted runtime snapshot, so changing views does not rename or reorder task state. While classification is running, it follows the real subprocess through `starting`, `waiting output`, `diagnostics`, `receiving`, `parsing`, and `stopping`, keeps live elapsed/limit progress, identifies a non-default Router runner, and identifies the path as `direct` or `via <proxy-host:port>`. Before output arrives it shows the active first-output deadline and total ceiling; after activity begins it shows total elapsed progress and the idle watchdog. As soon as a fresh initial or follow-up route settles, chat temporarily inserts a themed route rail with `route · <mode> · <source>` followed by the indented Router reason before Main or parallel execution finishes, then replaces it with the final answer or error without adding noise to persisted chat history. Task retry restores its saved route without announcing it as a new Router decision. The status line then replaces the wait state with the final route source, duration, or fallback cause.
|
|
231
252
|
- Completed complex tasks open as a structured result at the top of the chat viewport. Requirements, the complete bounded Actor worklog, authoritative integrated changed paths, Critic review, verification evidence, and findings render as themed full-width sections instead of losing everything after each section's first line. Changed files come from the workspace integration result rather than an Actor claim; verification keeps the Critic decision and reported test/build evidence. Multi-feature delivery uses the same result protocol and includes combined Wave verification. Long results start at the title and scroll toward findings; `Ctrl+D` toggles the focused result between full detail and its five-line compact summary, and beginning the next message collapses it automatically. Result lookup follows the persisted task id, so restoring one task cannot display another task's result. Empty state and ordinary short chat remain adjacent to the input.
|
|
232
253
|
- Restarting an existing task restores the latest persisted route evidence in the bottom status line, including fallback cause and duration. Follow-up Router classification has no task-side effects: it records the attempt in shared `routes.jsonl`, then a complex turn or simple question refreshes `sessions/<task>/latest-route.json` only after acquiring the task lease. A conflicting TUI leaves the previous committed route and turn files untouched. A corrupt latest-route record safely falls back to the latest worker turn and then the task's initial route record.
|
|
233
254
|
- Task session ids are single `task-...` path segments. CLI `--task` input, persisted metadata, startup scanning, and SQLite rebuilding reject path separators and ignore a task whose `meta.json` id differs from its directory, so restoring a session cannot escape or alias the workspace session root.
|
|
@@ -451,6 +472,31 @@ In chat or worker-log views, press `Ctrl+O` to attach to the selected worker's n
|
|
|
451
472
|
|
|
452
473
|
If a native resume fails because the underlying CLI reports that its context window is full, configure `fallback = "new"` under `[workers.<engine>.nativeSession]`. The old native session is archived as `native-session.retired.json`, removed from active use, and the worker is retried once with the normal fresh-session command. A valid retirement tombstone is also a cross-turn inheritance barrier: a retry or later turn cannot resurrect the same session from an older worker copy, while a newer replacement session remains reusable. A `process-cleanup-error` or `process-ownership-error` always blocks native-session fallback, even when the CLI output also mentions a full context window, so a fresh Worker cannot overlap an untracked or still-running resume process.
|
|
453
474
|
|
|
475
|
+
## Testing
|
|
476
|
+
|
|
477
|
+
Run the deterministic release checks from a source checkout:
|
|
478
|
+
|
|
479
|
+
```bash
|
|
480
|
+
npm test
|
|
481
|
+
npm run test:stability
|
|
482
|
+
npm run test:terminal-hosts
|
|
483
|
+
npm run typecheck
|
|
484
|
+
npm run build
|
|
485
|
+
npm run verify:package
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`test:stability` covers 20 same-task turns, 80 historical Workers, restart recovery, workspace switching, orphan cleanup, and interrupted startup. `test:terminal-hosts` uses real PTYs and exercises Apple Terminal, tmux, and Zellij profiles when those hosts are installed; unsupported hosts are reported as skipped.
|
|
489
|
+
|
|
490
|
+
Real Agent acceptance is deliberately opt-in because it uses model quota and local credentials:
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
HTTP_PROXY=http://127.0.0.1:7890 \
|
|
494
|
+
HTTPS_PROXY=http://127.0.0.1:7890 \
|
|
495
|
+
npm run test:real-agents
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The test performs real Main calls in two temporary workspaces through one running TUI, switches back, and verifies that each persisted `chat.jsonl` contains only its own response. Configure the corresponding Worker environment in local `config.toml` when the CLI does not inherit process proxy variables.
|
|
499
|
+
|
|
454
500
|
## Release
|
|
455
501
|
|
|
456
502
|
GitHub Actions runs CI on Ubuntu with Node.js 24.15 and 26, plus macOS with Node.js 24.15, for pushes and pull requests to `main`. Every job also packs the release artifact, installs that tarball into a clean global prefix, and executes the installed `--version` and `--help` entrypoints.
|
|
@@ -470,12 +516,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
|
|
|
470
516
|
To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
|
|
471
517
|
|
|
472
518
|
```bash
|
|
473
|
-
VERSION=0.
|
|
519
|
+
VERSION=0.2.0
|
|
474
520
|
git tag "v$VERSION"
|
|
475
521
|
git push origin "v$VERSION"
|
|
476
522
|
```
|
|
477
523
|
|
|
478
|
-
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.
|
|
524
|
+
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.2.0` requires tag `v0.2.0`. Published tags such as `v0.1.8` are immutable and must not be moved or reused.
|
|
479
525
|
|
|
480
526
|
## Publishing Hygiene
|
|
481
527
|
|
|
@@ -483,4 +529,6 @@ You can also run the Release workflow manually and enter the same tag value. The
|
|
|
483
529
|
- `.parallel-codex/last-workspace` and `.parallel-codex/workspaces.json` are local workspace-selection state and are ignored.
|
|
484
530
|
- `.parallel-codex/router/` contains local request classification audit records and is ignored.
|
|
485
531
|
- `.parallel-codex/sessions/` contains the workspace chat transcript, task prompts, logs, native session ids, isolated feature workspaces, and conflict evidence; never commit it.
|
|
532
|
+
- `.parallel-codex/diagnostics/` contains redacted support bundles and is ignored; review a bundle before sharing it.
|
|
533
|
+
- `.parallel-codex/probes/` contains failed live Agent probe evidence and is ignored.
|
|
486
534
|
- `docs/superpowers/` contains internal planning notes and is ignored for public releases.
|
package/dist/cli-args.js
CHANGED
|
@@ -2,7 +2,7 @@ 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(["--app-root", "--workspace", "-w", "--task", "-t", "--theme"]);
|
|
5
|
+
const allowedValueOptions = new Set(["--app-root", "--workspace", "-w", "--task", "-t", "--theme", "--diagnostics"]);
|
|
6
6
|
const allowedBooleanOptions = new Set(["--doctor", "--help", "-h", "--init", "--probe-agents", "--probe-router", "--themes", "--version", "-v"]);
|
|
7
7
|
export function parseCliArgs(args, cwd) {
|
|
8
8
|
const optionArgs = argsBeforeTerminator(args);
|
|
@@ -10,6 +10,8 @@ export function parseCliArgs(args, cwd) {
|
|
|
10
10
|
const workspaceFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--workspace" || arg.startsWith("--workspace=") || arg === "-w" || arg.startsWith("-w="));
|
|
11
11
|
const taskFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--task" || arg.startsWith("--task=") || arg === "-t" || arg.startsWith("-t="));
|
|
12
12
|
const themeFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--theme" || arg.startsWith("--theme="));
|
|
13
|
+
const diagnosticsFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--diagnostics" || arg.startsWith("--diagnostics="));
|
|
14
|
+
const diagnostics = diagnosticsFlagIndex >= 0;
|
|
13
15
|
const doctor = optionArgs.includes("--doctor");
|
|
14
16
|
const help = optionArgs.includes("--help") || optionArgs.includes("-h");
|
|
15
17
|
const init = optionArgs.includes("--init");
|
|
@@ -23,8 +25,12 @@ export function parseCliArgs(args, cwd) {
|
|
|
23
25
|
const workspaceRoot = explicitWorkspace ? resolvePathArg(cwd, explicitWorkspace) : cwd;
|
|
24
26
|
const taskId = flagValue(optionArgs, taskFlagIndex);
|
|
25
27
|
const theme = cliThemeValue(flagValue(optionArgs, themeFlagIndex));
|
|
28
|
+
const diagnosticsValue = flagValue(optionArgs, diagnosticsFlagIndex);
|
|
29
|
+
const diagnosticsPath = diagnosticsValue ? resolvePathArg(cwd, diagnosticsValue) : null;
|
|
26
30
|
return {
|
|
27
31
|
appRoot,
|
|
32
|
+
diagnostics,
|
|
33
|
+
diagnosticsPath,
|
|
28
34
|
doctor,
|
|
29
35
|
explicitWorkspace,
|
|
30
36
|
help,
|
package/dist/cli-help.js
CHANGED
|
@@ -10,6 +10,7 @@ Options:
|
|
|
10
10
|
--themes List built-in TUI theme palettes; combine with --theme to filter
|
|
11
11
|
--init Write .parallel-codex/config.toml if missing
|
|
12
12
|
--doctor Check config, agent commands, and theme palette preview
|
|
13
|
+
--diagnostics [dir] Export a sanitized support bundle; defaults inside the workspace
|
|
13
14
|
--probe-agents With --doctor, run fresh + resume probes (uses model quota)
|
|
14
15
|
--probe-router With --doctor, run one live Codex Router request
|
|
15
16
|
-v, --version Print the current version
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { prepareAppRoot } from "./core/app-root.js";
|
|
|
14
14
|
import { formatConfigErrorMessage } from "./core/config-errors.js";
|
|
15
15
|
import { configPath, loadConfig, withUiThemeOverride, writeDefaultConfig } from "./core/config.js";
|
|
16
16
|
import { pathExists } from "./core/file-store.js";
|
|
17
|
+
import { exportDiagnostics } from "./core/diagnostics.js";
|
|
17
18
|
import { readRouterAudit } from "./core/router-audit.js";
|
|
18
19
|
import { loadTaskSessionDetails as loadPersistedTaskSessionDetails } from "./core/task-session-details.js";
|
|
19
20
|
import { listWorkspaceChoices } from "./core/workspace.js";
|
|
@@ -67,6 +68,24 @@ async function main() {
|
|
|
67
68
|
process.stdout.write(result.text);
|
|
68
69
|
process.exitCode = result.ok ? 0 : 1;
|
|
69
70
|
}
|
|
71
|
+
else if (cliArgs.diagnostics) {
|
|
72
|
+
const workspaceRoot = await selectWorkspaceForCli({
|
|
73
|
+
appRoot: cliArgs.appRoot,
|
|
74
|
+
cwd: process.cwd(),
|
|
75
|
+
explicitWorkspace: cliArgs.explicitWorkspace,
|
|
76
|
+
interactive: false
|
|
77
|
+
});
|
|
78
|
+
const runtime = await createRuntime(cliArgs.appRoot, workspaceRoot);
|
|
79
|
+
try {
|
|
80
|
+
const result = await exportDiagnostics(cliArgs.appRoot, runtime, {
|
|
81
|
+
destinationPath: cliArgs.diagnosticsPath
|
|
82
|
+
});
|
|
83
|
+
console.log(`Diagnostics exported: ${result.path}`);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
runtime.index.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
70
89
|
else if (cliArgs.init) {
|
|
71
90
|
if (await pathExists(localConfigPath)) {
|
|
72
91
|
console.log(`Config already exists: ${localConfigPath}`);
|
|
@@ -114,7 +133,7 @@ async function main() {
|
|
|
114
133
|
await state.runtime.sessions.setTaskArchived(taskId, archived);
|
|
115
134
|
}, deleteTaskSession: async (taskId) => {
|
|
116
135
|
await state.runtime.sessions.deleteTask(taskId);
|
|
117
|
-
}, exportTaskSession: async (taskId) => (await state.runtime.sessions.exportTask(taskId)).path, loadCollaborationTimeline: (taskId) => state.runtime.sessions.readCollaborationTimeline(taskId), activateTaskSession: async (taskId) => {
|
|
136
|
+
}, exportTaskSession: async (taskId) => (await state.runtime.sessions.exportTask(taskId)).path, exportDiagnostics: async () => (await exportDiagnostics(cliArgs.appRoot, state.runtime)).path, loadCollaborationTimeline: (taskId) => state.runtime.sessions.readCollaborationTimeline(taskId), activateTaskSession: async (taskId) => {
|
|
118
137
|
if (!taskId) {
|
|
119
138
|
await state.runtime.index.setActiveTaskId(null);
|
|
120
139
|
return null;
|
package/dist/core/config.js
CHANGED
|
@@ -86,7 +86,8 @@ const CodexRouterConfigSchema = z.object({
|
|
|
86
86
|
});
|
|
87
87
|
const OrchestrationConfigSchema = z.object({
|
|
88
88
|
maxParallelFeatures: z.number().int().min(1).max(8),
|
|
89
|
-
maxRevisionRounds: z.number().int().min(1).max(10)
|
|
89
|
+
maxRevisionRounds: z.number().int().min(1).max(10),
|
|
90
|
+
maxConflictReplans: z.number().int().min(0).max(8)
|
|
90
91
|
}).strict();
|
|
91
92
|
const UiConfigSchema = z.object({
|
|
92
93
|
showStatusBar: z.boolean(),
|
|
@@ -177,7 +178,8 @@ export function defaultConfig(projectRoot) {
|
|
|
177
178
|
},
|
|
178
179
|
orchestration: {
|
|
179
180
|
maxParallelFeatures: 3,
|
|
180
|
-
maxRevisionRounds: 3
|
|
181
|
+
maxRevisionRounds: 3,
|
|
182
|
+
maxConflictReplans: 2
|
|
181
183
|
},
|
|
182
184
|
workers: {
|
|
183
185
|
codex: {
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { open, mkdtemp, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir, platform, arch } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { runDoctor } from "../doctor.js";
|
|
5
|
+
import { version } from "../version.js";
|
|
6
|
+
import { NativeSessionSchema } from "../domain/schemas.js";
|
|
7
|
+
import { ensureDir, pathExists, readJson, writeJson, writeText } from "./file-store.js";
|
|
8
|
+
import { readRouterAudit } from "./router-audit.js";
|
|
9
|
+
import { sanitizeRouterText } from "./router-redaction.js";
|
|
10
|
+
const DIAGNOSTICS_FORMAT = "parallel-codex-diagnostics-v1";
|
|
11
|
+
const DEFAULT_TASK_LIMIT = 20;
|
|
12
|
+
const DEFAULT_WORKER_LIMIT = 200;
|
|
13
|
+
const DEFAULT_ROUTER_RECORD_LIMIT = 100;
|
|
14
|
+
const DEFAULT_LOG_BYTES = 64 * 1024;
|
|
15
|
+
const DEFAULT_LOG_LINES = 200;
|
|
16
|
+
export async function exportDiagnostics(appRoot, runtime, options = {}) {
|
|
17
|
+
const now = options.now ?? (() => new Date());
|
|
18
|
+
const createdAt = now().toISOString();
|
|
19
|
+
const context = diagnosticsTextContext(appRoot, runtime.workspaceRoot);
|
|
20
|
+
const taskLimit = boundedLimit(options.taskLimit, DEFAULT_TASK_LIMIT, 100);
|
|
21
|
+
const workerLimit = boundedLimit(options.workerLimit, DEFAULT_WORKER_LIMIT, 1000);
|
|
22
|
+
const routerRecordLimit = boundedLimit(options.routerRecordLimit, DEFAULT_ROUTER_RECORD_LIMIT, 500);
|
|
23
|
+
const logBytes = boundedLimit(options.logBytes, DEFAULT_LOG_BYTES, 1024 * 1024);
|
|
24
|
+
const logLines = boundedLimit(options.logLines, DEFAULT_LOG_LINES, 2000);
|
|
25
|
+
const destination = await diagnosticsDestination(runtime, options.destinationPath ?? null, createdAt);
|
|
26
|
+
const stagingParent = dirname(destination);
|
|
27
|
+
await ensureDir(stagingParent);
|
|
28
|
+
if (await pathExists(destination)) {
|
|
29
|
+
throw new Error(`Diagnostics destination already exists: ${destination}`);
|
|
30
|
+
}
|
|
31
|
+
const staging = await mkdtemp(join(stagingParent, ".parallel-codex-diagnostics-"));
|
|
32
|
+
try {
|
|
33
|
+
const [activeTaskId, tasks, routerAudit, doctor] = await Promise.all([
|
|
34
|
+
runtime.index.activeTaskId(),
|
|
35
|
+
runtime.index.listTasks(taskLimit, { includeArchived: true }),
|
|
36
|
+
readRouterAudit(join(runtime.routerCwd, "routes.jsonl"), routerRecordLimit),
|
|
37
|
+
options.doctor
|
|
38
|
+
? options.doctor()
|
|
39
|
+
: runDoctor(appRoot, runtime.workspaceRoot, options.env ?? process.env)
|
|
40
|
+
]);
|
|
41
|
+
const workerRecords = [];
|
|
42
|
+
let omittedWorkers = 0;
|
|
43
|
+
for (let taskIndex = 0; taskIndex < tasks.length; taskIndex += 1) {
|
|
44
|
+
const task = tasks[taskIndex];
|
|
45
|
+
if (!task) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const taskWorkers = await runtime.orchestrator.listTaskWorkers(task.id);
|
|
49
|
+
const remaining = Math.max(0, workerLimit - workerRecords.length);
|
|
50
|
+
const selected = taskWorkers.length <= remaining
|
|
51
|
+
? taskWorkers
|
|
52
|
+
: latestWorkers(taskWorkers, remaining);
|
|
53
|
+
omittedWorkers += taskWorkers.length - selected.length;
|
|
54
|
+
for (const worker of selected) {
|
|
55
|
+
workerRecords.push(await exportWorkerLog(staging, task.id, worker, context, logBytes, logLines));
|
|
56
|
+
}
|
|
57
|
+
if (workerRecords.length >= workerLimit) {
|
|
58
|
+
omittedWorkers += tasks
|
|
59
|
+
.slice(taskIndex + 1)
|
|
60
|
+
.reduce((total, remainingTask) => total + remainingTask.workerCount, 0);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const safeTasks = sanitizeDiagnosticsValue(tasks, context);
|
|
65
|
+
const safeWorkers = sanitizeDiagnosticsValue(workerRecords, context);
|
|
66
|
+
const safeRouterAudit = sanitizeDiagnosticsValue(routerAudit, context);
|
|
67
|
+
const safeDoctorText = sanitizeDiagnosticsText(doctor.text, context);
|
|
68
|
+
const config = diagnosticsConfigSummary(runtime.config, options.env ?? process.env, context);
|
|
69
|
+
const report = {
|
|
70
|
+
format: DIAGNOSTICS_FORMAT,
|
|
71
|
+
createdAt,
|
|
72
|
+
app: {
|
|
73
|
+
name: "parallel-codex-tui",
|
|
74
|
+
version,
|
|
75
|
+
node: process.versions.node,
|
|
76
|
+
platform: platform(),
|
|
77
|
+
arch: arch()
|
|
78
|
+
},
|
|
79
|
+
workspace: "$WORKSPACE",
|
|
80
|
+
appRoot: appRoot === runtime.workspaceRoot ? "$WORKSPACE" : "$APP_ROOT",
|
|
81
|
+
sessionIndex: {
|
|
82
|
+
schemaVersion: runtime.index.schemaVersion(),
|
|
83
|
+
recovery: sanitizeDiagnosticsValue(runtime.index.recovery, context),
|
|
84
|
+
activeTaskId: activeTaskId ?? null
|
|
85
|
+
},
|
|
86
|
+
startupRecovery: sanitizeDiagnosticsValue({
|
|
87
|
+
pendingTaskCreations: runtime.pendingTaskCreations,
|
|
88
|
+
workspaceCommitRecovery: runtime.workspaceCommitRecovery,
|
|
89
|
+
recoveredTasks: runtime.recoveredTasks
|
|
90
|
+
}, context),
|
|
91
|
+
config,
|
|
92
|
+
doctor: {
|
|
93
|
+
ok: doctor.ok,
|
|
94
|
+
path: "doctor.txt"
|
|
95
|
+
},
|
|
96
|
+
tasks: {
|
|
97
|
+
path: "tasks.json",
|
|
98
|
+
exported: tasks.length,
|
|
99
|
+
limit: taskLimit
|
|
100
|
+
},
|
|
101
|
+
workers: {
|
|
102
|
+
path: "workers.json",
|
|
103
|
+
exported: workerRecords.length,
|
|
104
|
+
omitted: omittedWorkers,
|
|
105
|
+
limit: workerLimit
|
|
106
|
+
},
|
|
107
|
+
routerAudit: {
|
|
108
|
+
path: "router-audit.jsonl",
|
|
109
|
+
exported: routerAudit.length,
|
|
110
|
+
limit: routerRecordLimit
|
|
111
|
+
},
|
|
112
|
+
logs: {
|
|
113
|
+
exported: workerRecords.filter((worker) => worker.log).length,
|
|
114
|
+
maxBytesPerWorker: logBytes,
|
|
115
|
+
maxLinesPerWorker: logLines
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
await writeJson(join(staging, "report.json"), report);
|
|
119
|
+
await writeJson(join(staging, "tasks.json"), safeTasks);
|
|
120
|
+
await writeJson(join(staging, "workers.json"), safeWorkers);
|
|
121
|
+
await writeText(join(staging, "doctor.txt"), ensureTrailingNewline(safeDoctorText));
|
|
122
|
+
await writeText(join(staging, "router-audit.jsonl"), safeRouterAudit.map((record) => JSON.stringify(record)).join("\n") + (safeRouterAudit.length > 0 ? "\n" : ""));
|
|
123
|
+
await writeText(join(staging, "report.md"), diagnosticsMarkdownReport(report, config));
|
|
124
|
+
await writeJson(join(staging, "manifest.json"), {
|
|
125
|
+
format: DIAGNOSTICS_FORMAT,
|
|
126
|
+
created_at: createdAt,
|
|
127
|
+
report: "report.json",
|
|
128
|
+
redaction: {
|
|
129
|
+
enabled: true,
|
|
130
|
+
pathAliases: ["$WORKSPACE", "$APP_ROOT", "~"],
|
|
131
|
+
secrets: "URL credentials and paths, authorization values, secret assignments, and common raw token formats",
|
|
132
|
+
configValues: "Environment variable values, prompts, role instructions, command arguments, and source files are excluded",
|
|
133
|
+
logs: `Only the latest ${logLines} lines and ${logBytes} bytes per Worker are eligible for export`
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
await rename(staging, destination);
|
|
137
|
+
return {
|
|
138
|
+
path: destination,
|
|
139
|
+
createdAt,
|
|
140
|
+
taskCount: tasks.length,
|
|
141
|
+
workerCount: workerRecords.length,
|
|
142
|
+
logCount: workerRecords.filter((worker) => worker.log).length
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await rm(staging, { force: true, recursive: true });
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function diagnosticsDestination(runtime, requested, createdAt) {
|
|
151
|
+
if (requested) {
|
|
152
|
+
return requested;
|
|
153
|
+
}
|
|
154
|
+
const root = join(runtime.workspaceRoot, runtime.config.dataDir, "diagnostics");
|
|
155
|
+
await ensureDir(root);
|
|
156
|
+
const stamp = createdAt.replace(/[^0-9]/g, "").slice(0, 14);
|
|
157
|
+
let destination = join(root, stamp);
|
|
158
|
+
for (let suffix = 2; await pathExists(destination); suffix += 1) {
|
|
159
|
+
destination = join(root, `${stamp}-${suffix}`);
|
|
160
|
+
}
|
|
161
|
+
return destination;
|
|
162
|
+
}
|
|
163
|
+
async function exportWorkerLog(staging, taskId, worker, context, maxBytes, maxLines) {
|
|
164
|
+
const workerSegment = safePathSegment(worker.id);
|
|
165
|
+
const relativeLogPath = join("logs", safePathSegment(taskId), `${workerSegment}.log`);
|
|
166
|
+
const tail = await readBoundedTextTail(worker.logPath, maxBytes, maxLines);
|
|
167
|
+
if (tail) {
|
|
168
|
+
await writeText(join(staging, relativeLogPath), ensureTrailingNewline(sanitizeDiagnosticsText(tail.text, context)));
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
taskId,
|
|
172
|
+
id: worker.id,
|
|
173
|
+
...(worker.featureId ? { featureId: worker.featureId } : {}),
|
|
174
|
+
role: worker.role,
|
|
175
|
+
engine: worker.engine,
|
|
176
|
+
label: sanitizeDiagnosticsText(worker.label, context),
|
|
177
|
+
status: worker.runtimeStatus
|
|
178
|
+
? sanitizeDiagnosticsValue(worker.runtimeStatus, context)
|
|
179
|
+
: null,
|
|
180
|
+
nativeSession: await readNativeSession(worker),
|
|
181
|
+
log: tail
|
|
182
|
+
? {
|
|
183
|
+
path: relativeLogPath,
|
|
184
|
+
sourceBytes: tail.sourceBytes,
|
|
185
|
+
exportedBytes: Buffer.byteLength(sanitizeDiagnosticsText(tail.text, context), "utf8"),
|
|
186
|
+
exportedLines: tail.text.split("\n").length,
|
|
187
|
+
truncated: tail.truncated
|
|
188
|
+
}
|
|
189
|
+
: null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function readNativeSession(worker) {
|
|
193
|
+
try {
|
|
194
|
+
return await readJson(join(dirname(worker.statusPath), "native-session.json"), NativeSessionSchema);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.code === "ENOENT") {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function readBoundedTextTail(path, maxBytes, maxLines) {
|
|
204
|
+
let handle;
|
|
205
|
+
try {
|
|
206
|
+
handle = await open(path, "r");
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error.code === "ENOENT") {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const sourceBytes = (await handle.stat()).size;
|
|
216
|
+
const requested = Math.min(sourceBytes, maxBytes);
|
|
217
|
+
const start = Math.max(0, sourceBytes - requested);
|
|
218
|
+
const buffer = Buffer.alloc(requested);
|
|
219
|
+
const { bytesRead } = await handle.read(buffer, 0, requested, start);
|
|
220
|
+
let text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
221
|
+
let truncated = start > 0;
|
|
222
|
+
if (start > 0) {
|
|
223
|
+
const firstNewline = text.indexOf("\n");
|
|
224
|
+
if (firstNewline >= 0) {
|
|
225
|
+
text = text.slice(firstNewline + 1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const lines = text.split("\n");
|
|
229
|
+
if (lines.length > maxLines) {
|
|
230
|
+
text = lines.slice(-maxLines).join("\n");
|
|
231
|
+
truncated = true;
|
|
232
|
+
}
|
|
233
|
+
if (truncated) {
|
|
234
|
+
text = `[earlier log content omitted]\n${text}`;
|
|
235
|
+
}
|
|
236
|
+
return { text, sourceBytes, truncated };
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
await handle.close();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function diagnosticsConfigSummary(config, env, context) {
|
|
243
|
+
return {
|
|
244
|
+
dataDir: sanitizeDiagnosticsText(config.dataDir, context),
|
|
245
|
+
router: {
|
|
246
|
+
mode: config.router.defaultMode,
|
|
247
|
+
command: commandName(config.router.codex.command),
|
|
248
|
+
argumentCount: config.router.codex.args.length,
|
|
249
|
+
timeoutMs: config.router.codex.timeoutMs,
|
|
250
|
+
firstOutputTimeoutMs: config.router.codex.firstOutputTimeoutMs,
|
|
251
|
+
idleTimeoutMs: config.router.codex.idleTimeoutMs,
|
|
252
|
+
followUpTimeoutMs: config.router.codex.followUpTimeoutMs,
|
|
253
|
+
maxOutputBytes: config.router.codex.maxOutputBytes,
|
|
254
|
+
maxAttempts: config.router.codex.maxAttempts,
|
|
255
|
+
retryDelayMs: config.router.codex.retryDelayMs,
|
|
256
|
+
fallback: config.router.codex.fallback,
|
|
257
|
+
environment: environmentSummary(config.router.codex.env, env),
|
|
258
|
+
proxyVariables: proxyVariableNames(config.router.codex.env)
|
|
259
|
+
},
|
|
260
|
+
orchestration: config.orchestration,
|
|
261
|
+
pairing: config.pairing,
|
|
262
|
+
providers: Object.entries(config.workers).map(([id, worker]) => ({
|
|
263
|
+
id,
|
|
264
|
+
command: commandName(worker.command),
|
|
265
|
+
interactiveCommand: commandName(worker.interactive.command),
|
|
266
|
+
argumentCount: worker.args.length,
|
|
267
|
+
assignable: worker.assignable,
|
|
268
|
+
model: sanitizeDiagnosticsText(worker.model.name || "default", context),
|
|
269
|
+
modelProvider: sanitizeDiagnosticsText(worker.model.provider || "default", context),
|
|
270
|
+
modelArgumentCount: worker.model.args.length,
|
|
271
|
+
environment: environmentSummary(worker.model.env, env),
|
|
272
|
+
proxyVariables: proxyVariableNames(worker.model.env),
|
|
273
|
+
capabilities: {
|
|
274
|
+
profile: worker.capabilities.profile,
|
|
275
|
+
writableDirectoryArguments: worker.capabilities.writableDirArgs.length > 0,
|
|
276
|
+
freshSessionArguments: worker.capabilities.freshSessionArgs.length > 0
|
|
277
|
+
},
|
|
278
|
+
nativeSession: {
|
|
279
|
+
enabled: worker.nativeSession.enabled,
|
|
280
|
+
detectSessionId: worker.nativeSession.detectSessionId,
|
|
281
|
+
fallback: worker.nativeSession.fallback,
|
|
282
|
+
resumeArgumentCount: worker.nativeSession.resumeArgs.length,
|
|
283
|
+
forkSupported: worker.interactive.forkArgs.length > 0
|
|
284
|
+
},
|
|
285
|
+
timeouts: {
|
|
286
|
+
totalMs: worker.timeoutMs ?? null,
|
|
287
|
+
firstOutputMs: worker.firstOutputTimeoutMs ?? null,
|
|
288
|
+
idleMs: worker.idleTimeoutMs ?? null
|
|
289
|
+
}
|
|
290
|
+
})),
|
|
291
|
+
ui: {
|
|
292
|
+
theme: config.ui.theme,
|
|
293
|
+
showStatusBar: config.ui.showStatusBar,
|
|
294
|
+
autoOpenFailedWorker: config.ui.autoOpenFailedWorker,
|
|
295
|
+
colorOverrides: Object.keys(config.ui.colors).sort()
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function diagnosticsMarkdownReport(report, config) {
|
|
300
|
+
const providers = config.providers
|
|
301
|
+
.map((provider) => `- ${provider.id}: ${provider.command} · ${provider.modelProvider}/${provider.model}`)
|
|
302
|
+
.join("\n");
|
|
303
|
+
return `# parallel-codex-tui diagnostics
|
|
304
|
+
|
|
305
|
+
- Created: ${report.createdAt}
|
|
306
|
+
- Version: ${report.app.version}
|
|
307
|
+
- Runtime: Node ${report.app.node} · ${report.app.platform}/${report.app.arch}
|
|
308
|
+
- Workspace: ${report.workspace}
|
|
309
|
+
- Active task: ${report.sessionIndex.activeTaskId ?? "none"}
|
|
310
|
+
- Doctor: ${report.doctor.ok ? "ok" : "needs attention"}
|
|
311
|
+
- Tasks: ${report.tasks.exported}
|
|
312
|
+
- Workers: ${report.workers.exported}${report.workers.omitted ? ` (${report.workers.omitted} omitted by limit)` : ""}
|
|
313
|
+
- Logs: ${report.logs.exported} bounded tails
|
|
314
|
+
- Router audit rows: ${report.routerAudit.exported}
|
|
315
|
+
|
|
316
|
+
## Providers
|
|
317
|
+
|
|
318
|
+
${providers || "- none"}
|
|
319
|
+
|
|
320
|
+
## Privacy
|
|
321
|
+
|
|
322
|
+
This bundle is sanitized. It excludes environment values, prompts, role instructions, command arguments, source files, and complete lifetime logs. Review the bundle before sharing it.
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
function diagnosticsTextContext(appRoot, workspaceRoot) {
|
|
326
|
+
return {
|
|
327
|
+
aliases: [
|
|
328
|
+
{ path: workspaceRoot, alias: "$WORKSPACE" },
|
|
329
|
+
{ path: appRoot, alias: "$APP_ROOT" },
|
|
330
|
+
{ path: homedir(), alias: "~" }
|
|
331
|
+
]
|
|
332
|
+
.filter(({ path }) => path.length > 1)
|
|
333
|
+
.sort((left, right) => right.path.length - left.path.length)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
export function sanitizeDiagnosticsText(value, context) {
|
|
337
|
+
let sanitized = sanitizeRouterText(value);
|
|
338
|
+
for (const entry of context.aliases) {
|
|
339
|
+
sanitized = sanitized.split(entry.path).join(entry.alias);
|
|
340
|
+
}
|
|
341
|
+
return sanitized;
|
|
342
|
+
}
|
|
343
|
+
function sanitizeDiagnosticsValue(value, context) {
|
|
344
|
+
if (typeof value === "string") {
|
|
345
|
+
return sanitizeDiagnosticsText(value, context);
|
|
346
|
+
}
|
|
347
|
+
if (Array.isArray(value)) {
|
|
348
|
+
return value.map((entry) => sanitizeDiagnosticsValue(entry, context));
|
|
349
|
+
}
|
|
350
|
+
if (value && typeof value === "object") {
|
|
351
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
352
|
+
key,
|
|
353
|
+
sanitizeDiagnosticsValue(entry, context)
|
|
354
|
+
]));
|
|
355
|
+
}
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
function environmentSummary(values, env) {
|
|
359
|
+
return Object.entries(values).map(([name, value]) => ({
|
|
360
|
+
name,
|
|
361
|
+
configured: true,
|
|
362
|
+
referencedVariables: [...value.matchAll(/\$\{([A-Z_][A-Z0-9_]*)\}/gi)].map((match) => ({
|
|
363
|
+
name: match[1] ?? "",
|
|
364
|
+
present: Boolean(env[match[1] ?? ""])
|
|
365
|
+
}))
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
function proxyVariableNames(values) {
|
|
369
|
+
return Object.keys(values).filter((name) => /^(?:HTTP|HTTPS|ALL)_PROXY$/i.test(name)).sort();
|
|
370
|
+
}
|
|
371
|
+
function commandName(command) {
|
|
372
|
+
return basename(command) || command;
|
|
373
|
+
}
|
|
374
|
+
function latestWorkers(workers, limit) {
|
|
375
|
+
if (limit <= 0) {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
return [...workers]
|
|
379
|
+
.sort((left, right) => ((right.runtimeStatus?.last_event_at ?? "").localeCompare(left.runtimeStatus?.last_event_at ?? "")
|
|
380
|
+
|| right.id.localeCompare(left.id)))
|
|
381
|
+
.slice(0, limit);
|
|
382
|
+
}
|
|
383
|
+
function safePathSegment(value) {
|
|
384
|
+
return value.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 180) || "unknown";
|
|
385
|
+
}
|
|
386
|
+
function boundedLimit(value, fallback, maximum) {
|
|
387
|
+
if (!Number.isFinite(value)) {
|
|
388
|
+
return fallback;
|
|
389
|
+
}
|
|
390
|
+
return Math.min(maximum, Math.max(1, Math.trunc(value ?? fallback)));
|
|
391
|
+
}
|
|
392
|
+
function ensureTrailingNewline(value) {
|
|
393
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
394
|
+
}
|
|
@@ -6,7 +6,7 @@ const TASK_STATE_TRANSITIONS = {
|
|
|
6
6
|
actor_running: new Set(["critic_running", "verifying", "paused", "failed", "cancelled"]),
|
|
7
7
|
critic_running: new Set(["revision_needed", "integrating", "paused", "failed", "cancelled"]),
|
|
8
8
|
revision_needed: new Set(["actor_running", "paused", "failed", "cancelled"]),
|
|
9
|
-
integrating: new Set(["actor_running", "verifying", "paused", "done", "failed", "cancelled"]),
|
|
9
|
+
integrating: new Set(["judging", "actor_running", "verifying", "paused", "done", "failed", "cancelled"]),
|
|
10
10
|
verifying: new Set(["revision_needed", "integrating", "paused", "done", "failed", "cancelled"]),
|
|
11
11
|
paused: new Set(["routed", "judging", "ready_for_pair", "failed", "cancelled"]),
|
|
12
12
|
done: new Set(["routed", "cancelled"]),
|
|
@@ -58,7 +58,7 @@ export async function createFeatureChannel(input) {
|
|
|
58
58
|
await ensureDir(dir);
|
|
59
59
|
await ensureDir(join(input.task.dir, "dialogue"));
|
|
60
60
|
if (input.resume) {
|
|
61
|
-
await ensureFeatureChannelFiles(input, channel);
|
|
61
|
+
await ensureFeatureChannelFiles(input, channel, input.refreshDefinition ?? false);
|
|
62
62
|
const repairedState = await repairFeatureStatus(channel);
|
|
63
63
|
if (repairedState) {
|
|
64
64
|
await appendFeatureDialogue(channel, "feature.status_recovered", "actor", `Recovered Feature status as ${repairedState} while preserving collaboration evidence.`);
|
|
@@ -76,7 +76,7 @@ export async function createFeatureChannel(input) {
|
|
|
76
76
|
await appendFeatureDialogue(channel, "feature.created", "actor", "Feature mailbox created for the current turn.");
|
|
77
77
|
return channel;
|
|
78
78
|
}
|
|
79
|
-
async function ensureFeatureChannelFiles(input, channel) {
|
|
79
|
+
async function ensureFeatureChannelFiles(input, channel, refreshDefinition) {
|
|
80
80
|
const files = [
|
|
81
81
|
[channel.specPath, buildFeatureSpec(input, channel)],
|
|
82
82
|
[channel.actorWorklogPath, ""],
|
|
@@ -84,7 +84,7 @@ async function ensureFeatureChannelFiles(input, channel) {
|
|
|
84
84
|
[channel.criticFindingsPath, ""]
|
|
85
85
|
];
|
|
86
86
|
for (const [path, initialContent] of files) {
|
|
87
|
-
if (!(await pathExists(path))) {
|
|
87
|
+
if ((refreshDefinition && path === channel.specPath) || !(await pathExists(path))) {
|
|
88
88
|
await writeText(path, initialContent);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir } from "node:fs/promises";
|
|
1
|
+
import { cp, readdir } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, removeIfExists, writeJson, writeText } from "../core/file-store.js";
|
|
4
4
|
import { runWithLeaseFinalization } from "../core/lease-finalization.js";
|
|
@@ -16,7 +16,7 @@ import { validateFinalJudgeAcceptance } from "./final-acceptance.js";
|
|
|
16
16
|
import { featureExecutionWaves, parseFeaturePlan } from "./feature-plan.js";
|
|
17
17
|
import { JUDGE_REQUIRED_ARTIFACTS, JUDGE_VALIDATION_FILE, validateJudgeArtifacts } from "./judge-artifacts.js";
|
|
18
18
|
import { buildSupervisorSummary } from "./supervisor-summary.js";
|
|
19
|
-
import { ParallelWorkspaceManager } from "./workspace-sandbox.js";
|
|
19
|
+
import { ParallelWorkspaceManager, WorkspaceMergeConflictError } from "./workspace-sandbox.js";
|
|
20
20
|
const PREVIOUS_TURN_SUMMARY_LIMIT = 5;
|
|
21
21
|
const PREVIOUS_TURN_SUMMARY_LENGTH = 600;
|
|
22
22
|
const JUDGE_ARTIFACTS = [
|
|
@@ -431,7 +431,7 @@ export class Orchestrator {
|
|
|
431
431
|
criticEngine: route.critic_engine,
|
|
432
432
|
resume: input.retry
|
|
433
433
|
})));
|
|
434
|
-
return await this.
|
|
434
|
+
return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
|
|
435
435
|
}
|
|
436
436
|
const feature = await createFeatureChannel({
|
|
437
437
|
task,
|
|
@@ -477,7 +477,7 @@ export class Orchestrator {
|
|
|
477
477
|
criticEngine: route.critic_engine,
|
|
478
478
|
resume: input.retry
|
|
479
479
|
})));
|
|
480
|
-
return await this.
|
|
480
|
+
return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
|
|
481
481
|
}
|
|
482
482
|
const feature = await createFeatureChannel({
|
|
483
483
|
task,
|
|
@@ -558,6 +558,127 @@ export class Orchestrator {
|
|
|
558
558
|
throw new Error(`Invalid feature plan at ${sourcePath}: ${errorMessage(error)}`);
|
|
559
559
|
}
|
|
560
560
|
}
|
|
561
|
+
async runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, plan, features) {
|
|
562
|
+
let currentInput = input;
|
|
563
|
+
let currentJudge = judge;
|
|
564
|
+
let currentPlan = plan;
|
|
565
|
+
let currentFeatures = features;
|
|
566
|
+
let replanRound = 0;
|
|
567
|
+
const workspaceManager = new ParallelWorkspaceManager({
|
|
568
|
+
workspaceRoot: input.cwd,
|
|
569
|
+
taskDir: task.dir,
|
|
570
|
+
dataDir: this.config.dataDir
|
|
571
|
+
});
|
|
572
|
+
while (true) {
|
|
573
|
+
try {
|
|
574
|
+
return await this.runFeaturePlan(currentInput, task, route, turn, workers, currentJudge, currentPlan, currentFeatures);
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
if (!(error instanceof WorkspaceMergeConflictError)
|
|
578
|
+
|| error.wave === undefined
|
|
579
|
+
|| !error.featureId
|
|
580
|
+
|| error.waveFeatureIds.length < 2) {
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
if (replanRound >= this.config.orchestration.maxConflictReplans) {
|
|
584
|
+
throw new Error(`${error.message} Automatic Judge replan limit exhausted after ${replanRound} `
|
|
585
|
+
+ `${replanRound === 1 ? "round" : "rounds"}.`, { cause: error });
|
|
586
|
+
}
|
|
587
|
+
replanRound += 1;
|
|
588
|
+
const definitionIds = conflictDefinitionIds(currentPlan, currentFeatures, error.waveFeatureIds);
|
|
589
|
+
if (definitionIds.length < 2) {
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
const reportPath = join(turn.dir, `feature-replan-${String(replanRound).padStart(2, "0")}.json`);
|
|
593
|
+
const conflictArchiveDir = join(turn.dir, "conflicts", `replan-${String(replanRound).padStart(2, "0")}`);
|
|
594
|
+
await ensureDir(join(turn.dir, "conflicts"));
|
|
595
|
+
await cp(error.conflictDir, conflictArchiveDir, { recursive: true, force: true });
|
|
596
|
+
const requestedAt = new Date().toISOString();
|
|
597
|
+
const report = {
|
|
598
|
+
version: 1,
|
|
599
|
+
state: "requested",
|
|
600
|
+
round: replanRound,
|
|
601
|
+
requested_at: requestedAt,
|
|
602
|
+
reason: "workspace-merge-conflict",
|
|
603
|
+
wave: error.wave,
|
|
604
|
+
conflict_paths: error.paths,
|
|
605
|
+
conflict_evidence: conflictArchiveDir,
|
|
606
|
+
conflicting_feature_id: error.featureId,
|
|
607
|
+
wave_feature_ids: error.waveFeatureIds,
|
|
608
|
+
definition_ids: definitionIds,
|
|
609
|
+
previous_plan: currentPlan
|
|
610
|
+
};
|
|
611
|
+
await writeJson(reportPath, report);
|
|
612
|
+
await this.sessions.appendEvent(task, "feature.conflict_replan_requested", `Judge replan ${replanRound}/${this.config.orchestration.maxConflictReplans} requested for wave ${error.wave}: ${definitionIds.join(", ")}`);
|
|
613
|
+
await Promise.all(currentFeatures
|
|
614
|
+
.filter((feature) => error.waveFeatureIds.includes(feature.id))
|
|
615
|
+
.map((feature) => appendFeatureDialogue(feature, "judge.conflict_replan_requested", "judge", `Workspace conflict in ${error.paths.join(", ")}; Judge is replanning the Feature DAG.`, { report: reportPath, conflicts: conflictArchiveDir })));
|
|
616
|
+
await this.sessions.updateTaskStatus(task, "judging");
|
|
617
|
+
currentInput.onStatus?.({
|
|
618
|
+
taskId: task.id,
|
|
619
|
+
judge: `replanning ${replanRound}/${this.config.orchestration.maxConflictReplans}`,
|
|
620
|
+
actor: "waiting",
|
|
621
|
+
critic: "waiting"
|
|
622
|
+
});
|
|
623
|
+
await this.clearTurnJudgeArtifacts(turn);
|
|
624
|
+
const replanContext = {
|
|
625
|
+
round: replanRound,
|
|
626
|
+
reportPath,
|
|
627
|
+
conflictPaths: error.paths,
|
|
628
|
+
waveFeatureIds: definitionIds,
|
|
629
|
+
previousFeatureIds: currentPlan.features.map((feature) => feature.id)
|
|
630
|
+
};
|
|
631
|
+
const judgeWorker = await this.runJudge({ ...currentInput, retry: true }, task, route.judge_engine, workers, turn, replanContext);
|
|
632
|
+
const revisedJudge = await this.snapshotJudgeArtifacts(judgeWorker, turn);
|
|
633
|
+
let judgePlan = null;
|
|
634
|
+
let judgePlanError = "";
|
|
635
|
+
try {
|
|
636
|
+
judgePlan = await this.loadFeaturePlan(revisedJudge, turn);
|
|
637
|
+
}
|
|
638
|
+
catch (planError) {
|
|
639
|
+
judgePlanError = errorMessage(planError);
|
|
640
|
+
}
|
|
641
|
+
const judgePlanAccepted = Boolean(judgePlan
|
|
642
|
+
&& sameFeatureIds(currentPlan, judgePlan)
|
|
643
|
+
&& conflictFeaturesAreSerialized(judgePlan, definitionIds));
|
|
644
|
+
const revisedPlan = judgePlanAccepted
|
|
645
|
+
? judgePlan
|
|
646
|
+
: serializeConflictWave(currentPlan, definitionIds);
|
|
647
|
+
await writeJson(join(turn.dir, "feature-plan.json"), revisedPlan);
|
|
648
|
+
await workspaceManager.discardWavesFrom(turn.turnId, error.wave);
|
|
649
|
+
const revisedFeatures = await Promise.all(revisedPlan.features.map((feature) => createFeatureChannel({
|
|
650
|
+
task,
|
|
651
|
+
turn,
|
|
652
|
+
request: input.request,
|
|
653
|
+
judgeDir: revisedJudge.dir,
|
|
654
|
+
feature,
|
|
655
|
+
actorEngine: route.actor_engine,
|
|
656
|
+
criticEngine: route.critic_engine,
|
|
657
|
+
resume: true,
|
|
658
|
+
refreshDefinition: true
|
|
659
|
+
})));
|
|
660
|
+
await this.sessions.updateTaskStatus(task, "ready_for_pair");
|
|
661
|
+
await writeJson(reportPath, {
|
|
662
|
+
...report,
|
|
663
|
+
state: "applied",
|
|
664
|
+
applied_at: new Date().toISOString(),
|
|
665
|
+
source: judgePlanAccepted ? "judge" : "supervisor-serialization-fallback",
|
|
666
|
+
...(judgePlanError ? { judge_plan_error: judgePlanError } : {}),
|
|
667
|
+
...(judgePlan && !judgePlanAccepted ? { rejected_judge_plan: judgePlan } : {}),
|
|
668
|
+
revised_plan: revisedPlan
|
|
669
|
+
});
|
|
670
|
+
await this.sessions.appendEvent(task, "feature.conflict_replan_applied", `Applied ${judgePlanAccepted ? "Judge" : "supervisor fallback"} DAG replan ${replanRound}; wave ${error.wave} will resume from isolated workspaces`);
|
|
671
|
+
await Promise.all(revisedFeatures
|
|
672
|
+
.filter((feature) => error.waveFeatureIds.includes(feature.id))
|
|
673
|
+
.map((feature) => appendFeatureDialogue(feature, "judge.conflict_replan_applied", "judge", `Replanned dependencies: ${feature.dependsOn.length > 0 ? feature.dependsOn.join(", ") : "none"}.`, { report: reportPath, plan: join(turn.dir, "feature-plan.json") })));
|
|
674
|
+
currentInput = { ...currentInput, retry: true };
|
|
675
|
+
currentJudge = revisedJudge;
|
|
676
|
+
currentPlan = revisedPlan;
|
|
677
|
+
features.splice(0, features.length, ...revisedFeatures);
|
|
678
|
+
currentFeatures = features;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
561
682
|
async runFeaturePlan(input, task, route, turn, workers, judge, plan, features) {
|
|
562
683
|
const channels = new Map(plan.features.map((definition, index) => [definition.id, features[index]]));
|
|
563
684
|
const assignments = new Map(await Promise.all(features.map(async (feature) => [
|
|
@@ -1294,21 +1415,22 @@ export class Orchestrator {
|
|
|
1294
1415
|
throwIfCancelled(input.signal);
|
|
1295
1416
|
return readTextIfExists(outputLogPath);
|
|
1296
1417
|
}
|
|
1297
|
-
async runJudge(input, task, engine, workers, turn) {
|
|
1418
|
+
async runJudge(input, task, engine, workers, turn, replan) {
|
|
1298
1419
|
const workerId = taskWorkerId("judge", engine, turn.turnId);
|
|
1299
1420
|
const judgeFiles = this.workerFiles(task, workerId);
|
|
1300
1421
|
const judge = await this.sessions.initializeWorker(task, {
|
|
1301
1422
|
workerId,
|
|
1302
1423
|
role: "judge",
|
|
1303
1424
|
engine,
|
|
1304
|
-
preserveOutput: input.retry,
|
|
1425
|
+
preserveOutput: input.retry || Boolean(replan),
|
|
1305
1426
|
prompt: buildJudgePrompt({
|
|
1306
1427
|
request: input.request,
|
|
1307
1428
|
taskDir: task.dir,
|
|
1308
1429
|
workerDir: judgeFiles.dir,
|
|
1309
1430
|
workspaceDir: input.cwd,
|
|
1310
1431
|
turn: await this.promptTurnContext(task, turn),
|
|
1311
|
-
role: this.config.roles.judge
|
|
1432
|
+
role: this.config.roles.judge,
|
|
1433
|
+
replan
|
|
1312
1434
|
})
|
|
1313
1435
|
});
|
|
1314
1436
|
this.recordWorker(input, workers, {
|
|
@@ -2196,6 +2318,47 @@ function requiredChannel(channels, definition) {
|
|
|
2196
2318
|
}
|
|
2197
2319
|
return channel;
|
|
2198
2320
|
}
|
|
2321
|
+
function conflictDefinitionIds(plan, channels, channelIds) {
|
|
2322
|
+
const definitionByChannel = new Map(channels.map((channel, index) => [
|
|
2323
|
+
channel.id,
|
|
2324
|
+
plan.features[index]?.id
|
|
2325
|
+
]));
|
|
2326
|
+
return [...new Set(channelIds
|
|
2327
|
+
.map((channelId) => definitionByChannel.get(channelId))
|
|
2328
|
+
.filter((id) => Boolean(id)))];
|
|
2329
|
+
}
|
|
2330
|
+
function sameFeatureIds(previous, revised) {
|
|
2331
|
+
const previousIds = previous.features.map((feature) => feature.id).sort();
|
|
2332
|
+
const revisedIds = revised.features.map((feature) => feature.id).sort();
|
|
2333
|
+
return previousIds.length === revisedIds.length
|
|
2334
|
+
&& previousIds.every((id, index) => id === revisedIds[index]);
|
|
2335
|
+
}
|
|
2336
|
+
function conflictFeaturesAreSerialized(plan, featureIds) {
|
|
2337
|
+
const waveByFeature = new Map();
|
|
2338
|
+
for (const [waveIndex, wave] of featureExecutionWaves(plan).entries()) {
|
|
2339
|
+
for (const feature of wave) {
|
|
2340
|
+
waveByFeature.set(feature.id, waveIndex);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
const waveIndexes = featureIds.map((id) => waveByFeature.get(id));
|
|
2344
|
+
return waveIndexes.every((index) => index !== undefined)
|
|
2345
|
+
&& new Set(waveIndexes).size === featureIds.length;
|
|
2346
|
+
}
|
|
2347
|
+
function serializeConflictWave(plan, featureIds) {
|
|
2348
|
+
const serializedIds = new Set(featureIds);
|
|
2349
|
+
let previousId = null;
|
|
2350
|
+
const features = plan.features.map((feature) => {
|
|
2351
|
+
if (!serializedIds.has(feature.id)) {
|
|
2352
|
+
return feature;
|
|
2353
|
+
}
|
|
2354
|
+
const dependsOn = previousId
|
|
2355
|
+
? [...new Set([...feature.depends_on, previousId])]
|
|
2356
|
+
: feature.depends_on;
|
|
2357
|
+
previousId = feature.id;
|
|
2358
|
+
return { ...feature, depends_on: dependsOn };
|
|
2359
|
+
});
|
|
2360
|
+
return parseFeaturePlan({ version: 1, features });
|
|
2361
|
+
}
|
|
2199
2362
|
function requiredFeatureAssignment(assignments, channel) {
|
|
2200
2363
|
const assignment = assignments.get(channel.id);
|
|
2201
2364
|
if (!assignment) {
|
|
@@ -53,6 +53,20 @@ export function buildJudgePrompt(input) {
|
|
|
53
53
|
"Use safe lowercase ids made from letters, numbers, and hyphens.",
|
|
54
54
|
"List dependencies in \"depends_on\"; independent features can run in parallel.",
|
|
55
55
|
"Example: {\"version\":1,\"features\":[{\"id\":\"ui\",\"title\":\"UI\",\"description\":\"Build the interface\",\"depends_on\":[]}]}",
|
|
56
|
+
...(input.replan ? [
|
|
57
|
+
"",
|
|
58
|
+
`# Conflict replan ${input.replan.round}`,
|
|
59
|
+
"",
|
|
60
|
+
`Conflict report: ${input.replan.reportPath}`,
|
|
61
|
+
`Conflicting paths: ${input.replan.conflictPaths.join(", ")}`,
|
|
62
|
+
`Features from the conflicting wave: ${input.replan.waveFeatureIds.join(", ")}`,
|
|
63
|
+
"The previous parallel DAG produced an isolated merge conflict; the live workspace was not changed by that wave.",
|
|
64
|
+
"Read the conflict report and rewrite the Judge artifacts for a safe retry.",
|
|
65
|
+
`Preserve exactly these feature ids: ${input.replan.previousFeatureIds.join(", ")}.`,
|
|
66
|
+
"Do not add, remove, or rename Features during this replan.",
|
|
67
|
+
"Use depends_on to serialize the Features from the conflicting wave so they no longer execute together.",
|
|
68
|
+
"Keep unrelated safe Features parallel when their dependencies allow it."
|
|
69
|
+
] : []),
|
|
56
70
|
"",
|
|
57
71
|
"User request:",
|
|
58
72
|
input.request,
|
|
@@ -10,13 +10,21 @@ const LIVE_COMMIT_PROTOCOL = "atomic-claim-v1";
|
|
|
10
10
|
export class WorkspaceMergeConflictError extends Error {
|
|
11
11
|
paths;
|
|
12
12
|
conflictDir;
|
|
13
|
-
|
|
13
|
+
wave;
|
|
14
|
+
featureId;
|
|
15
|
+
waveFeatureIds;
|
|
16
|
+
integratedFeatureIds;
|
|
17
|
+
constructor(paths, conflictDir, context = {}) {
|
|
14
18
|
const count = paths.length;
|
|
15
19
|
super(`Workspace integration conflict in ${count} ${count === 1 ? "path" : "paths"}: ${paths.join(", ")}. `
|
|
16
20
|
+ `The live workspace was not changed. Conflict evidence: ${conflictDir}`);
|
|
17
21
|
this.name = "WorkspaceMergeConflictError";
|
|
18
22
|
this.paths = paths;
|
|
19
23
|
this.conflictDir = conflictDir;
|
|
24
|
+
this.wave = context.wave;
|
|
25
|
+
this.featureId = context.featureId;
|
|
26
|
+
this.waveFeatureIds = [...(context.waveFeatureIds ?? [])];
|
|
27
|
+
this.integratedFeatureIds = [...(context.integratedFeatureIds ?? [])];
|
|
20
28
|
}
|
|
21
29
|
}
|
|
22
30
|
export class WorkspaceLiveMutationError extends Error {
|
|
@@ -163,7 +171,7 @@ export class ParallelWorkspaceManager {
|
|
|
163
171
|
await rm(wave.integrationDir, { recursive: true, force: true });
|
|
164
172
|
await rm(wave.verificationDir, { recursive: true, force: true });
|
|
165
173
|
await cloneTree(wave.baselineDir, wave.stagingDir);
|
|
166
|
-
for (const featureId of wave.featureIds) {
|
|
174
|
+
for (const [featureIndex, featureId] of wave.featureIds.entries()) {
|
|
167
175
|
const featureDir = wave.featureDirs.get(featureId);
|
|
168
176
|
if (!featureDir) {
|
|
169
177
|
throw new Error(`Feature workspace missing for ${featureId}`);
|
|
@@ -171,7 +179,12 @@ export class ParallelWorkspaceManager {
|
|
|
171
179
|
const featureConflictDir = join(wave.conflictDir, featureId);
|
|
172
180
|
const plan = await planWorkspaceMerge(wave.baselineDir, featureDir, wave.stagingDir, featureConflictDir, (path) => this.excludeRelativePath(path));
|
|
173
181
|
if (plan.conflicts.length > 0) {
|
|
174
|
-
throw new WorkspaceMergeConflictError(plan.conflicts, featureConflictDir
|
|
182
|
+
throw new WorkspaceMergeConflictError(plan.conflicts, featureConflictDir, {
|
|
183
|
+
wave: wave.wave,
|
|
184
|
+
featureId,
|
|
185
|
+
waveFeatureIds: wave.featureIds,
|
|
186
|
+
integratedFeatureIds: wave.featureIds.slice(0, featureIndex)
|
|
187
|
+
});
|
|
175
188
|
}
|
|
176
189
|
await applyMergePlan(wave.stagingDir, plan);
|
|
177
190
|
}
|
|
@@ -187,6 +200,29 @@ export class ParallelWorkspaceManager {
|
|
|
187
200
|
});
|
|
188
201
|
return { changedPaths };
|
|
189
202
|
}
|
|
203
|
+
async discardWavesFrom(turnId, firstWave) {
|
|
204
|
+
if (!/^\d{4,}$/.test(turnId)) {
|
|
205
|
+
throw new Error(`Unsafe workspace turn id: ${turnId}`);
|
|
206
|
+
}
|
|
207
|
+
if (!Number.isInteger(firstWave) || firstWave < 1) {
|
|
208
|
+
throw new Error(`Workspace wave must be a positive integer: ${firstWave}`);
|
|
209
|
+
}
|
|
210
|
+
const turnRoot = join(this.taskDir, "workspaces", `turn-${turnId}`);
|
|
211
|
+
if (!(await pathIsDirectory(turnRoot))) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const entry of await readdir(turnRoot, { withFileTypes: true })) {
|
|
215
|
+
if (!entry.isDirectory()) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const match = entry.name.match(/^wave-(\d+)$/);
|
|
219
|
+
if (match && Number(match[1] ?? 0) >= firstWave) {
|
|
220
|
+
await rm(join(turnRoot, entry.name), { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await rm(join(turnRoot, "final-verification"), { recursive: true, force: true });
|
|
224
|
+
await rm(join(turnRoot, "final-verification.json"), { force: true });
|
|
225
|
+
}
|
|
190
226
|
async prepareVerificationWorkspace(wave) {
|
|
191
227
|
await rm(wave.verificationDir, { recursive: true, force: true });
|
|
192
228
|
await cloneTree(wave.integrationDir, wave.verificationDir);
|
package/dist/tui/App.js
CHANGED
|
@@ -19,7 +19,7 @@ import { TerminalOutput } from "./TerminalOutput.js";
|
|
|
19
19
|
import { NativeTerminalScreen } from "./terminal-screen.js";
|
|
20
20
|
import { WorkerOutputView } from "./WorkerOutputView.js";
|
|
21
21
|
import { compactEndByDisplayWidth, displayWidth, wrapByDisplayWidth } from "./display-width.js";
|
|
22
|
-
import { isAttachShortcut, isCopyShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, rawPlainArrowDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
|
|
22
|
+
import { isAttachShortcut, isCopyShortcut, isDiagnosticsShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, rawPlainArrowDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
|
|
23
23
|
import { createRawInputDecoder, tokenizeRawInput } from "./raw-input-decoder.js";
|
|
24
24
|
import { decodeHtmlEntities } from "./markdown-text.js";
|
|
25
25
|
import { configureTuiTheme, TUI_THEME } from "./theme.js";
|
|
@@ -48,7 +48,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
|
|
|
48
48
|
errorOffsets: [],
|
|
49
49
|
diffOffsets: []
|
|
50
50
|
};
|
|
51
|
-
export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
|
|
51
|
+
export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
|
|
52
52
|
configureTuiTheme({
|
|
53
53
|
theme: config.ui.theme,
|
|
54
54
|
colors: config.ui.colors
|
|
@@ -123,6 +123,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
123
123
|
const nativeAttachRef = useRef(nativeAttach);
|
|
124
124
|
const nativeAttachRequestSequenceRef = useRef(0);
|
|
125
125
|
const clipboardNoticeTimerRef = useRef(null);
|
|
126
|
+
const diagnosticsExportingRef = useRef(false);
|
|
126
127
|
const copyTextByViewRef = useRef({ chat: "", worker: "" });
|
|
127
128
|
const messagesRef = useRef([...initialMessages]);
|
|
128
129
|
const activeRunControllerRef = useRef(null);
|
|
@@ -185,6 +186,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
185
186
|
const refreshTaskSessionDetailsRef = useRef(refreshTaskSessionDetails);
|
|
186
187
|
const openTaskSessionDetailWorkerRef = useRef(openTaskSessionDetailWorker);
|
|
187
188
|
const copyVisibleViewRef = useRef(copyVisibleView);
|
|
189
|
+
const exportDiagnosticsRef = useRef(exportDiagnosticsBundle);
|
|
188
190
|
const workspaceReturnViewRef = useRef("chat");
|
|
189
191
|
const routerReturnViewRef = useRef("chat");
|
|
190
192
|
const workerOverviewReturnViewRef = useRef("chat");
|
|
@@ -372,6 +374,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
372
374
|
refreshTaskSessionDetailsRef.current = refreshTaskSessionDetails;
|
|
373
375
|
openTaskSessionDetailWorkerRef.current = openTaskSessionDetailWorker;
|
|
374
376
|
copyVisibleViewRef.current = copyVisibleView;
|
|
377
|
+
exportDiagnosticsRef.current = exportDiagnosticsBundle;
|
|
375
378
|
});
|
|
376
379
|
useEffect(() => {
|
|
377
380
|
if ((view !== "collaboration" && view !== "features") || !activeTaskId || !loadCollaborationTimeline) {
|
|
@@ -696,6 +699,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
696
699
|
void copyVisibleViewRef.current();
|
|
697
700
|
return;
|
|
698
701
|
}
|
|
702
|
+
if (currentView !== "workspace"
|
|
703
|
+
&& currentView !== "native"
|
|
704
|
+
&& tokenizeRawInput(chunk).some((inputChunk) => isDiagnosticsShortcut(inputChunk, {}))) {
|
|
705
|
+
void exportDiagnosticsRef.current();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
699
708
|
if (currentView === "chat" && routeFallbackPromptRef.current) {
|
|
700
709
|
const fallbackChunks = tokenizeRawInput(chunk);
|
|
701
710
|
if (fallbackChunks.some((fallbackChunk) => isExitShortcut(fallbackChunk, {}))) {
|
|
@@ -1555,6 +1564,46 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
1555
1564
|
setAttachError(`Copy failed · ${error instanceof Error ? error.message : String(error)}`);
|
|
1556
1565
|
}
|
|
1557
1566
|
}
|
|
1567
|
+
async function exportDiagnosticsBundle() {
|
|
1568
|
+
if (diagnosticsExportingRef.current) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
if (!exportDiagnostics) {
|
|
1572
|
+
setAttachError("Diagnostics export is unavailable");
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
diagnosticsExportingRef.current = true;
|
|
1576
|
+
if (clipboardNoticeTimerRef.current) {
|
|
1577
|
+
clearTimeout(clipboardNoticeTimerRef.current);
|
|
1578
|
+
clipboardNoticeTimerRef.current = null;
|
|
1579
|
+
}
|
|
1580
|
+
setAttachError(null);
|
|
1581
|
+
setClipboardNotice({ state: "copying", text: "exporting sanitized diagnostics" });
|
|
1582
|
+
try {
|
|
1583
|
+
const path = await exportDiagnostics();
|
|
1584
|
+
if (!mountedRef.current) {
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
setClipboardNotice({ state: "copied", text: `diagnostics exported · ${path}` });
|
|
1588
|
+
clipboardNoticeTimerRef.current = setTimeout(() => {
|
|
1589
|
+
if (!mountedRef.current) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
clipboardNoticeTimerRef.current = null;
|
|
1593
|
+
setClipboardNotice(null);
|
|
1594
|
+
}, 2600);
|
|
1595
|
+
}
|
|
1596
|
+
catch (error) {
|
|
1597
|
+
if (!mountedRef.current) {
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
setClipboardNotice(null);
|
|
1601
|
+
setAttachError(`Diagnostics export failed · ${error instanceof Error ? error.message : String(error)}`);
|
|
1602
|
+
}
|
|
1603
|
+
finally {
|
|
1604
|
+
diagnosticsExportingRef.current = false;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1558
1607
|
function visibleClipboardPayload(currentView) {
|
|
1559
1608
|
if (currentView === "chat") {
|
|
1560
1609
|
return { label: "chat", text: copyTextByViewRef.current.chat };
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -415,7 +415,8 @@ function workerInputHints(width) {
|
|
|
415
415
|
}
|
|
416
416
|
function statusDetailInputHints(width) {
|
|
417
417
|
return selectInputHints(width, [
|
|
418
|
-
{ label: "status", detail: " · ^S/Esc back · ^C exit" },
|
|
418
|
+
{ label: "status", detail: " · ^X diagnostics · ^S/Esc back · ^C exit" },
|
|
419
|
+
{ label: "status", detail: " · ^X diag · ^S/Esc back · ^C exit" },
|
|
419
420
|
{ label: "status", detail: " · ^S back · ^C exit" },
|
|
420
421
|
{ label: "status", detail: " · ^S back" },
|
|
421
422
|
{ label: "status", detail: "" },
|
package/dist/tui/keyboard.js
CHANGED
|
@@ -34,6 +34,9 @@ export function isWorkerSearchShortcut(input, key) {
|
|
|
34
34
|
export function isCopyShortcut(input, key) {
|
|
35
35
|
return (key.ctrl === true && input.toLowerCase() === "y") || input === "\u0019";
|
|
36
36
|
}
|
|
37
|
+
export function isDiagnosticsShortcut(input, key) {
|
|
38
|
+
return (key.ctrl === true && input.toLowerCase() === "x") || input === "\u0018";
|
|
39
|
+
}
|
|
37
40
|
export function workerLogJumpKind(input) {
|
|
38
41
|
if (input === "e" || input === "E") {
|
|
39
42
|
return "error";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.
|
|
1
|
+
export const version = "0.2.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parallel-codex-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,8 +41,14 @@
|
|
|
41
41
|
"postbuild": "node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
42
42
|
"prepack": "npm run build",
|
|
43
43
|
"pretest": "node scripts/fix-node-pty-permissions.mjs",
|
|
44
|
+
"pretest:stability": "node scripts/fix-node-pty-permissions.mjs",
|
|
45
|
+
"pretest:terminal-hosts": "node scripts/fix-node-pty-permissions.mjs",
|
|
46
|
+
"pretest:real-agents": "node scripts/fix-node-pty-permissions.mjs",
|
|
44
47
|
"verify:package": "node scripts/verify-package.mjs",
|
|
45
48
|
"test": "vitest run",
|
|
49
|
+
"test:stability": "vitest run tests/cli-worker-history-smoke.test.ts tests/cli-workspace-switch-smoke.test.ts tests/cli-orphan-process-recovery-smoke.test.ts tests/cli-startup-recovery-smoke.test.ts",
|
|
50
|
+
"test:terminal-hosts": "vitest run tests/cli-terminal-host-compat-smoke.test.ts",
|
|
51
|
+
"test:real-agents": "PCT_REAL_AGENT_TESTS=1 vitest run tests/cli-real-multi-workspace.test.ts",
|
|
46
52
|
"test:watch": "vitest",
|
|
47
53
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
|
48
54
|
"lint": "tsc --noEmit -p tsconfig.typecheck.json"
|