parallel-codex-tui 0.3.0 → 0.3.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 +19 -11
- package/dist/cli.js +3 -1
- package/dist/core/collaboration-timeline.js +3 -1
- package/dist/core/role-configuration.js +47 -8
- package/dist/core/session-index.js +123 -10
- package/dist/core/session-manager.js +15 -4
- package/dist/core/task-report.js +510 -0
- package/dist/core/task-search.js +142 -0
- package/dist/domain/schemas.js +9 -0
- package/dist/orchestrator/collaboration-channel.js +11 -3
- package/dist/orchestrator/orchestrator.js +117 -38
- package/dist/tui/App.js +125 -14
- package/dist/tui/FeatureBoardView.js +4 -1
- package/dist/tui/InputBar.js +22 -7
- package/dist/tui/StatusDetailView.js +27 -6
- package/dist/tui/TaskSessionsView.js +30 -9
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,13 @@ Built with Codex-assisted development.
|
|
|
6
6
|
|
|
7
7
|
## Current Release
|
|
8
8
|
|
|
9
|
-
`v0.3.
|
|
9
|
+
`v0.3.2` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.3.2) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.3.2). In the Task session center, `Ctrl+F` now searches durable evidence across Tasks and Turns, Feature names, roles, Providers, models, Worker states, summaries, and native session ids. Filters such as `turn:"中文输入" role:actor provider:codex state:done` can be combined with ordinary Unicode text; live async results reject stale responses, Enter keeps the filter, Esc restores the previously applied query, and `X` clears it.
|
|
10
|
+
|
|
11
|
+
Every Task export now includes human-readable `report.md` and structured `report.json` beside the complete raw `session/` snapshot. Reports preserve each Turn request and route, Judge requirements/plans/acceptance criteria, supervisor summaries, Feature assignments and latest review evidence, Wave verification results, Worker Provider/model/state, active native session metadata, completion contracts, and final acceptance evidence. Integrated changed paths are compared with the current workspace as `match`, `drift`, `missing`, `unexpected`, or `unavailable`; the newest integrated wave for each path is authoritative, deleted paths are represented correctly, and unsafe or symlink-traversing paths are never read.
|
|
12
|
+
|
|
13
|
+
`Ctrl+E` opens runtime role/model control for Main, Judge, Actor, and Critic. The complete matrix can apply once to the next request, persist for retries and follow-ups in the current Task, or become the default for future requests. Saving validates every selected Provider before it writes configuration, and concurrent TUI instances atomically consume a one-shot matrix exactly once.
|
|
14
|
+
|
|
15
|
+
Feature assignment now controls both Provider and model. From a retryable Feature, press `M`, use `A`/`C` to cycle the Actor/Critic Provider, and use `1`/`2` to edit the Actor/Critic model. A model change starts a distinct Worker identity, keeps all earlier logs and native sessions, and remains an explicit Feature override when Task defaults change. Status details show separate `actual`, `next`, and `future` role matrices alongside the selected historical Worker's persisted model.
|
|
10
16
|
|
|
11
17
|
`Ctrl+T`, then `C`, opens every persisted Main conversation and provides the same durable management lifecycle as Task sessions: rename, archive, unarchive, scoped export, and confirmed deletion. Restoring one switches the exact `conversation_id`, reloads only that conversation's file-backed history, and restores its archived native Codex or Claude session ids when they are available. `Ctrl+N` creates an explicit, durable Main conversation boundary without deleting chat, Tasks, or Worker logs.
|
|
12
18
|
|
|
@@ -14,7 +20,7 @@ A real Claude acceptance retires the first native session, inserts 16 newer reco
|
|
|
14
20
|
|
|
15
21
|
Complex task memory now retains the root Turn plus the latest 19 previous Turn summaries within a 12,000-character budget. When a longer task omits intermediate summaries inline, the prompt points the Worker to the complete immutable Turn history on disk. Main chat still extracts only the final Codex answer while retaining the complete CLI transcript in `output.log`; legacy transcript-shaped chat records are cleaned when displayed without rewriting their file-backed evidence. Workspace integration ignores known host metadata such as `.DS_Store` and AppleDouble files while continuing to reject real concurrent project edits.
|
|
16
22
|
|
|
17
|
-
The default role map is Main/Judge/Actor on Codex and Critic on Claude. Status details show the
|
|
23
|
+
The default role map is Main/Judge/Actor on Codex and Critic on Claude. Status details show the actual, next, and future role maps separately from each historical Worker's persisted Provider/model. Config monitoring labels Router-only edits as active on the next request and marks role, Worker, model, permission, or UI changes as requiring restart. Claude text/JSON print runs are recognized as buffered work, use the total deadline instead of a false first-output deadline, and run safe isolated tools through non-interactive `auto` permissions. The `v0.2.4` non-interactive Codex permission placement remains included. The release keeps terminal scrolling and copying available at the same time without requiring Shift and preserves the embedded native Agent scrollback across status-detail round trips and real PTY resizes.
|
|
18
24
|
|
|
19
25
|
Highlights:
|
|
20
26
|
|
|
@@ -27,7 +33,7 @@ Highlights:
|
|
|
27
33
|
- 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.
|
|
28
34
|
- 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.
|
|
29
35
|
|
|
30
|
-
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. The deterministic repository suite contains 1,
|
|
36
|
+
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. The deterministic repository suite contains 1,349 tests across 138 files: 1,348 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
|
|
31
37
|
|
|
32
38
|
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.
|
|
33
39
|
|
|
@@ -420,15 +426,15 @@ critic = "anthropic_compat"
|
|
|
420
426
|
|
|
421
427
|
## Runtime Role Control
|
|
422
428
|
|
|
423
|
-
While the TUI is idle, press `Ctrl+E` to configure Main, Judge, Actor, and Critic without editing TOML or restarting. Up/Down selects a role, Left/Right (or `[`/`]`) cycles its configured Worker Provider, `M` edits the model name with a Unicode-safe cursor, and `Tab` changes the persistence scope. Press Enter to
|
|
429
|
+
While the TUI is idle, press `Ctrl+E` to configure Main, Judge, Actor, and Critic without editing TOML or restarting. Up/Down selects a role, Left/Right (or `[`/`]`) cycles its configured Worker Provider, `M` edits the model name with a Unicode-safe cursor, and `Tab` changes the persistence scope. Press Enter to validate every selected CLI, environment, capability contract, and proxy before saving the complete matrix. `X` clears the saved override and inherits its parent scope, `R` discards unsaved edits, and `Esc` or `Ctrl+E` returns. `Ctrl+Y` copies the visible matrix. A Provider switch resets that role to the Provider's configured default model; a blank model edit also means the Provider default.
|
|
424
430
|
|
|
425
431
|
The three scopes are explicit:
|
|
426
432
|
|
|
427
|
-
- `next request` is stored in the current workspace at `.parallel-codex/role-configuration.next.json`, overrides every role once, and is removed only when
|
|
428
|
-
- `current task` is stored beside that Task's `meta.json`, applies to retries and same-Task follow-ups, and updates unfinished Feature assignments without deleting earlier Workers or logs.
|
|
433
|
+
- `next request` is stored in the current workspace at `.parallel-codex/role-configuration.next.json`, overrides every role once, and is removed only when one routed request atomically claims it. Concurrent TUI instances cannot both consume it.
|
|
434
|
+
- `current task` is stored beside that Task's `meta.json`, applies to retries and same-Task follow-ups, and updates inherited unfinished Feature assignments without deleting earlier Workers or logs. Explicit Feature overrides remain unchanged.
|
|
429
435
|
- `future requests` is stored in the app root at `.parallel-codex/role-configuration.json` and becomes the default for new Main calls and Tasks across restarts.
|
|
430
436
|
|
|
431
|
-
Every complex Turn writes the exact consumed matrix to `turns/<turn>/role-configuration.json`. Retries use that evidence instead of silently adopting a pending one-shot selection, native attach uses the selected Worker's persisted Provider/model, and status details show the
|
|
437
|
+
Every complex Turn writes the exact consumed matrix to `turns/<turn>/role-configuration.json`. Retries use that evidence instead of silently adopting a pending one-shot selection, native attach uses the selected Worker's persisted Provider/model, and status details show the actual Turn, next-request, and future-default matrices separately from historical Worker identity.
|
|
432
438
|
|
|
433
439
|
For a third-party command with its own syntax, inherit `generic` so parallel-codex-tui does not inject `--sandbox`, `--permission-mode`, or other built-in-only flags:
|
|
434
440
|
|
|
@@ -477,11 +483,13 @@ In chat, scroll long conversation history with the mouse wheel or PageUp/PageDow
|
|
|
477
483
|
|
|
478
484
|
From Worker overview, press `F` to open the file-backed Feature board. It summarizes every planned feature by turn, state, blocked dependencies and open Critic findings while preserving descriptions and Actor replies from the same collaboration files. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select a feature; Enter opens the selected feature's collaboration timeline; `R` refreshes immediately; and `Esc` returns to Worker overview. When a timeline was opened from this board, `Esc` returns to the Feature board instead of skipping back to Workers. While the selected feature is running, press `P` twice to pause only its active Actor or Critic process, or press `X` twice to cancel it; the first press opens a confirmation and `Esc` keeps it running. A paused feature preserves completed peers and same-wave checkpoints; select it and press `Ctrl+R` to resume the same task, turn, and native Worker session. After cancellation, already-running peers finish, queued workers stop, and integration remains blocked, so a partial wave never reaches the live workspace. Once the task settles as cancelled, `Ctrl+R` retries the cancelled task with persisted worker sessions; completed peers are loaded from their same-wave checkpoints instead of running again. Checkpoint load, reuse, and recovery events appear in the collaboration timeline.
|
|
479
485
|
|
|
480
|
-
For a failed, cancelled, or paused task, select an unfinished Feature and press `M` to edit its
|
|
486
|
+
For a failed, cancelled, or paused task, select an unfinished Feature and press `M` to edit its assignment. `A` cycles that Feature's Actor through every `assignable` Worker Provider, `C` cycles its Critic, `1` edits the Actor model, `2` edits the Critic model, and `M` or `Esc` closes the prompt. The choice is persisted in `features/<turn>-<feature>/assignment.json`, appears on the Feature board and collaboration artifacts, and takes effect on the next `Ctrl+R` retry. A changed Provider or model starts a distinct Worker while every previous Worker log and native-session record remains available. Feature overrides survive later Task-default changes. Assignment writes hold the same task lease as execution, so another live TUI cannot change a Feature while it is resuming.
|
|
481
487
|
|
|
482
488
|
From Worker overview, press `C` to open the file-backed Actor/Critic collaboration timeline. It merges `dialogue/actor-critic.jsonl`, feature status, Critic findings, Actor replies, finding resolution, and Wave events into one chronological view. The timeline follows new file evidence automatically and shows verified `fixed`/`open` counts when resolution evidence exists. Up/Down selects a collaboration event; `Enter` opens its complete event detail, including artifact paths from dialogue, status, Critic findings, Actor replies, and finding resolution; `U` filters to unresolved feature evidence; `Tab` cycles all features and each individual feature; `R` refreshes immediately; the mouse wheel or PageUp/PageDown scrolls history; and `Esc` returns to Worker overview.
|
|
483
489
|
|
|
484
|
-
`Ctrl+T` opens the workspace's persisted Task sessions without exiting the outer TUI. The list shows status, creation time, turn and Worker counts, plus the number of distinct native sessions after deduplicating reused `engine + session_id` bindings; `>` marks the selected row and `*` marks the active Task. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select, Enter to restore, `C` to open Main conversations, `I` to inspect the complete session hierarchy, `R` to rename with Unicode-safe cursor editing, `A` to archive or unarchive, `D` twice to confirm deletion, `E` to export, `H` to show or hide archived sessions, `Ctrl+N` to leave the active Task and start a fresh Main conversation, and `Esc` to return without losing the chat draft.
|
|
490
|
+
`Ctrl+T` opens the workspace's persisted Task sessions without exiting the outer TUI. The list shows status, creation time, turn and Worker counts, plus the number of distinct native sessions after deduplicating reused `engine + session_id` bindings; `>` marks the selected row and `*` marks the active Task. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select, Enter to restore, `Ctrl+F` to search, `C` to open Main conversations, `I` to inspect the complete session hierarchy, `R` to rename with Unicode-safe cursor editing, `A` to archive or unarchive, `D` twice to confirm deletion, `E` to export, `H` to show or hide archived sessions, `Ctrl+N` to leave the active Task and start a fresh Main conversation, and `Esc` to return without losing the chat draft. Search accepts plain Unicode text plus `task:`, `turn:`, `feature:`, `role:`, `provider:`, `model:`, and `state:` filters; quote a value containing spaces. Search is an AND across terms and reads the rebuildable SQLite evidence index rather than scanning lifetime Worker logs. Archived sessions are hidden by default, cannot be restored until unarchived, and are excluded from automatic startup selection.
|
|
491
|
+
|
|
492
|
+
Archive, delete, and export require a terminal Task with no live task lease; the active Task cannot be archived or deleted. Exports are complete file-backed snapshots under `.parallel-codex/exports/<task>-<timestamp>/` with a versioned manifest, `report.md`, `report.json`, the raw `session/`, and no transient lease files. The report compares every authoritative integrated path with the current workspace while blocking relative-path escape and parent-symlink traversal. Restoring a Task reloads its route, Workers, retry state, and recorded native session ids. The selected active Task is stored in `session-index.sqlite` and survives process and workspace switches; `Ctrl+N` persists an intentionally empty active-Task context instead of reopening an older Task on restart. SQLite schema changes run as ordered, transactional migrations. The current index stores bounded Turn request text and each Worker's Feature, role, Provider, model, state, and native session binding for structured search. Existing catalogs receive a pre-migration snapshot, each successful startup/reindex refreshes a healthy backup, and an integrity failure restores that backup or rebuilds the catalog from authoritative Task files while preserving the corrupt copy for inspection. `meta.json`, Turn files, and Worker files remain the rebuild authority.
|
|
485
493
|
|
|
486
494
|
The Main conversation list shows each scoped title, message count, distinct native session count, last activity, current `*` marker, and selected `>` marker. Use the same Up/Down, PageUp/PageDown, mouse-wheel, or `Tab` navigation; Enter restores the selected conversation, `R` renames it with Unicode-safe cursor editing, `A` archives or unarchives it, `D` twice confirms deletion, `E` exports it, `H` shows or hides archived conversations, `N` starts a new one, `T` returns to Tasks, and `Esc` returns to chat. Archived conversations are hidden by default and cannot be restored until unarchived; the current Main conversation cannot be archived or deleted. A Main export is a versioned, conversation-scoped snapshot under `.parallel-codex/exports/<conversation>-<timestamp>/` containing only that conversation's chat rows and matching native sessions. Confirmed deletion streams the shared chat JSONL into an atomic replacement, preserves Task chat and malformed evidence, and restores the original chat and conversation archive if publication fails. Restoration preserves every Task and Worker log, switches `conversation.json`, and rebuilds Main history from records carrying only the selected `conversation_id` (or the pre-boundary legacy scope). Before switching, active Main native sessions are archived and retired; archived bindings for the selected conversation are then restored for best-effort native resume. A brand-new empty legacy scope is not added to the catalog.
|
|
487
495
|
|
|
@@ -537,12 +545,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
|
|
|
537
545
|
To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
|
|
538
546
|
|
|
539
547
|
```bash
|
|
540
|
-
VERSION=0.3.
|
|
548
|
+
VERSION=0.3.2
|
|
541
549
|
git tag "v$VERSION"
|
|
542
550
|
git push origin "v$VERSION"
|
|
543
551
|
```
|
|
544
552
|
|
|
545
|
-
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.3.
|
|
553
|
+
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.3.2` requires tag `v0.3.2`. Published tags such as `v0.2.10` are immutable and must not be moved or reused.
|
|
546
554
|
|
|
547
555
|
## Publishing Hygiene
|
|
548
556
|
|
package/dist/cli.js
CHANGED
|
@@ -128,7 +128,9 @@ async function main() {
|
|
|
128
128
|
records,
|
|
129
129
|
policy: routerDiagnosticsPolicy(latestConfig.router)
|
|
130
130
|
};
|
|
131
|
-
}, loadTaskSessions: (options) =>
|
|
131
|
+
}, loadTaskSessions: (options) => options?.query?.trim()
|
|
132
|
+
? state.runtime.index.searchTasks(options.query, 100, options)
|
|
133
|
+
: state.runtime.index.listTasks(100, options), loadMainConversations: (options) => state.runtime.sessions.listMainConversations(100, options), activateMainConversation: async (conversationId) => {
|
|
132
134
|
const restored = await state.runtime.sessions.activateMainConversation(conversationId);
|
|
133
135
|
await state.runtime.index.setActiveTaskId(null);
|
|
134
136
|
return restored;
|
|
@@ -133,7 +133,9 @@ async function readCollaborationFeatures(taskDir) {
|
|
|
133
133
|
updatedAt: status.updated_at,
|
|
134
134
|
...(assignment ? {
|
|
135
135
|
actorEngine: assignment.actor_engine,
|
|
136
|
-
criticEngine: assignment.critic_engine
|
|
136
|
+
criticEngine: assignment.critic_engine,
|
|
137
|
+
actorModel: assignment.actor_model,
|
|
138
|
+
criticModel: assignment.critic_model
|
|
137
139
|
} : {}),
|
|
138
140
|
findings: findingEvidence.count,
|
|
139
141
|
replies: replyEvidence.count,
|
|
@@ -2,11 +2,12 @@ import { readdir } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { pathExists, readJson, removeIfExists, writeJson } from "./file-store.js";
|
|
5
|
-
import {
|
|
5
|
+
import { claimTaskRunLease, TaskRunLeaseConflictError } from "./process-ownership.js";
|
|
6
|
+
import { EngineNameSchema, WorkerModelNameSchema } from "../domain/schemas.js";
|
|
6
7
|
export const CONFIGURABLE_ROLES = ["main", "judge", "actor", "critic"];
|
|
7
8
|
const RoleExecutionTargetSchema = z.object({
|
|
8
9
|
engine: EngineNameSchema,
|
|
9
|
-
model:
|
|
10
|
+
model: WorkerModelNameSchema
|
|
10
11
|
}).strict();
|
|
11
12
|
const RoleExecutionSelectionSchema = z.object({
|
|
12
13
|
main: RoleExecutionTargetSchema,
|
|
@@ -67,7 +68,7 @@ export class RoleConfigurationManager {
|
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
69
70
|
if (scope === "next") {
|
|
70
|
-
await writeRoleConfiguration(this.nextPath(), parsed);
|
|
71
|
+
await this.withNextConfigurationLease(() => writeRoleConfiguration(this.nextPath(), parsed));
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
73
74
|
if (!taskDir) {
|
|
@@ -83,7 +84,7 @@ export class RoleConfigurationManager {
|
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
if (scope === "next") {
|
|
86
|
-
await removeIfExists(this.nextPath());
|
|
87
|
+
await this.withNextConfigurationLease(() => removeIfExists(this.nextPath()));
|
|
87
88
|
return;
|
|
88
89
|
}
|
|
89
90
|
if (!taskDir) {
|
|
@@ -93,12 +94,14 @@ export class RoleConfigurationManager {
|
|
|
93
94
|
}
|
|
94
95
|
async selectionForRequest(taskDir) {
|
|
95
96
|
const taskSelection = await this.selectionForTask(taskDir);
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
return this.withNextConfigurationLease(async () => {
|
|
98
|
+
const next = await readRoleConfigurationIfValid(this.nextPath(), this.config);
|
|
99
|
+
if (!next) {
|
|
100
|
+
return taskSelection;
|
|
101
|
+
}
|
|
98
102
|
await removeIfExists(this.nextPath());
|
|
99
103
|
return next;
|
|
100
|
-
}
|
|
101
|
-
return taskSelection;
|
|
104
|
+
});
|
|
102
105
|
}
|
|
103
106
|
async selectionForTask(taskDir) {
|
|
104
107
|
const taskSelection = taskDir
|
|
@@ -126,12 +129,45 @@ export class RoleConfigurationManager {
|
|
|
126
129
|
baselineRoles() {
|
|
127
130
|
return cloneRoleSelection(this.baselineSelection);
|
|
128
131
|
}
|
|
132
|
+
validate(roles) {
|
|
133
|
+
return parseRoleSelection(roles, this.config);
|
|
134
|
+
}
|
|
135
|
+
workspaceRootPath() {
|
|
136
|
+
return this.workspaceRoot;
|
|
137
|
+
}
|
|
129
138
|
futurePath() {
|
|
130
139
|
return roleFutureConfigurationPath(this.appRoot, this.config.dataDir);
|
|
131
140
|
}
|
|
132
141
|
nextPath() {
|
|
133
142
|
return roleNextConfigurationPath(this.workspaceRoot, this.config.dataDir);
|
|
134
143
|
}
|
|
144
|
+
nextLeaseDir() {
|
|
145
|
+
return join(this.workspaceRoot, this.config.dataDir, ".role-configuration-next-lock");
|
|
146
|
+
}
|
|
147
|
+
async withNextConfigurationLease(run) {
|
|
148
|
+
let lease = null;
|
|
149
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
150
|
+
try {
|
|
151
|
+
lease = await claimTaskRunLease(this.nextLeaseDir());
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (!(error instanceof TaskRunLeaseConflictError) || attempt === 79) {
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
await delay(25);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!lease) {
|
|
162
|
+
throw new Error("Timed out waiting to update the next-request role configuration.");
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return await run();
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await lease.release();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
135
171
|
async readLatestTurnSelection(taskDir) {
|
|
136
172
|
const turnsDir = join(taskDir, "turns");
|
|
137
173
|
if (!(await pathExists(turnsDir))) {
|
|
@@ -236,3 +272,6 @@ function applyRoleEngines(config, roles) {
|
|
|
236
272
|
config.pairing[role] = roles[role].engine;
|
|
237
273
|
}
|
|
238
274
|
}
|
|
275
|
+
function delay(milliseconds) {
|
|
276
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
277
|
+
}
|
|
@@ -3,7 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { NativeSessionSchema, RetiredNativeSessionSchema, RouteDecisionSchema, TaskIdSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
5
5
|
import { ensureDir, pathExists, readJson, readTextIfExists } from "./file-store.js";
|
|
6
|
-
|
|
6
|
+
import { matchTaskSearchDocument, parseTaskSearchQuery } from "./task-search.js";
|
|
7
|
+
const SESSION_INDEX_SCHEMA_VERSION = 3;
|
|
7
8
|
const SESSION_INDEX_FILENAME = "session-index.sqlite";
|
|
8
9
|
export class SessionIndex {
|
|
9
10
|
db;
|
|
@@ -86,24 +87,33 @@ export class SessionIndex {
|
|
|
86
87
|
archived_at=excluded.archived_at`)
|
|
87
88
|
.run(task.id, task.title, task.created_at, task.cwd, task.mode, task.status, task.archived_at ?? null);
|
|
88
89
|
}
|
|
89
|
-
async upsertTurn(taskId, turn) {
|
|
90
|
+
async upsertTurn(taskId, turn, request = "") {
|
|
90
91
|
this.db
|
|
91
|
-
.prepare(`INSERT INTO turns (task_id, turn_id, created_at, request_path)
|
|
92
|
-
VALUES (?, ?, ?, ?)
|
|
92
|
+
.prepare(`INSERT INTO turns (task_id, turn_id, created_at, request_path, request_text)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?)
|
|
93
94
|
ON CONFLICT(task_id, turn_id) DO UPDATE SET
|
|
94
95
|
created_at=excluded.created_at,
|
|
95
|
-
request_path=excluded.request_path
|
|
96
|
-
|
|
96
|
+
request_path=excluded.request_path,
|
|
97
|
+
request_text=CASE
|
|
98
|
+
WHEN excluded.request_text <> '' THEN excluded.request_text
|
|
99
|
+
ELSE turns.request_text
|
|
100
|
+
END`)
|
|
101
|
+
.run(taskId, turn.turn_id, turn.created_at, turn.request_path, indexedRequestText(request));
|
|
97
102
|
}
|
|
98
103
|
async upsertWorker(taskId, status, paths) {
|
|
99
104
|
this.db
|
|
100
105
|
.prepare(`INSERT INTO workers (
|
|
101
|
-
task_id, worker_id,
|
|
106
|
+
task_id, worker_id, feature_id, feature_title, role, engine, model_name, model_provider,
|
|
107
|
+
state, phase, summary, status_path, output_log_path, dir, native_session_id
|
|
102
108
|
)
|
|
103
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
104
110
|
ON CONFLICT(task_id, worker_id) DO UPDATE SET
|
|
111
|
+
feature_id=excluded.feature_id,
|
|
112
|
+
feature_title=excluded.feature_title,
|
|
105
113
|
role=excluded.role,
|
|
106
114
|
engine=excluded.engine,
|
|
115
|
+
model_name=excluded.model_name,
|
|
116
|
+
model_provider=excluded.model_provider,
|
|
107
117
|
state=excluded.state,
|
|
108
118
|
phase=excluded.phase,
|
|
109
119
|
summary=excluded.summary,
|
|
@@ -111,7 +121,7 @@ export class SessionIndex {
|
|
|
111
121
|
output_log_path=excluded.output_log_path,
|
|
112
122
|
dir=excluded.dir,
|
|
113
123
|
native_session_id=excluded.native_session_id`)
|
|
114
|
-
.run(taskId, status.worker_id, status.role, status.engine, status.state, status.phase, status.summary, paths.statusPath, paths.outputLogPath, paths.dir, status.native_session_id ?? null);
|
|
124
|
+
.run(taskId, status.worker_id, status.feature_id ?? null, status.feature_title ?? null, status.role, status.engine, status.model_name ?? null, status.model_provider ?? null, status.state, status.phase, status.summary, paths.statusPath, paths.outputLogPath, paths.dir, status.native_session_id ?? null);
|
|
115
125
|
}
|
|
116
126
|
async upsertNativeSession(taskId, record) {
|
|
117
127
|
this.db
|
|
@@ -198,6 +208,89 @@ export class SessionIndex {
|
|
|
198
208
|
}];
|
|
199
209
|
});
|
|
200
210
|
}
|
|
211
|
+
async searchTasks(query, limit = 100, options = {}) {
|
|
212
|
+
const boundedLimit = Number.isFinite(limit)
|
|
213
|
+
? Math.min(500, Math.max(0, Math.trunc(limit)))
|
|
214
|
+
: 100;
|
|
215
|
+
if (boundedLimit === 0) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const terms = parseTaskSearchQuery(query);
|
|
219
|
+
const tasks = await this.listTasks(500, options);
|
|
220
|
+
if (terms.length === 0) {
|
|
221
|
+
return tasks.slice(0, boundedLimit).map((task) => ({
|
|
222
|
+
...task,
|
|
223
|
+
searchMatch: { fields: [], summary: "" }
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
const documents = new Map(tasks.map((task) => [task.id, {
|
|
227
|
+
task: {
|
|
228
|
+
id: task.id,
|
|
229
|
+
title: task.title,
|
|
230
|
+
cwd: task.cwd,
|
|
231
|
+
mode: task.mode,
|
|
232
|
+
state: task.status
|
|
233
|
+
},
|
|
234
|
+
turns: [],
|
|
235
|
+
workers: [],
|
|
236
|
+
nativeSessions: []
|
|
237
|
+
}]));
|
|
238
|
+
const archivedFilter = options.includeArchived ? "" : "WHERE tasks.archived_at IS NULL";
|
|
239
|
+
const turns = this.db.prepare(`SELECT turns.task_id, turns.turn_id, turns.request_text
|
|
240
|
+
FROM turns
|
|
241
|
+
INNER JOIN tasks ON tasks.id = turns.task_id
|
|
242
|
+
${archivedFilter}`).all();
|
|
243
|
+
for (const turn of turns) {
|
|
244
|
+
documents.get(turn.task_id)?.turns.push({
|
|
245
|
+
turnId: turn.turn_id,
|
|
246
|
+
request: turn.request_text
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const workers = this.db.prepare(`SELECT
|
|
250
|
+
workers.task_id,
|
|
251
|
+
workers.worker_id,
|
|
252
|
+
workers.feature_id,
|
|
253
|
+
workers.feature_title,
|
|
254
|
+
workers.role,
|
|
255
|
+
workers.engine,
|
|
256
|
+
workers.model_name,
|
|
257
|
+
workers.model_provider,
|
|
258
|
+
workers.state,
|
|
259
|
+
workers.phase,
|
|
260
|
+
workers.summary
|
|
261
|
+
FROM workers
|
|
262
|
+
INNER JOIN tasks ON tasks.id = workers.task_id
|
|
263
|
+
${archivedFilter}`).all();
|
|
264
|
+
for (const worker of workers) {
|
|
265
|
+
documents.get(worker.task_id)?.workers.push({
|
|
266
|
+
id: worker.worker_id,
|
|
267
|
+
featureId: worker.feature_id ?? "",
|
|
268
|
+
featureTitle: worker.feature_title ?? "",
|
|
269
|
+
role: worker.role,
|
|
270
|
+
provider: worker.engine,
|
|
271
|
+
model: worker.model_name ?? "",
|
|
272
|
+
modelProvider: worker.model_provider ?? "",
|
|
273
|
+
state: worker.state,
|
|
274
|
+
phase: worker.phase,
|
|
275
|
+
summary: worker.summary
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const nativeSessions = this.db.prepare(`SELECT native_sessions.task_id, native_sessions.session_id, native_sessions.engine
|
|
279
|
+
FROM native_sessions
|
|
280
|
+
INNER JOIN tasks ON tasks.id = native_sessions.task_id
|
|
281
|
+
${archivedFilter}`).all();
|
|
282
|
+
for (const session of nativeSessions) {
|
|
283
|
+
documents.get(session.task_id)?.nativeSessions.push({
|
|
284
|
+
sessionId: session.session_id,
|
|
285
|
+
provider: session.engine
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return tasks.flatMap((task) => {
|
|
289
|
+
const document = documents.get(task.id);
|
|
290
|
+
const searchMatch = document ? matchTaskSearchDocument(terms, document) : null;
|
|
291
|
+
return searchMatch ? [{ ...task, searchMatch }] : [];
|
|
292
|
+
}).slice(0, boundedLimit);
|
|
293
|
+
}
|
|
201
294
|
async activeTaskId() {
|
|
202
295
|
const row = this.db
|
|
203
296
|
.prepare("SELECT value FROM workspace_state WHERE key = 'active_task_id'")
|
|
@@ -293,14 +386,19 @@ export class SessionIndex {
|
|
|
293
386
|
turn_id TEXT NOT NULL,
|
|
294
387
|
created_at TEXT NOT NULL,
|
|
295
388
|
request_path TEXT NOT NULL,
|
|
389
|
+
request_text TEXT NOT NULL DEFAULT '',
|
|
296
390
|
PRIMARY KEY (task_id, turn_id)
|
|
297
391
|
);
|
|
298
392
|
|
|
299
393
|
CREATE TABLE IF NOT EXISTS workers (
|
|
300
394
|
task_id TEXT NOT NULL,
|
|
301
395
|
worker_id TEXT NOT NULL,
|
|
396
|
+
feature_id TEXT,
|
|
397
|
+
feature_title TEXT,
|
|
302
398
|
role TEXT NOT NULL,
|
|
303
399
|
engine TEXT NOT NULL,
|
|
400
|
+
model_name TEXT,
|
|
401
|
+
model_provider TEXT,
|
|
304
402
|
state TEXT NOT NULL,
|
|
305
403
|
phase TEXT NOT NULL,
|
|
306
404
|
summary TEXT NOT NULL,
|
|
@@ -335,6 +433,14 @@ export class SessionIndex {
|
|
|
335
433
|
this.ensureColumn("tasks", "archived_at", "TEXT");
|
|
336
434
|
return;
|
|
337
435
|
}
|
|
436
|
+
if (targetVersion === 3) {
|
|
437
|
+
this.ensureColumn("turns", "request_text", "TEXT NOT NULL DEFAULT ''");
|
|
438
|
+
this.ensureColumn("workers", "feature_id", "TEXT");
|
|
439
|
+
this.ensureColumn("workers", "feature_title", "TEXT");
|
|
440
|
+
this.ensureColumn("workers", "model_name", "TEXT");
|
|
441
|
+
this.ensureColumn("workers", "model_provider", "TEXT");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
338
444
|
throw new Error(`Missing session index migration for schema v${targetVersion}`);
|
|
339
445
|
}
|
|
340
446
|
async writeBackup(targetPath) {
|
|
@@ -380,7 +486,7 @@ export class SessionIndex {
|
|
|
380
486
|
&& turn.task_id === taskId
|
|
381
487
|
&& turn.turn_id === turnEntry.name
|
|
382
488
|
&& turn.request_path === `turns/${turnEntry.name}/user.md`) {
|
|
383
|
-
await this.upsertTurn(taskId, turn);
|
|
489
|
+
await this.upsertTurn(taskId, turn, request);
|
|
384
490
|
}
|
|
385
491
|
}
|
|
386
492
|
}
|
|
@@ -535,6 +641,13 @@ async function replaceFile(source, target) {
|
|
|
535
641
|
await rename(source, target);
|
|
536
642
|
}
|
|
537
643
|
}
|
|
644
|
+
function indexedRequestText(request) {
|
|
645
|
+
const clean = request
|
|
646
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
647
|
+
.trim();
|
|
648
|
+
const points = Array.from(clean);
|
|
649
|
+
return points.length > 32768 ? points.slice(0, 32768).join("") : clean;
|
|
650
|
+
}
|
|
538
651
|
async function readJsonIfValid(path, schema) {
|
|
539
652
|
if (!(await pathExists(path))) {
|
|
540
653
|
return null;
|
|
@@ -11,6 +11,7 @@ import { formatTaskTimestamp, taskDir, taskSessionIdIsValid } from "./paths.js";
|
|
|
11
11
|
import { sessionsRoot } from "./paths.js";
|
|
12
12
|
import { loadCollaborationTimeline } from "./collaboration-timeline.js";
|
|
13
13
|
import { taskStateTransitionAllowed } from "./task-state-machine.js";
|
|
14
|
+
import { buildTaskReport } from "./task-report.js";
|
|
14
15
|
import { processIsAlive, readProcessStartToken } from "./process-identity.js";
|
|
15
16
|
import { claimTaskRunLease, inspectTaskRunLease, TaskRunLeaseConflictError, terminateOwnedWorkerProcess } from "./process-ownership.js";
|
|
16
17
|
import { ChatRecordSchema, ConversationIdSchema, MainConversationArchiveSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
@@ -113,7 +114,7 @@ export class SessionManager {
|
|
|
113
114
|
await rename(stagingDir, finalDir);
|
|
114
115
|
published = true;
|
|
115
116
|
await this.updateTaskStatus(task, "routed");
|
|
116
|
-
await this.index?.upsertTurn(task.id, await readJson(join(task.dir, "turns", turn.turnId, "turn.json"), TurnMetaSchema));
|
|
117
|
+
await this.index?.upsertTurn(task.id, await readJson(join(task.dir, "turns", turn.turnId, "turn.json"), TurnMetaSchema), input.request);
|
|
117
118
|
await this.index?.setActiveTaskId(id);
|
|
118
119
|
if (!options.retainCreationClaim) {
|
|
119
120
|
await removeIfExists(creation.claimPath);
|
|
@@ -633,11 +634,21 @@ export class SessionManager {
|
|
|
633
634
|
const destination = join(exportsRoot, `${taskId}-${stamp}-${suffix}`);
|
|
634
635
|
try {
|
|
635
636
|
await this.appendEvent(task, "task.exported", "Task session exported");
|
|
637
|
+
const taskReport = await buildTaskReport({
|
|
638
|
+
task: meta,
|
|
639
|
+
taskDir: task.dir,
|
|
640
|
+
workspaceRoot: this.projectRoot,
|
|
641
|
+
generatedAt: createdAt
|
|
642
|
+
});
|
|
643
|
+
await writeJson(join(staging, "report.json"), taskReport.report);
|
|
644
|
+
await writeText(join(staging, "report.md"), taskReport.markdown);
|
|
636
645
|
await writeJson(join(staging, "manifest.json"), {
|
|
637
646
|
format: "parallel-codex-task-export-v1",
|
|
638
647
|
exported_at: createdAt,
|
|
639
648
|
source_workspace: this.projectRoot,
|
|
640
649
|
session_path: "session",
|
|
650
|
+
report_path: "report.md",
|
|
651
|
+
report_json_path: "report.json",
|
|
641
652
|
task: await this.readMeta(task)
|
|
642
653
|
});
|
|
643
654
|
await cp(task.dir, join(staging, "session"), {
|
|
@@ -1583,7 +1594,7 @@ export class SessionManager {
|
|
|
1583
1594
|
await this.index.upsertTask(meta);
|
|
1584
1595
|
const snapshot = await this.readCompleteTaskCreation(this.taskFromId(taskId).dir, taskId);
|
|
1585
1596
|
if (snapshot) {
|
|
1586
|
-
await this.index.upsertTurn(taskId, snapshot.turn);
|
|
1597
|
+
await this.index.upsertTurn(taskId, snapshot.turn, await readTextIfExists(join(this.taskFromId(taskId).dir, snapshot.turn.request_path)));
|
|
1587
1598
|
}
|
|
1588
1599
|
}
|
|
1589
1600
|
async quarantinePendingTaskCreation(pending) {
|
|
@@ -1637,7 +1648,7 @@ export class SessionManager {
|
|
|
1637
1648
|
&& pendingMeta.request_path === `turns/${pending.turnId}/user.md`);
|
|
1638
1649
|
if (request && pendingRoute && pendingMeta && metaMatches) {
|
|
1639
1650
|
await rename(pending.dir, files.dir);
|
|
1640
|
-
await this.index?.upsertTurn(task.id, pendingMeta);
|
|
1651
|
+
await this.index?.upsertTurn(task.id, pendingMeta, request);
|
|
1641
1652
|
await this.appendEvent(task, "turn.recovered_after_restart", `Published complete pending turn ${pending.turnId} after restart`);
|
|
1642
1653
|
summary.published += 1;
|
|
1643
1654
|
continue;
|
|
@@ -1716,7 +1727,7 @@ export class SessionManager {
|
|
|
1716
1727
|
}
|
|
1717
1728
|
}
|
|
1718
1729
|
if (projectIndex) {
|
|
1719
|
-
await this.index?.upsertTurn(task.id, turnMeta);
|
|
1730
|
+
await this.index?.upsertTurn(task.id, turnMeta, request);
|
|
1720
1731
|
}
|
|
1721
1732
|
return files;
|
|
1722
1733
|
}
|