parallel-codex-tui 0.1.0 → 0.1.4
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 +90 -3
- package/README.md +269 -12
- package/dist/bootstrap.js +50 -18
- package/dist/cli-args.js +96 -14
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-recovery.js +70 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +40 -0
- package/dist/cli.js +291 -35
- package/dist/core/app-root.js +8 -0
- package/dist/core/collaboration-timeline.js +261 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +191 -23
- package/dist/core/file-store.js +130 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +10 -0
- package/dist/core/process-identity.js +48 -0
- package/dist/core/process-mutation-turn.js +128 -0
- package/dist/core/process-ownership.js +276 -0
- package/dist/core/process-tree.js +90 -0
- package/dist/core/router-audit.js +155 -0
- package/dist/core/router-redaction.js +31 -0
- package/dist/core/router.js +473 -42
- package/dist/core/session-index.js +225 -30
- package/dist/core/session-manager.js +1182 -44
- package/dist/core/task-state-machine.js +17 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +126 -0
- package/dist/doctor.js +384 -30
- package/dist/domain/schemas.js +127 -6
- package/dist/orchestrator/collaboration-channel.js +255 -4
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +1777 -212
- package/dist/orchestrator/prompts.js +126 -2
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +911 -0
- package/dist/tui/App.js +2838 -159
- package/dist/tui/AppShell.js +188 -23
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +227 -0
- package/dist/tui/InputBar.js +514 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/TaskSessionsView.js +207 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1403 -161
- package/dist/tui/WorkerOverviewView.js +250 -0
- package/dist/tui/chat-history.js +25 -0
- package/dist/tui/chat-input.js +67 -19
- package/dist/tui/chat-paste.js +76 -0
- package/dist/tui/display-width.js +41 -3
- package/dist/tui/incremental-text-file.js +101 -0
- package/dist/tui/keyboard.js +46 -0
- package/dist/tui/markdown-text.js +14 -0
- package/dist/tui/raw-input-decoder.js +3 -0
- package/dist/tui/scrolling.js +2 -1
- package/dist/tui/status-line.js +318 -11
- package/dist/tui/task-memory.js +15 -0
- package/dist/tui/task-result.js +105 -0
- package/dist/tui/terminal-screen.js +13 -1
- package/dist/tui/theme-contrast.js +144 -0
- package/dist/tui/theme-preview.js +109 -0
- package/dist/tui/theme.js +158 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +212 -0
- package/dist/workers/live-probe.js +176 -0
- package/dist/workers/mock-adapter.js +39 -6
- package/dist/workers/native-attach.js +147 -8
- package/dist/workers/native-session-detection.js +17 -0
- package/dist/workers/process-adapter.js +580 -81
- package/dist/workers/registry.js +4 -2
- package/package.json +17 -2
|
@@ -1,26 +1,73 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { appendJsonLine, appendText, ensureDir, pathExists, readJson, readRecentJsonLines, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
|
|
6
|
+
import { runWithLeaseFinalization } from "./lease-finalization.js";
|
|
7
|
+
import { formatTaskTimestamp, taskDir, taskSessionIdIsValid } from "./paths.js";
|
|
5
8
|
import { sessionsRoot } from "./paths.js";
|
|
6
|
-
import {
|
|
9
|
+
import { loadCollaborationTimeline } from "./collaboration-timeline.js";
|
|
10
|
+
import { taskStateTransitionAllowed } from "./task-state-machine.js";
|
|
11
|
+
import { processIsAlive, readProcessStartToken } from "./process-identity.js";
|
|
12
|
+
import { claimTaskRunLease, inspectTaskRunLease, TaskRunLeaseConflictError, terminateOwnedWorkerProcess } from "./process-ownership.js";
|
|
13
|
+
import { ChatRecordSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
14
|
+
export class InterruptedTaskRecoveryBlockedError extends Error {
|
|
15
|
+
taskId;
|
|
16
|
+
blocks;
|
|
17
|
+
constructor(taskId, blocks, subject = `task ${taskId}`) {
|
|
18
|
+
const details = blocks
|
|
19
|
+
.map((block) => `${block.workerId} (${block.reason}; ${block.processPath})`)
|
|
20
|
+
.join(", ");
|
|
21
|
+
const stateLabel = subject === `task ${taskId}` ? "Task" : subject;
|
|
22
|
+
super(`Startup recovery blocked for ${subject}: ${details}. `
|
|
23
|
+
+ `${stateLabel} state and checkpoints were left unchanged to prevent concurrent workers. `
|
|
24
|
+
+ "Verify or stop each recorded process, then restart parallel-codex-tui.");
|
|
25
|
+
this.taskId = taskId;
|
|
26
|
+
this.blocks = blocks;
|
|
27
|
+
this.name = "InterruptedTaskRecoveryBlockedError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const TERMINAL_TASK_STATES = new Set(["done", "failed", "cancelled"]);
|
|
31
|
+
const ACTIVE_WORKER_STATES = new Set(["idle", "starting", "running", "waiting"]);
|
|
32
|
+
const ACTIVE_FEATURE_STATES = new Set([
|
|
33
|
+
"queued",
|
|
34
|
+
"actor_running",
|
|
35
|
+
"actor_done",
|
|
36
|
+
"critic_running",
|
|
37
|
+
"critic_done",
|
|
38
|
+
"revision_needed",
|
|
39
|
+
"integrating",
|
|
40
|
+
"verifying"
|
|
41
|
+
]);
|
|
42
|
+
const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
|
|
43
|
+
const PENDING_TASK_CREATION_CLAIM = /^\.(task-.+)\.creating\.json$/;
|
|
44
|
+
const TaskCreationOwnerSchema = z.object({
|
|
45
|
+
version: z.literal(1),
|
|
46
|
+
task_id: TaskIdSchema,
|
|
47
|
+
pid: z.number().int().positive(),
|
|
48
|
+
started_at: z.string().datetime(),
|
|
49
|
+
process_start_token: z.string().min(1).optional()
|
|
50
|
+
});
|
|
7
51
|
export class SessionManager {
|
|
8
52
|
projectRoot;
|
|
9
53
|
dataDir;
|
|
10
54
|
now;
|
|
11
55
|
randomId;
|
|
12
56
|
index;
|
|
57
|
+
claimTaskRunLease;
|
|
13
58
|
constructor(options) {
|
|
14
59
|
this.projectRoot = options.projectRoot;
|
|
15
60
|
this.dataDir = options.dataDir;
|
|
16
61
|
this.now = options.now ?? (() => new Date());
|
|
17
62
|
this.randomId = options.randomId ?? (() => Math.random().toString(16).slice(2, 6));
|
|
18
63
|
this.index = options.index;
|
|
64
|
+
this.claimTaskRunLease = options.claimTaskRunLease ?? claimTaskRunLease;
|
|
19
65
|
}
|
|
20
|
-
async createTask(input) {
|
|
66
|
+
async createTask(input, options = {}) {
|
|
21
67
|
const createdAt = this.now();
|
|
22
|
-
const
|
|
23
|
-
const
|
|
68
|
+
const baseId = `task-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
|
|
69
|
+
const creation = await this.claimUniqueTaskDirectory(baseId, createdAt);
|
|
70
|
+
const { taskId: id, stagingDir, finalDir } = creation;
|
|
24
71
|
const meta = {
|
|
25
72
|
id,
|
|
26
73
|
title: titleFromRequest(input.request),
|
|
@@ -29,24 +76,131 @@ export class SessionManager {
|
|
|
29
76
|
mode: input.route.mode,
|
|
30
77
|
status: "created"
|
|
31
78
|
};
|
|
32
|
-
|
|
33
|
-
await writeJson(join(dir, "meta.json"), TaskMetaSchema.parse(meta));
|
|
34
|
-
await writeText(join(dir, "user-request.md"), `${input.request.trim()}\n`);
|
|
35
|
-
await this.index?.upsertTask(meta);
|
|
36
|
-
await writeJson(join(dir, "route.json"), RouteDecisionSchema.parse(input.route));
|
|
37
|
-
await this.writeTurn({ id, dir }, "0001", input.request, input.route, createdAt);
|
|
38
|
-
await this.appendEvent({ id, dir }, "task.created", "Task session created");
|
|
39
|
-
return {
|
|
79
|
+
const stagingTask = {
|
|
40
80
|
id,
|
|
41
|
-
dir,
|
|
42
|
-
metaPath: join(
|
|
43
|
-
routePath: join(
|
|
44
|
-
eventsPath: join(
|
|
81
|
+
dir: stagingDir,
|
|
82
|
+
metaPath: join(stagingDir, "meta.json"),
|
|
83
|
+
routePath: join(stagingDir, "route.json"),
|
|
84
|
+
eventsPath: join(stagingDir, "events.jsonl")
|
|
45
85
|
};
|
|
86
|
+
const task = this.taskFromId(id);
|
|
87
|
+
let published = false;
|
|
88
|
+
try {
|
|
89
|
+
await writeJson(stagingTask.metaPath, TaskMetaSchema.parse(meta));
|
|
90
|
+
await writeText(join(stagingDir, "user-request.md"), `${input.request.trim()}\n`);
|
|
91
|
+
await writeJson(stagingTask.routePath, RouteDecisionSchema.parse(input.route));
|
|
92
|
+
const turn = await this.writeTurn(stagingTask, "0001", input.request, input.route, createdAt, false);
|
|
93
|
+
await this.appendEvent(stagingTask, "task.created", "Task session created");
|
|
94
|
+
await rename(stagingDir, finalDir);
|
|
95
|
+
published = true;
|
|
96
|
+
await this.updateTaskStatus(task, "routed");
|
|
97
|
+
await this.index?.upsertTurn(task.id, await readJson(join(task.dir, "turns", turn.turnId, "turn.json"), TurnMetaSchema));
|
|
98
|
+
await this.index?.setActiveTaskId(id);
|
|
99
|
+
if (!options.retainCreationClaim) {
|
|
100
|
+
await removeIfExists(creation.claimPath);
|
|
101
|
+
}
|
|
102
|
+
return task;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
try {
|
|
106
|
+
if (!published) {
|
|
107
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
await removeIfExists(creation.claimPath);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Startup reconciliation can finish cleanup when immediate cleanup is unavailable.
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async releaseTaskCreationClaim(task) {
|
|
118
|
+
await removeIfExists(this.taskCreationClaimPath(task.id));
|
|
119
|
+
}
|
|
120
|
+
async claimUniqueTaskDirectory(baseId, createdAt) {
|
|
121
|
+
const root = sessionsRoot(this.projectRoot, this.dataDir);
|
|
122
|
+
await ensureDir(root);
|
|
123
|
+
const processStartToken = await readProcessStartToken(process.pid);
|
|
124
|
+
for (let attempt = 1;; attempt += 1) {
|
|
125
|
+
const id = attempt === 1 ? baseId : `${baseId}-${String(attempt).padStart(4, "0")}`;
|
|
126
|
+
const claimPath = this.taskCreationClaimPath(id);
|
|
127
|
+
const stagingDir = join(root, `.${id}.creating`);
|
|
128
|
+
const finalDir = taskDir(this.projectRoot, this.dataDir, id);
|
|
129
|
+
const owner = TaskCreationOwnerSchema.parse({
|
|
130
|
+
version: 1,
|
|
131
|
+
task_id: id,
|
|
132
|
+
pid: process.pid,
|
|
133
|
+
started_at: createdAt.toISOString(),
|
|
134
|
+
...(processStartToken ? { process_start_token: processStartToken } : {})
|
|
135
|
+
});
|
|
136
|
+
if (!(await writeJsonExclusive(claimPath, owner))) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
if (await pathExists(finalDir)) {
|
|
141
|
+
await removeIfExists(claimPath);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
await mkdir(stagingDir);
|
|
145
|
+
return { taskId: id, stagingDir, finalDir, claimPath };
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
await removeIfExists(claimPath);
|
|
149
|
+
if (error.code === "EEXIST") {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
taskCreationClaimPath(taskId) {
|
|
157
|
+
return join(sessionsRoot(this.projectRoot, this.dataDir), `.${taskId}.creating.json`);
|
|
46
158
|
}
|
|
47
159
|
mainSessionDir() {
|
|
48
160
|
return join(this.projectRoot, this.dataDir, "sessions", "main");
|
|
49
161
|
}
|
|
162
|
+
async reconcileInterruptedMainSession() {
|
|
163
|
+
const main = this.taskFromId("main");
|
|
164
|
+
if (!(await pathExists(main.dir))) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
let recoveryLease;
|
|
168
|
+
try {
|
|
169
|
+
recoveryLease = await this.claimTaskRunLease(main.dir);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error instanceof TaskRunLeaseConflictError) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
return runWithLeaseFinalization("Main session startup recovery", recoveryLease, async () => {
|
|
178
|
+
const workers = await this.reconcileTaskWorkers(main, "Main session");
|
|
179
|
+
if (workers.recovered === 0 && workers.terminated === 0) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
await this.appendEvent(main, "main.recovered_after_restart", `Recovered interrupted Main session; ${workers.recovered} active workers marked cancelled and ${workers.terminated} processes terminated`);
|
|
183
|
+
return {
|
|
184
|
+
workersRecovered: workers.recovered,
|
|
185
|
+
processesTerminated: workers.terminated
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
async appendChatMessage(input) {
|
|
190
|
+
const record = ChatRecordSchema.parse({
|
|
191
|
+
time: this.now().toISOString(),
|
|
192
|
+
from: input.from,
|
|
193
|
+
text: input.text,
|
|
194
|
+
task_id: input.taskId
|
|
195
|
+
});
|
|
196
|
+
await appendJsonLine(join(this.mainSessionDir(), "chat.jsonl"), record);
|
|
197
|
+
}
|
|
198
|
+
async readChatHistory(limit = 200) {
|
|
199
|
+
const boundedLimit = Number.isFinite(limit)
|
|
200
|
+
? Math.min(1000, Math.max(0, Math.trunc(limit)))
|
|
201
|
+
: 200;
|
|
202
|
+
return readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, boundedLimit);
|
|
203
|
+
}
|
|
50
204
|
taskFromId(taskId) {
|
|
51
205
|
const dir = taskDir(this.projectRoot, this.dataDir, taskId);
|
|
52
206
|
return {
|
|
@@ -57,6 +211,260 @@ export class SessionManager {
|
|
|
57
211
|
eventsPath: join(dir, "events.jsonl")
|
|
58
212
|
};
|
|
59
213
|
}
|
|
214
|
+
async readCollaborationTimeline(taskId) {
|
|
215
|
+
const task = this.taskFromId(taskId);
|
|
216
|
+
if (!(await this.hasTask(taskId))) {
|
|
217
|
+
throw new Error(`Task session not found: ${taskId}`);
|
|
218
|
+
}
|
|
219
|
+
return loadCollaborationTimeline(taskId, task.dir);
|
|
220
|
+
}
|
|
221
|
+
async hasTask(taskId) {
|
|
222
|
+
if (!taskSessionIdIsValid(taskId) || taskId === "main") {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const task = this.taskFromId(taskId);
|
|
226
|
+
const meta = await readTaskMetaIfValid(task.metaPath);
|
|
227
|
+
return meta?.id === taskId;
|
|
228
|
+
}
|
|
229
|
+
async renameTask(taskId, title) {
|
|
230
|
+
const normalizedTitle = normalizeTaskTitle(title);
|
|
231
|
+
return this.withTaskManagementLease(taskId, "rename", async (task, meta) => {
|
|
232
|
+
const next = TaskMetaSchema.parse({ ...meta, title: normalizedTitle });
|
|
233
|
+
await writeJson(task.metaPath, next);
|
|
234
|
+
await this.index?.upsertTask(next);
|
|
235
|
+
await this.appendEvent(task, "task.renamed", `Task renamed to ${normalizedTitle}`);
|
|
236
|
+
return next;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async setTaskArchived(taskId, archived) {
|
|
240
|
+
return this.withTaskManagementLease(taskId, archived ? "archive" : "unarchive", async (task, meta) => {
|
|
241
|
+
this.assertTerminalTask(meta, archived ? "archive" : "unarchive");
|
|
242
|
+
if (archived && await this.index?.activeTaskId() === taskId) {
|
|
243
|
+
throw new Error(`Cannot archive active task ${taskId}. Start a new task first.`);
|
|
244
|
+
}
|
|
245
|
+
const { archived_at: _archivedAt, ...withoutArchive } = meta;
|
|
246
|
+
const next = TaskMetaSchema.parse(archived
|
|
247
|
+
? { ...withoutArchive, archived_at: this.now().toISOString() }
|
|
248
|
+
: withoutArchive);
|
|
249
|
+
await writeJson(task.metaPath, next);
|
|
250
|
+
await this.index?.upsertTask(next);
|
|
251
|
+
await this.appendEvent(task, archived ? "task.archived" : "task.unarchived", archived ? "Task session archived" : "Task session restored from archive");
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async deleteTask(taskId) {
|
|
256
|
+
if (await this.index?.activeTaskId() === taskId) {
|
|
257
|
+
throw new Error(`Cannot delete active task ${taskId}. Start a new task first.`);
|
|
258
|
+
}
|
|
259
|
+
const deletedDir = await this.withTaskManagementLease(taskId, "delete", async (task, meta) => {
|
|
260
|
+
this.assertTerminalTask(meta, "delete");
|
|
261
|
+
if (await this.index?.activeTaskId() === taskId) {
|
|
262
|
+
throw new Error(`Cannot delete active task ${taskId}. Start a new task first.`);
|
|
263
|
+
}
|
|
264
|
+
const target = join(sessionsRoot(this.projectRoot, this.dataDir), `.${taskId}.deleted-${randomUUID()}`);
|
|
265
|
+
await rename(task.dir, target);
|
|
266
|
+
try {
|
|
267
|
+
await this.index?.deleteTask(taskId);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
try {
|
|
271
|
+
await rename(target, task.dir);
|
|
272
|
+
}
|
|
273
|
+
catch (rollbackError) {
|
|
274
|
+
throw new Error(`Task index deletion failed and session rollback also failed: ${errorMessage(error)}; ${errorMessage(rollbackError)}`, { cause: new AggregateError([error, rollbackError]) });
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
return target;
|
|
279
|
+
});
|
|
280
|
+
await rm(deletedDir, { force: true, recursive: true });
|
|
281
|
+
}
|
|
282
|
+
async exportTask(taskId) {
|
|
283
|
+
return this.withTaskManagementLease(taskId, "export", async (task, meta) => {
|
|
284
|
+
this.assertTerminalTask(meta, "export");
|
|
285
|
+
const createdAt = this.now().toISOString();
|
|
286
|
+
const exportsRoot = join(this.projectRoot, this.dataDir, "exports");
|
|
287
|
+
await ensureDir(exportsRoot);
|
|
288
|
+
const staging = await mkdtemp(join(exportsRoot, `.${taskId}-`));
|
|
289
|
+
const suffix = basename(staging).slice(-6);
|
|
290
|
+
const stamp = createdAt.replace(/[^0-9]/g, "").slice(0, 14);
|
|
291
|
+
const destination = join(exportsRoot, `${taskId}-${stamp}-${suffix}`);
|
|
292
|
+
try {
|
|
293
|
+
await this.appendEvent(task, "task.exported", "Task session exported");
|
|
294
|
+
await writeJson(join(staging, "manifest.json"), {
|
|
295
|
+
format: "parallel-codex-task-export-v1",
|
|
296
|
+
exported_at: createdAt,
|
|
297
|
+
source_workspace: this.projectRoot,
|
|
298
|
+
session_path: "session",
|
|
299
|
+
task: await this.readMeta(task)
|
|
300
|
+
});
|
|
301
|
+
await cp(task.dir, join(staging, "session"), {
|
|
302
|
+
recursive: true,
|
|
303
|
+
preserveTimestamps: true,
|
|
304
|
+
verbatimSymlinks: true,
|
|
305
|
+
filter: (source) => !isTransientTaskLeasePath(task.dir, source)
|
|
306
|
+
});
|
|
307
|
+
await rename(staging, destination);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
await rm(staging, { force: true, recursive: true });
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
return { taskId, path: destination, createdAt };
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
async reconcilePendingTaskCreations() {
|
|
317
|
+
const report = {
|
|
318
|
+
published: 0,
|
|
319
|
+
abandoned: 0,
|
|
320
|
+
active: 0,
|
|
321
|
+
publishedTaskIds: []
|
|
322
|
+
};
|
|
323
|
+
for (const pending of await this.pendingTaskCreationDirectories()) {
|
|
324
|
+
const owner = await readTaskCreationOwnerIfValid(pending.claimPath);
|
|
325
|
+
if (owner?.task_id === pending.taskId && await taskCreationOwnerIsActive(owner)) {
|
|
326
|
+
report.active += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const finalTask = this.taskFromId(pending.taskId);
|
|
330
|
+
const finalMeta = await readTaskMetaIfValid(finalTask.metaPath);
|
|
331
|
+
if (finalMeta) {
|
|
332
|
+
await this.projectPublishedTaskCreation(pending.taskId, finalMeta);
|
|
333
|
+
if (await pathExists(pending.stagingDir)) {
|
|
334
|
+
await this.quarantinePendingTaskCreation(pending);
|
|
335
|
+
report.abandoned += 1;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
report.published += 1;
|
|
339
|
+
report.publishedTaskIds.push(pending.taskId);
|
|
340
|
+
}
|
|
341
|
+
await removeIfExists(pending.claimPath);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const snapshot = await this.readCompletePendingTaskCreation(pending);
|
|
345
|
+
if (snapshot) {
|
|
346
|
+
if (!(await pathExists(pending.finalDir))) {
|
|
347
|
+
try {
|
|
348
|
+
await rename(pending.stagingDir, pending.finalDir);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
const code = error.code;
|
|
352
|
+
if (code !== "ENOENT" && code !== "EEXIST") {
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const publishedMeta = await readTaskMetaIfValid(finalTask.metaPath);
|
|
358
|
+
if (publishedMeta) {
|
|
359
|
+
await removeIfExists(pending.claimPath);
|
|
360
|
+
await this.projectPublishedTaskCreation(pending.taskId, publishedMeta);
|
|
361
|
+
report.published += 1;
|
|
362
|
+
report.publishedTaskIds.push(pending.taskId);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const racedMeta = await readTaskMetaIfValid(finalTask.metaPath);
|
|
367
|
+
if (racedMeta) {
|
|
368
|
+
await removeIfExists(pending.claimPath);
|
|
369
|
+
await this.projectPublishedTaskCreation(pending.taskId, racedMeta);
|
|
370
|
+
report.published += 1;
|
|
371
|
+
report.publishedTaskIds.push(pending.taskId);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
let archived = false;
|
|
375
|
+
if (await pathExists(pending.stagingDir)) {
|
|
376
|
+
archived = await this.quarantinePendingTaskCreation(pending);
|
|
377
|
+
}
|
|
378
|
+
if (!archived) {
|
|
379
|
+
const concurrentlyPublishedMeta = await readTaskMetaIfValid(finalTask.metaPath);
|
|
380
|
+
if (concurrentlyPublishedMeta) {
|
|
381
|
+
await removeIfExists(pending.claimPath);
|
|
382
|
+
await this.projectPublishedTaskCreation(pending.taskId, concurrentlyPublishedMeta);
|
|
383
|
+
report.published += 1;
|
|
384
|
+
report.publishedTaskIds.push(pending.taskId);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
await removeIfExists(pending.claimPath);
|
|
389
|
+
report.abandoned += 1;
|
|
390
|
+
}
|
|
391
|
+
return report;
|
|
392
|
+
}
|
|
393
|
+
async reconcileInterruptedTasks() {
|
|
394
|
+
const root = sessionsRoot(this.projectRoot, this.dataDir);
|
|
395
|
+
if (!(await pathExists(root))) {
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
399
|
+
const recovered = [];
|
|
400
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
401
|
+
if (!entry.isDirectory() || !TaskIdSchema.safeParse(entry.name).success) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const task = this.taskFromId(entry.name);
|
|
405
|
+
if (await this.taskCreationClaimIsActive(task.id)) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const meta = await readTaskMetaIfValid(task.metaPath);
|
|
409
|
+
if (!meta || meta.id !== task.id) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const needsTaskRecovery = await this.taskNeedsRecovery(task, meta);
|
|
413
|
+
const needsTransitionRepair = await this.taskStatusTransitionNeedsRepair(task, meta);
|
|
414
|
+
const needsTurnReconciliation = (await this.pendingTurnDirectories(task)).length > 0;
|
|
415
|
+
if (!needsTaskRecovery && !needsTransitionRepair && !needsTurnReconciliation) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
let recoveryLease;
|
|
419
|
+
try {
|
|
420
|
+
recoveryLease = await this.claimTaskRunLease(task.dir);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
if (error instanceof TaskRunLeaseConflictError) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
const recovery = await runWithLeaseFinalization(`Task ${task.id} startup recovery`, recoveryLease, async () => {
|
|
429
|
+
const claimedMeta = await readTaskMetaIfValid(task.metaPath);
|
|
430
|
+
if (!claimedMeta) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const turns = await this.reconcilePendingTurns(task);
|
|
434
|
+
const claimedNeedsTaskRecovery = await this.taskNeedsRecovery(task, claimedMeta);
|
|
435
|
+
const claimedNeedsTransitionRepair = await this.taskStatusTransitionNeedsRepair(task, claimedMeta);
|
|
436
|
+
if (!claimedNeedsTaskRecovery && !claimedNeedsTransitionRepair) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
if (claimedNeedsTransitionRepair) {
|
|
440
|
+
await this.syncTaskStatusTransition(task, claimedMeta);
|
|
441
|
+
}
|
|
442
|
+
if (!claimedNeedsTaskRecovery) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
const workers = await this.reconcileTaskWorkers(task);
|
|
446
|
+
const featuresRecovered = await this.reconcileTaskFeatures(task);
|
|
447
|
+
await this.updateTaskStatus(task, "cancelled");
|
|
448
|
+
await this.appendEvent(task, claimedMeta.status === "done" ? "task.recovered_incomplete_done" : "task.recovered_after_restart", claimedMeta.status === "done"
|
|
449
|
+
? `Recovered incomplete done task; ${workers.recovered} active workers and ${featuresRecovered} active features marked cancelled, checkpoints preserved`
|
|
450
|
+
: `Recovered interrupted task from ${claimedMeta.status}; ${workers.recovered} active workers and ${featuresRecovered} active features marked cancelled, checkpoints preserved`);
|
|
451
|
+
return {
|
|
452
|
+
taskId: task.id,
|
|
453
|
+
previousState: claimedMeta.status,
|
|
454
|
+
workersRecovered: workers.recovered,
|
|
455
|
+
featuresRecovered,
|
|
456
|
+
processesTerminated: workers.terminated,
|
|
457
|
+
...(turns.published > 0 ? { turnsPublished: turns.published } : {}),
|
|
458
|
+
...(turns.repaired > 0 ? { turnsRepaired: turns.repaired } : {}),
|
|
459
|
+
...(turns.abandoned > 0 ? { turnsAbandoned: turns.abandoned } : {})
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
if (recovery) {
|
|
463
|
+
recovered.push(recovery);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return recovered;
|
|
467
|
+
}
|
|
60
468
|
async latestTask() {
|
|
61
469
|
const root = sessionsRoot(this.projectRoot, this.dataDir);
|
|
62
470
|
if (!(await pathExists(root))) {
|
|
@@ -65,18 +473,22 @@ export class SessionManager {
|
|
|
65
473
|
const entries = await readdir(root, { withFileTypes: true });
|
|
66
474
|
const tasks = [];
|
|
67
475
|
for (const entry of entries) {
|
|
68
|
-
if (!entry.isDirectory() || !entry.name.
|
|
476
|
+
if (!entry.isDirectory() || !TaskIdSchema.safeParse(entry.name).success) {
|
|
69
477
|
continue;
|
|
70
478
|
}
|
|
71
479
|
const metaPath = join(root, entry.name, "meta.json");
|
|
72
480
|
if (await pathExists(metaPath)) {
|
|
73
|
-
const meta = await
|
|
74
|
-
if (meta
|
|
481
|
+
const meta = await readTaskMetaIfValid(metaPath);
|
|
482
|
+
if (!meta) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (meta.id === entry.name && meta.mode === "complex" && !meta.archived_at) {
|
|
75
486
|
tasks.push(meta);
|
|
76
487
|
}
|
|
77
488
|
}
|
|
78
489
|
}
|
|
79
|
-
const latest = tasks.sort((left, right) => left.created_at.localeCompare(right.created_at)
|
|
490
|
+
const latest = tasks.sort((left, right) => (left.created_at.localeCompare(right.created_at)
|
|
491
|
+
|| left.id.localeCompare(right.id))).at(-1);
|
|
80
492
|
if (!latest) {
|
|
81
493
|
return null;
|
|
82
494
|
}
|
|
@@ -95,6 +507,9 @@ export class SessionManager {
|
|
|
95
507
|
const turnId = await this.nextTurnId(task);
|
|
96
508
|
const turn = await this.writeTurn(task, turnId, input.request, input.route, this.now());
|
|
97
509
|
await this.appendEvent(task, "turn.created", `Turn ${turnId} created`);
|
|
510
|
+
if (await readTaskMetaIfValid(task.metaPath)) {
|
|
511
|
+
await this.updateTaskStatus(task, "routed");
|
|
512
|
+
}
|
|
98
513
|
return turn;
|
|
99
514
|
}
|
|
100
515
|
async latestTurn(task) {
|
|
@@ -113,14 +528,59 @@ export class SessionManager {
|
|
|
113
528
|
}
|
|
114
529
|
return this.turnFiles(task, turnId);
|
|
115
530
|
}
|
|
531
|
+
async readLatestRoute(task) {
|
|
532
|
+
const latestTaskRoute = await readRouteDecisionIfValid(join(task.dir, "latest-route.json"));
|
|
533
|
+
if (latestTaskRoute) {
|
|
534
|
+
return latestTaskRoute;
|
|
535
|
+
}
|
|
536
|
+
const latestTurn = await this.latestTurn(task);
|
|
537
|
+
if (latestTurn) {
|
|
538
|
+
const latestRoute = await readRouteDecisionIfValid(latestTurn.routePath);
|
|
539
|
+
if (latestRoute) {
|
|
540
|
+
return latestRoute;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return readRouteDecisionIfValid(task.routePath);
|
|
544
|
+
}
|
|
545
|
+
async recordLatestRoute(task, route) {
|
|
546
|
+
await writeJson(join(task.dir, "latest-route.json"), RouteDecisionSchema.parse(route));
|
|
547
|
+
}
|
|
116
548
|
async readMeta(task) {
|
|
117
549
|
return readJson(task.metaPath, TaskMetaSchema);
|
|
118
550
|
}
|
|
119
551
|
async updateTaskStatus(task, status) {
|
|
120
552
|
const meta = await this.readMeta(task);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
553
|
+
if (meta.status !== status && await this.taskStatusTransitionNeedsRepair(task, meta)) {
|
|
554
|
+
await this.syncTaskStatusTransition(task, meta);
|
|
555
|
+
}
|
|
556
|
+
if (meta.status === status) {
|
|
557
|
+
await this.syncTaskStatusTransition(task, meta);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const completeEvidence = status === "done" || meta.status === "done"
|
|
561
|
+
? await this.hasCompleteTaskEvidence(task)
|
|
562
|
+
: false;
|
|
563
|
+
if (meta.status === "done" && completeEvidence) {
|
|
564
|
+
throw new Error(`Task ${task.id} is completely done and cannot move backward to ${status}.`);
|
|
565
|
+
}
|
|
566
|
+
if (!taskStateTransitionAllowed(meta.status, status)) {
|
|
567
|
+
throw new Error(`Task ${task.id} cannot move from ${meta.status} to ${status}.`);
|
|
568
|
+
}
|
|
569
|
+
if (status === "done" && !completeEvidence) {
|
|
570
|
+
throw new Error(`Task ${task.id} cannot move to done before latest-turn completion evidence is published.`);
|
|
571
|
+
}
|
|
572
|
+
const nextMeta = TaskMetaSchema.parse({
|
|
573
|
+
...meta,
|
|
574
|
+
status,
|
|
575
|
+
status_transition: {
|
|
576
|
+
id: randomUUID(),
|
|
577
|
+
from: meta.status,
|
|
578
|
+
to: status,
|
|
579
|
+
at: this.now().toISOString()
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
await writeJson(task.metaPath, nextMeta);
|
|
583
|
+
await this.syncTaskStatusTransition(task, nextMeta);
|
|
124
584
|
}
|
|
125
585
|
async initializeWorker(task, input) {
|
|
126
586
|
const dir = join(task.dir, input.workerId);
|
|
@@ -129,6 +589,8 @@ export class SessionManager {
|
|
|
129
589
|
const statusPath = join(dir, "status.json");
|
|
130
590
|
const status = {
|
|
131
591
|
worker_id: input.workerId,
|
|
592
|
+
...(input.featureId ? { feature_id: input.featureId } : {}),
|
|
593
|
+
...(input.featureTitle ? { feature_title: input.featureTitle } : {}),
|
|
132
594
|
role: input.role,
|
|
133
595
|
engine: input.engine,
|
|
134
596
|
state: "idle",
|
|
@@ -139,7 +601,13 @@ export class SessionManager {
|
|
|
139
601
|
await ensureDir(dir);
|
|
140
602
|
await this.clearWorkerArtifacts(dir, input.role);
|
|
141
603
|
await writeText(promptPath, input.prompt);
|
|
142
|
-
await
|
|
604
|
+
if (await pathExists(outputLogPath)) {
|
|
605
|
+
const continuation = input.preserveOutput ? "retry" : "resume";
|
|
606
|
+
await appendText(outputLogPath, `\n--- ${continuation} ${this.now().toISOString()} ---\n`);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
await writeText(outputLogPath, "");
|
|
610
|
+
}
|
|
143
611
|
await writeJson(statusPath, status);
|
|
144
612
|
await this.index?.upsertWorker(task.id, status, {
|
|
145
613
|
dir,
|
|
@@ -156,27 +624,110 @@ export class SessionManager {
|
|
|
156
624
|
}
|
|
157
625
|
async readNativeSession(worker) {
|
|
158
626
|
const path = join(worker.dir, "native-session.json");
|
|
627
|
+
const retired = await readRetiredNativeSessionIfValid(join(worker.dir, "native-session.retired.json"));
|
|
159
628
|
if (!(await pathExists(path))) {
|
|
629
|
+
if (retired) {
|
|
630
|
+
await this.finalizeNativeSessionRetirement(worker, retired.session_id);
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
let active;
|
|
635
|
+
try {
|
|
636
|
+
active = await readJson(path, NativeSessionSchema);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
await removeIfExists(path);
|
|
640
|
+
await this.clearWorkerStatusNativeSession(worker);
|
|
641
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
|
|
160
642
|
return null;
|
|
161
643
|
}
|
|
162
|
-
|
|
644
|
+
if (retired?.session_id === active.session_id) {
|
|
645
|
+
await this.finalizeNativeSessionRetirement(worker, active.session_id);
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
await this.syncNativeSessionProjection(worker, active);
|
|
649
|
+
return active;
|
|
650
|
+
}
|
|
651
|
+
async hasRetiredNativeSession(worker) {
|
|
652
|
+
return Boolean(await readRetiredNativeSessionIfValid(join(worker.dir, "native-session.retired.json")));
|
|
653
|
+
}
|
|
654
|
+
async reconcileNativeSessionState() {
|
|
655
|
+
const root = sessionsRoot(this.projectRoot, this.dataDir);
|
|
656
|
+
if (!(await pathExists(root))) {
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
let reconciled = 0;
|
|
660
|
+
const sessionEntries = await readdir(root, { withFileTypes: true });
|
|
661
|
+
for (const sessionEntry of sessionEntries) {
|
|
662
|
+
if (!sessionEntry.isDirectory()
|
|
663
|
+
|| (sessionEntry.name !== "main" && !TaskIdSchema.safeParse(sessionEntry.name).success)) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const sessionDir = join(root, sessionEntry.name);
|
|
667
|
+
const lease = await inspectTaskRunLease(sessionDir);
|
|
668
|
+
if (lease.state === "active") {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const workerEntries = await readdir(sessionDir, { withFileTypes: true });
|
|
672
|
+
for (const workerEntry of workerEntries) {
|
|
673
|
+
if (!workerEntry.isDirectory()) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const worker = { dir: join(sessionDir, workerEntry.name) };
|
|
677
|
+
const activePath = join(worker.dir, "native-session.json");
|
|
678
|
+
const retiredPath = join(worker.dir, "native-session.retired.json");
|
|
679
|
+
const statusPath = join(worker.dir, "status.json");
|
|
680
|
+
const [hasActive, hasRetired, hasStatus] = await Promise.all([
|
|
681
|
+
pathExists(activePath),
|
|
682
|
+
pathExists(retiredPath),
|
|
683
|
+
pathExists(statusPath)
|
|
684
|
+
]);
|
|
685
|
+
if (!hasActive && !hasRetired && !hasStatus) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const retired = await readRetiredNativeSessionIfValid(retiredPath);
|
|
689
|
+
const active = await readNativeSessionIfValid(activePath);
|
|
690
|
+
if (retired && (!active || active.session_id === retired.session_id)) {
|
|
691
|
+
await this.finalizeNativeSessionRetirement(worker, retired.session_id);
|
|
692
|
+
reconciled += 1;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (active) {
|
|
696
|
+
await this.syncNativeSessionProjection(worker, active);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
await removeIfExists(activePath);
|
|
700
|
+
await this.clearWorkerStatusNativeSession(worker);
|
|
701
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return reconciled;
|
|
163
705
|
}
|
|
164
706
|
async writeNativeSession(worker, record) {
|
|
165
|
-
|
|
166
|
-
await
|
|
707
|
+
const active = NativeSessionSchema.parse(record);
|
|
708
|
+
await writeJson(join(worker.dir, "native-session.json"), active);
|
|
709
|
+
await this.syncNativeSessionProjection(worker, active);
|
|
167
710
|
}
|
|
168
711
|
async retireNativeSession(worker, reason) {
|
|
169
712
|
const nativeSessionPath = join(worker.dir, "native-session.json");
|
|
170
713
|
if (!(await pathExists(nativeSessionPath))) {
|
|
171
714
|
return;
|
|
172
715
|
}
|
|
173
|
-
const record = await
|
|
716
|
+
const record = await readNativeSessionIfValid(nativeSessionPath);
|
|
717
|
+
if (!record) {
|
|
718
|
+
await removeIfExists(nativeSessionPath);
|
|
719
|
+
await this.clearWorkerStatusNativeSession(worker);
|
|
720
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
174
723
|
await writeJson(join(worker.dir, "native-session.retired.json"), {
|
|
175
724
|
...record,
|
|
176
725
|
retired_at: this.now().toISOString(),
|
|
177
726
|
retired_reason: reason
|
|
178
727
|
});
|
|
179
728
|
await removeIfExists(nativeSessionPath);
|
|
729
|
+
await this.clearWorkerStatusNativeSession(worker);
|
|
730
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), record.worker_id);
|
|
180
731
|
}
|
|
181
732
|
async appendEvent(task, type, message) {
|
|
182
733
|
const event = {
|
|
@@ -187,6 +738,60 @@ export class SessionManager {
|
|
|
187
738
|
};
|
|
188
739
|
await appendJsonLine(join(task.dir, "events.jsonl"), event);
|
|
189
740
|
}
|
|
741
|
+
async withTaskManagementLease(taskId, operation, run) {
|
|
742
|
+
const id = TaskIdSchema.parse(taskId);
|
|
743
|
+
const task = this.taskFromId(id);
|
|
744
|
+
if (!(await this.hasTask(id))) {
|
|
745
|
+
throw new Error(`Task session not found: ${id}`);
|
|
746
|
+
}
|
|
747
|
+
const lease = await this.claimTaskRunLease(task.dir);
|
|
748
|
+
return runWithLeaseFinalization(`Task ${operation}`, lease, async () => {
|
|
749
|
+
const meta = await this.readMeta(task);
|
|
750
|
+
if (meta.id !== id) {
|
|
751
|
+
throw new Error(`Task session metadata does not match ${id}.`);
|
|
752
|
+
}
|
|
753
|
+
return run(task, meta);
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
assertTerminalTask(meta, operation) {
|
|
757
|
+
if (!TERMINAL_TASK_STATES.has(meta.status)) {
|
|
758
|
+
throw new Error(`Cannot ${operation} task ${meta.id} while it is ${meta.status}.`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async syncTaskStatusTransition(task, meta) {
|
|
762
|
+
const transition = meta.status_transition;
|
|
763
|
+
if (transition && !(await this.hasTaskStatusTransitionEvent(task, transition.id))) {
|
|
764
|
+
const event = EventRecordSchema.parse({
|
|
765
|
+
time: transition.at,
|
|
766
|
+
type: `task.${transition.to}`,
|
|
767
|
+
message: `Task moved from ${transition.from} to ${transition.to}`,
|
|
768
|
+
task_id: task.id,
|
|
769
|
+
transition_id: transition.id,
|
|
770
|
+
from_state: transition.from,
|
|
771
|
+
to_state: transition.to
|
|
772
|
+
});
|
|
773
|
+
await appendJsonLine(task.eventsPath, event);
|
|
774
|
+
}
|
|
775
|
+
await this.index?.upsertTask(meta);
|
|
776
|
+
}
|
|
777
|
+
async hasTaskStatusTransitionEvent(task, transitionId) {
|
|
778
|
+
const events = await readTextIfExists(task.eventsPath);
|
|
779
|
+
for (const line of events.split(/\r?\n/)) {
|
|
780
|
+
if (!line.trim()) {
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const event = EventRecordSchema.safeParse(JSON.parse(line));
|
|
785
|
+
if (event.success && event.data.transition_id === transitionId) {
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// A corrupt audit row does not invalidate later transition evidence.
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
190
795
|
async nextTurnId(task) {
|
|
191
796
|
const latest = await this.latestTurn(task);
|
|
192
797
|
if (!latest) {
|
|
@@ -194,11 +799,336 @@ export class SessionManager {
|
|
|
194
799
|
}
|
|
195
800
|
return String(Number(latest.turnId) + 1).padStart(4, "0");
|
|
196
801
|
}
|
|
802
|
+
async taskNeedsRecovery(task, meta) {
|
|
803
|
+
if (!TERMINAL_TASK_STATES.has(meta.status)) {
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
if (meta.status !== "done" || await this.hasCompleteTaskEvidence(task)) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
const latestTurn = await this.latestTurn(task);
|
|
810
|
+
return Boolean(latestTurn
|
|
811
|
+
&& (latestTurn.turnId !== "0001"
|
|
812
|
+
|| await this.hasIntegratedLatestTurnCheckpoint(task)));
|
|
813
|
+
}
|
|
814
|
+
async taskStatusTransitionNeedsRepair(task, meta) {
|
|
815
|
+
const transitionId = meta.status_transition?.id;
|
|
816
|
+
return Boolean(transitionId && !(await this.hasTaskStatusTransitionEvent(task, transitionId)));
|
|
817
|
+
}
|
|
818
|
+
async hasIntegratedLatestTurnCheckpoint(task) {
|
|
819
|
+
const latestTurn = await this.latestTurn(task);
|
|
820
|
+
if (!latestTurn) {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
const root = join(task.dir, "workspaces", `turn-${latestTurn.turnId}`);
|
|
824
|
+
if (!(await pathExists(root))) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
828
|
+
for (const entry of entries) {
|
|
829
|
+
if (!entry.isDirectory() || !/^wave-\d{4}$/.test(entry.name)) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const value = JSON.parse(await readTextIfExists(join(root, entry.name, "integration.json")));
|
|
834
|
+
if (value
|
|
835
|
+
&& typeof value === "object"
|
|
836
|
+
&& !Array.isArray(value)
|
|
837
|
+
&& value.state === "integrated") {
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// A corrupt integration record is not proof that live commit completed.
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
async hasCompleteTaskEvidence(task) {
|
|
848
|
+
const latestTurn = await this.latestTurn(task);
|
|
849
|
+
if (!latestTurn) {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
if (!(await readTextIfExists(join(latestTurn.dir, "supervisor-summary.md"))).trim()) {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
const featuresRoot = join(task.dir, "features");
|
|
856
|
+
if (!(await pathExists(featuresRoot))) {
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
const entries = await readdir(featuresRoot, { withFileTypes: true });
|
|
860
|
+
for (const entry of entries) {
|
|
861
|
+
if (!entry.isDirectory()) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
const belongsToLatestTurn = entry.name === latestTurn.turnId
|
|
865
|
+
|| entry.name.startsWith(`${latestTurn.turnId}-`);
|
|
866
|
+
const statusPath = join(featuresRoot, entry.name, "status.json");
|
|
867
|
+
try {
|
|
868
|
+
const status = await readJson(statusPath, FeatureStatusSchema);
|
|
869
|
+
const statusIsLatestTurn = status.turn_id === latestTurn.turnId;
|
|
870
|
+
if (belongsToLatestTurn !== statusIsLatestTurn) {
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
if (statusIsLatestTurn
|
|
874
|
+
&& (status.feature_id !== entry.name
|
|
875
|
+
|| status.task_id !== task.id
|
|
876
|
+
|| status.state !== "approved")) {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
if (belongsToLatestTurn) {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
async reconcileTaskWorkers(task, recoverySubject = `task ${task.id}`) {
|
|
889
|
+
const entries = await readdir(task.dir, { withFileTypes: true });
|
|
890
|
+
const workerDirs = entries
|
|
891
|
+
.filter((entry) => entry.isDirectory())
|
|
892
|
+
.map((entry) => ({ workerId: entry.name, dir: join(task.dir, entry.name) }));
|
|
893
|
+
let terminated = 0;
|
|
894
|
+
const blocks = [];
|
|
895
|
+
for (const worker of workerDirs) {
|
|
896
|
+
const dir = worker.dir;
|
|
897
|
+
const processResult = await terminateOwnedWorkerProcess(dir);
|
|
898
|
+
if (processResult === "terminated") {
|
|
899
|
+
terminated += 1;
|
|
900
|
+
}
|
|
901
|
+
if (processResult === "unverifiable" || processResult === "still-running") {
|
|
902
|
+
blocks.push({
|
|
903
|
+
workerId: worker.workerId,
|
|
904
|
+
processPath: join(dir, "process.json"),
|
|
905
|
+
reason: processResult
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (blocks.length > 0) {
|
|
910
|
+
await this.appendEvent(task, task.id === "main" ? "main.recovery_blocked" : "task.recovery_blocked", `Startup recovery blocked by ${blocks.map((block) => `${block.workerId}:${block.reason}`).join(", ")}; ${recoverySubject} state left unchanged`);
|
|
911
|
+
throw new InterruptedTaskRecoveryBlockedError(task.id, blocks, recoverySubject);
|
|
912
|
+
}
|
|
913
|
+
let recovered = 0;
|
|
914
|
+
for (const worker of workerDirs) {
|
|
915
|
+
const dir = worker.dir;
|
|
916
|
+
const statusPath = join(dir, "status.json");
|
|
917
|
+
const status = await readWorkerStatusIfValid(statusPath);
|
|
918
|
+
if (!status || !ACTIVE_WORKER_STATES.has(status.state)) {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const nextStatus = WorkerStatusSchema.parse({
|
|
922
|
+
...status,
|
|
923
|
+
state: "cancelled",
|
|
924
|
+
phase: "orphaned-after-restart",
|
|
925
|
+
last_event_at: this.now().toISOString(),
|
|
926
|
+
summary: "Previous TUI exited before this worker finished; checkpoint is ready to retry"
|
|
927
|
+
});
|
|
928
|
+
await writeJson(statusPath, nextStatus);
|
|
929
|
+
const outputLogPath = join(dir, "output.log");
|
|
930
|
+
await appendText(outputLogPath, "\nRecovered after previous TUI exit; worker marked cancelled for checkpoint retry.\n");
|
|
931
|
+
await this.index?.upsertWorker(task.id, nextStatus, { dir, statusPath, outputLogPath });
|
|
932
|
+
recovered += 1;
|
|
933
|
+
}
|
|
934
|
+
return { recovered, terminated };
|
|
935
|
+
}
|
|
936
|
+
async reconcileTaskFeatures(task) {
|
|
937
|
+
const root = join(task.dir, "features");
|
|
938
|
+
if (!(await pathExists(root))) {
|
|
939
|
+
return 0;
|
|
940
|
+
}
|
|
941
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
942
|
+
let recovered = 0;
|
|
943
|
+
for (const entry of entries) {
|
|
944
|
+
if (!entry.isDirectory()) {
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const statusPath = join(root, entry.name, "status.json");
|
|
948
|
+
if (!(await pathExists(statusPath))) {
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
const status = await readJson(statusPath, FeatureStatusSchema);
|
|
953
|
+
if (!ACTIVE_FEATURE_STATES.has(status.state)) {
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
await writeJson(statusPath, FeatureStatusSchema.parse({
|
|
957
|
+
...status,
|
|
958
|
+
state: "cancelled",
|
|
959
|
+
updated_at: this.now().toISOString()
|
|
960
|
+
}));
|
|
961
|
+
recovered += 1;
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
// Corrupt feature evidence must not prevent other task checkpoints from being recovered.
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return recovered;
|
|
968
|
+
}
|
|
197
969
|
async indexTaskFromFiles(task) {
|
|
198
970
|
if (!this.index || !(await pathExists(task.metaPath))) {
|
|
199
971
|
return;
|
|
200
972
|
}
|
|
201
|
-
|
|
973
|
+
const meta = await readTaskMetaIfValid(task.metaPath);
|
|
974
|
+
if (meta?.id === task.id) {
|
|
975
|
+
await this.index.upsertTask(meta);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async pendingTaskCreationDirectories() {
|
|
979
|
+
const root = sessionsRoot(this.projectRoot, this.dataDir);
|
|
980
|
+
if (!(await pathExists(root))) {
|
|
981
|
+
return [];
|
|
982
|
+
}
|
|
983
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
984
|
+
return entries.flatMap((entry) => {
|
|
985
|
+
const match = entry.isFile() ? entry.name.match(PENDING_TASK_CREATION_CLAIM) : null;
|
|
986
|
+
if (!match?.[1]) {
|
|
987
|
+
return [];
|
|
988
|
+
}
|
|
989
|
+
const taskId = match[1];
|
|
990
|
+
if (!TaskIdSchema.safeParse(taskId).success) {
|
|
991
|
+
return [];
|
|
992
|
+
}
|
|
993
|
+
return [{
|
|
994
|
+
taskId,
|
|
995
|
+
stagingDir: join(root, `.${taskId}.creating`),
|
|
996
|
+
finalDir: taskDir(this.projectRoot, this.dataDir, taskId),
|
|
997
|
+
claimPath: join(root, entry.name)
|
|
998
|
+
}];
|
|
999
|
+
}).sort((left, right) => left.taskId.localeCompare(right.taskId));
|
|
1000
|
+
}
|
|
1001
|
+
async taskCreationClaimIsActive(taskId) {
|
|
1002
|
+
const owner = await readTaskCreationOwnerIfValid(this.taskCreationClaimPath(taskId));
|
|
1003
|
+
return Boolean(owner?.task_id === taskId && await taskCreationOwnerIsActive(owner));
|
|
1004
|
+
}
|
|
1005
|
+
async readCompletePendingTaskCreation(pending) {
|
|
1006
|
+
return this.readCompleteTaskCreation(pending.stagingDir, pending.taskId);
|
|
1007
|
+
}
|
|
1008
|
+
async readCompleteTaskCreation(directory, taskId) {
|
|
1009
|
+
try {
|
|
1010
|
+
const firstTurnDir = join(directory, "turns", "0001");
|
|
1011
|
+
const [meta, route, request, turn, turnRoute, turnRequest] = await Promise.all([
|
|
1012
|
+
readTaskMetaIfValid(join(directory, "meta.json")),
|
|
1013
|
+
readRouteDecisionIfValid(join(directory, "route.json")),
|
|
1014
|
+
readTextIfExists(join(directory, "user-request.md")),
|
|
1015
|
+
readTurnMetaIfValid(join(firstTurnDir, "turn.json")),
|
|
1016
|
+
readRouteDecisionIfValid(join(firstTurnDir, "route.json")),
|
|
1017
|
+
readTextIfExists(join(firstTurnDir, "user.md"))
|
|
1018
|
+
]);
|
|
1019
|
+
if (!meta
|
|
1020
|
+
|| !route
|
|
1021
|
+
|| !turn
|
|
1022
|
+
|| !turnRoute
|
|
1023
|
+
|| !request.trim()
|
|
1024
|
+
|| request.trim() !== turnRequest.trim()
|
|
1025
|
+
|| meta.id !== taskId
|
|
1026
|
+
|| meta.mode !== route.mode
|
|
1027
|
+
|| turn.task_id !== taskId
|
|
1028
|
+
|| turn.turn_id !== "0001"
|
|
1029
|
+
|| turn.request_path !== "turns/0001/user.md"
|
|
1030
|
+
|| turnRoute.mode !== route.mode) {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
return { meta, turn };
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
async projectPublishedTaskCreation(taskId, meta) {
|
|
1040
|
+
if (!this.index) {
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
await this.index.upsertTask(meta);
|
|
1044
|
+
const snapshot = await this.readCompleteTaskCreation(this.taskFromId(taskId).dir, taskId);
|
|
1045
|
+
if (snapshot) {
|
|
1046
|
+
await this.index.upsertTurn(taskId, snapshot.turn);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async quarantinePendingTaskCreation(pending) {
|
|
1050
|
+
const root = join(sessionsRoot(this.projectRoot, this.dataDir), ".abandoned");
|
|
1051
|
+
await ensureDir(root);
|
|
1052
|
+
const name = basename(pending.stagingDir);
|
|
1053
|
+
const preferred = join(root, name);
|
|
1054
|
+
const destination = await pathExists(preferred)
|
|
1055
|
+
? join(root, `${name}.${randomUUID()}`)
|
|
1056
|
+
: preferred;
|
|
1057
|
+
try {
|
|
1058
|
+
await rename(pending.stagingDir, destination);
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
if (error.code === "ENOENT") {
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
throw error;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async pendingTurnDirectories(task) {
|
|
1069
|
+
const root = join(task.dir, "turns");
|
|
1070
|
+
if (!(await pathExists(root))) {
|
|
1071
|
+
return [];
|
|
1072
|
+
}
|
|
1073
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
1074
|
+
return entries.flatMap((entry) => {
|
|
1075
|
+
const match = entry.isDirectory() ? entry.name.match(PENDING_TURN_DIRECTORY) : null;
|
|
1076
|
+
return match?.[1]
|
|
1077
|
+
? [{ name: entry.name, turnId: match[1], dir: join(root, entry.name) }]
|
|
1078
|
+
: [];
|
|
1079
|
+
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
1080
|
+
}
|
|
1081
|
+
async reconcilePendingTurns(task) {
|
|
1082
|
+
const summary = { published: 0, repaired: 0, abandoned: 0 };
|
|
1083
|
+
for (const pending of await this.pendingTurnDirectories(task)) {
|
|
1084
|
+
const files = this.turnFiles(task, pending.turnId);
|
|
1085
|
+
if (await pathExists(files.dir)) {
|
|
1086
|
+
await this.quarantinePendingTurn(task, pending);
|
|
1087
|
+
await this.appendEvent(task, "turn.pending_abandoned", `Archived pending turn ${pending.turnId} because the committed turn already exists`);
|
|
1088
|
+
summary.abandoned += 1;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const request = (await readTextIfExists(join(pending.dir, "user.md"))).trim();
|
|
1092
|
+
const pendingRoute = await readRouteDecisionIfValid(join(pending.dir, "route.json"));
|
|
1093
|
+
const pendingMeta = await readTurnMetaIfValid(join(pending.dir, "turn.json"));
|
|
1094
|
+
const metaMatches = Boolean(pendingMeta
|
|
1095
|
+
&& pendingMeta.task_id === task.id
|
|
1096
|
+
&& pendingMeta.turn_id === pending.turnId
|
|
1097
|
+
&& pendingMeta.request_path === `turns/${pending.turnId}/user.md`);
|
|
1098
|
+
if (request && pendingRoute && pendingMeta && metaMatches) {
|
|
1099
|
+
await rename(pending.dir, files.dir);
|
|
1100
|
+
await this.index?.upsertTurn(task.id, pendingMeta);
|
|
1101
|
+
await this.appendEvent(task, "turn.recovered_after_restart", `Published complete pending turn ${pending.turnId} after restart`);
|
|
1102
|
+
summary.published += 1;
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const fallbackRoute = pendingRoute
|
|
1106
|
+
?? await readRouteDecisionIfValid(join(task.dir, "latest-route.json"))
|
|
1107
|
+
?? await readRouteDecisionIfValid(task.routePath);
|
|
1108
|
+
if (request && fallbackRoute) {
|
|
1109
|
+
const createdAt = pendingMeta && metaMatches
|
|
1110
|
+
? new Date(pendingMeta.created_at)
|
|
1111
|
+
: this.now();
|
|
1112
|
+
await this.quarantinePendingTurn(task, pending);
|
|
1113
|
+
await this.writeTurn(task, pending.turnId, request, fallbackRoute, createdAt);
|
|
1114
|
+
await this.appendEvent(task, "turn.repaired_after_restart", `Rebuilt partial pending turn ${pending.turnId} from its durable request and route evidence`);
|
|
1115
|
+
summary.repaired += 1;
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
await this.quarantinePendingTurn(task, pending);
|
|
1119
|
+
await this.appendEvent(task, "turn.pending_abandoned", `Archived incomplete pending turn ${pending.turnId}; no durable request and route pair was available`);
|
|
1120
|
+
summary.abandoned += 1;
|
|
1121
|
+
}
|
|
1122
|
+
return summary;
|
|
1123
|
+
}
|
|
1124
|
+
async quarantinePendingTurn(task, pending) {
|
|
1125
|
+
const root = join(task.dir, "turns", ".abandoned");
|
|
1126
|
+
await ensureDir(root);
|
|
1127
|
+
const preferred = join(root, pending.name);
|
|
1128
|
+
const destination = await pathExists(preferred)
|
|
1129
|
+
? join(root, `${pending.name}.${randomUUID()}`)
|
|
1130
|
+
: preferred;
|
|
1131
|
+
await rename(pending.dir, destination);
|
|
202
1132
|
}
|
|
203
1133
|
async backfillInitialTurn(task, fallbackRoute) {
|
|
204
1134
|
const firstTurn = this.turnFiles(task, "0001");
|
|
@@ -213,29 +1143,48 @@ export class SessionManager {
|
|
|
213
1143
|
if (!request) {
|
|
214
1144
|
return;
|
|
215
1145
|
}
|
|
216
|
-
const route = (await
|
|
217
|
-
|
|
218
|
-
: fallbackRoute;
|
|
219
|
-
const meta = (await pathExists(task.metaPath)) ? await readJson(task.metaPath, TaskMetaSchema) : null;
|
|
1146
|
+
const route = (await readRouteDecisionIfValid(task.routePath)) ?? fallbackRoute;
|
|
1147
|
+
const meta = await readTaskMetaIfValid(task.metaPath);
|
|
220
1148
|
await this.writeTurn(task, "0001", request, route, meta ? new Date(meta.created_at) : this.now());
|
|
221
1149
|
}
|
|
222
|
-
async writeTurn(task, turnId, request, route, createdAt) {
|
|
1150
|
+
async writeTurn(task, turnId, request, route, createdAt, projectIndex = true) {
|
|
223
1151
|
const files = this.turnFiles(task, turnId);
|
|
224
|
-
const turnMeta = {
|
|
1152
|
+
const turnMeta = TurnMetaSchema.parse({
|
|
225
1153
|
task_id: task.id,
|
|
226
1154
|
turn_id: turnId,
|
|
227
1155
|
created_at: createdAt.toISOString(),
|
|
228
1156
|
request_path: `turns/${turnId}/user.md`
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
await
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
1157
|
+
});
|
|
1158
|
+
const parsedRoute = RouteDecisionSchema.parse(route);
|
|
1159
|
+
if (await pathExists(files.dir)) {
|
|
1160
|
+
throw new Error(`Turn ${turnId} already exists for task ${task.id}.`);
|
|
1161
|
+
}
|
|
1162
|
+
const pendingDir = join(task.dir, "turns", `.turn-${turnId}-${randomUUID()}.pending`);
|
|
1163
|
+
const pendingFiles = this.turnFilesAtDir(turnId, pendingDir);
|
|
1164
|
+
let published = false;
|
|
1165
|
+
try {
|
|
1166
|
+
await ensureDir(pendingDir);
|
|
1167
|
+
await writeText(pendingFiles.userPath, `${request.trim()}\n`);
|
|
1168
|
+
await writeJson(pendingFiles.routePath, parsedRoute);
|
|
1169
|
+
await writeJson(pendingFiles.metaPath, turnMeta);
|
|
1170
|
+
await rename(pendingDir, files.dir);
|
|
1171
|
+
published = true;
|
|
1172
|
+
}
|
|
1173
|
+
finally {
|
|
1174
|
+
if (!published) {
|
|
1175
|
+
await rm(pendingDir, { recursive: true, force: true });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (projectIndex) {
|
|
1179
|
+
await this.index?.upsertTurn(task.id, turnMeta);
|
|
1180
|
+
}
|
|
235
1181
|
return files;
|
|
236
1182
|
}
|
|
237
1183
|
turnFiles(task, turnId) {
|
|
238
1184
|
const dir = join(task.dir, "turns", turnId);
|
|
1185
|
+
return this.turnFilesAtDir(turnId, dir);
|
|
1186
|
+
}
|
|
1187
|
+
turnFilesAtDir(turnId, dir) {
|
|
239
1188
|
return {
|
|
240
1189
|
turnId,
|
|
241
1190
|
dir,
|
|
@@ -247,7 +1196,74 @@ export class SessionManager {
|
|
|
247
1196
|
taskIdFromWorkerDir(workerDir) {
|
|
248
1197
|
return basename(dirname(workerDir));
|
|
249
1198
|
}
|
|
1199
|
+
workerIdFromWorkerDir(workerDir) {
|
|
1200
|
+
return basename(workerDir);
|
|
1201
|
+
}
|
|
1202
|
+
async finalizeNativeSessionRetirement(worker, sessionId) {
|
|
1203
|
+
await removeIfExists(join(worker.dir, "native-session.json"));
|
|
1204
|
+
await this.clearWorkerStatusNativeSession(worker, sessionId);
|
|
1205
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
|
|
1206
|
+
}
|
|
1207
|
+
async syncNativeSessionProjection(worker, record) {
|
|
1208
|
+
await this.setWorkerStatusNativeSession(worker, record.session_id);
|
|
1209
|
+
await this.index?.upsertNativeSession(this.taskIdFromWorkerDir(worker.dir), record);
|
|
1210
|
+
}
|
|
1211
|
+
async setWorkerStatusNativeSession(worker, sessionId) {
|
|
1212
|
+
const statusPath = join(worker.dir, "status.json");
|
|
1213
|
+
const status = await readWorkerStatusIfValid(statusPath);
|
|
1214
|
+
if (!status) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const nextStatus = status.native_session_id === sessionId
|
|
1218
|
+
? status
|
|
1219
|
+
: WorkerStatusSchema.parse({ ...status, native_session_id: sessionId });
|
|
1220
|
+
if (nextStatus !== status) {
|
|
1221
|
+
await writeJson(statusPath, nextStatus);
|
|
1222
|
+
}
|
|
1223
|
+
await this.index?.upsertWorker(this.taskIdFromWorkerDir(worker.dir), nextStatus, {
|
|
1224
|
+
dir: worker.dir,
|
|
1225
|
+
statusPath,
|
|
1226
|
+
outputLogPath: join(worker.dir, "output.log")
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
async clearWorkerStatusNativeSession(worker, expectedSessionId) {
|
|
1230
|
+
const statusPath = join(worker.dir, "status.json");
|
|
1231
|
+
if (!(await pathExists(statusPath))) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const status = await readWorkerStatusIfValid(statusPath);
|
|
1235
|
+
if (!status) {
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (!status.native_session_id) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (expectedSessionId && status.native_session_id !== expectedSessionId) {
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const nextStatus = { ...status };
|
|
1245
|
+
delete nextStatus.native_session_id;
|
|
1246
|
+
await writeJson(statusPath, WorkerStatusSchema.parse(nextStatus));
|
|
1247
|
+
await this.index?.upsertWorker(this.taskIdFromWorkerDir(worker.dir), nextStatus, {
|
|
1248
|
+
dir: worker.dir,
|
|
1249
|
+
statusPath,
|
|
1250
|
+
outputLogPath: join(worker.dir, "output.log")
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
250
1253
|
async clearWorkerArtifacts(dir, role) {
|
|
1254
|
+
if (role === "judge") {
|
|
1255
|
+
for (const file of [
|
|
1256
|
+
"requirements.md",
|
|
1257
|
+
"plan.md",
|
|
1258
|
+
"acceptance.md",
|
|
1259
|
+
"actor-brief.md",
|
|
1260
|
+
"critic-brief.md",
|
|
1261
|
+
"features.json"
|
|
1262
|
+
]) {
|
|
1263
|
+
await removeIfExists(join(dir, file));
|
|
1264
|
+
}
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
251
1267
|
const files = role === "actor"
|
|
252
1268
|
? ["worklog.md", "patch.diff"]
|
|
253
1269
|
: role === "critic"
|
|
@@ -262,3 +1278,125 @@ function titleFromRequest(request) {
|
|
|
262
1278
|
const firstLine = request.trim().split("\n")[0] ?? "Untitled task";
|
|
263
1279
|
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
|
264
1280
|
}
|
|
1281
|
+
function normalizeTaskTitle(title) {
|
|
1282
|
+
const normalized = title
|
|
1283
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
1284
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
1285
|
+
.replace(/\s+/g, " ")
|
|
1286
|
+
.trim();
|
|
1287
|
+
if (!normalized) {
|
|
1288
|
+
throw new Error("Task title cannot be empty.");
|
|
1289
|
+
}
|
|
1290
|
+
if (Array.from(normalized).length > 160) {
|
|
1291
|
+
throw new Error("Task title cannot exceed 160 characters.");
|
|
1292
|
+
}
|
|
1293
|
+
return normalized;
|
|
1294
|
+
}
|
|
1295
|
+
function isTransientTaskLeasePath(taskDir, source) {
|
|
1296
|
+
if (dirname(source) !== taskDir) {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
const name = basename(source);
|
|
1300
|
+
return name === "run-owner.json" || name.startsWith(".run-owner-claim-");
|
|
1301
|
+
}
|
|
1302
|
+
function errorMessage(error) {
|
|
1303
|
+
return error instanceof Error ? error.message : String(error);
|
|
1304
|
+
}
|
|
1305
|
+
async function readTaskMetaIfValid(metaPath) {
|
|
1306
|
+
if (!(await pathExists(metaPath))) {
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
return await readJson(metaPath, TaskMetaSchema);
|
|
1311
|
+
}
|
|
1312
|
+
catch {
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async function readTaskCreationOwnerIfValid(path) {
|
|
1317
|
+
if (!(await pathExists(path))) {
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
try {
|
|
1321
|
+
return await readJson(path, TaskCreationOwnerSchema);
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
async function taskCreationOwnerIsActive(owner) {
|
|
1328
|
+
if (!processIsAlive(owner.pid)) {
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
if (!owner.process_start_token) {
|
|
1332
|
+
return true;
|
|
1333
|
+
}
|
|
1334
|
+
return (await readProcessStartToken(owner.pid)) === owner.process_start_token;
|
|
1335
|
+
}
|
|
1336
|
+
async function writeJsonExclusive(path, value) {
|
|
1337
|
+
try {
|
|
1338
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
1339
|
+
return true;
|
|
1340
|
+
}
|
|
1341
|
+
catch (error) {
|
|
1342
|
+
if (error.code === "EEXIST") {
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
throw error;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
async function readRouteDecisionIfValid(routePath) {
|
|
1349
|
+
if (!(await pathExists(routePath))) {
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
try {
|
|
1353
|
+
return await readJson(routePath, RouteDecisionSchema);
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
async function readTurnMetaIfValid(metaPath) {
|
|
1360
|
+
if (!(await pathExists(metaPath))) {
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
return await readJson(metaPath, TurnMetaSchema);
|
|
1365
|
+
}
|
|
1366
|
+
catch {
|
|
1367
|
+
return null;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
async function readNativeSessionIfValid(nativeSessionPath) {
|
|
1371
|
+
if (!(await pathExists(nativeSessionPath))) {
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
try {
|
|
1375
|
+
return await readJson(nativeSessionPath, NativeSessionSchema);
|
|
1376
|
+
}
|
|
1377
|
+
catch {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
async function readRetiredNativeSessionIfValid(retiredSessionPath) {
|
|
1382
|
+
if (!(await pathExists(retiredSessionPath))) {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
try {
|
|
1386
|
+
return await readJson(retiredSessionPath, RetiredNativeSessionSchema);
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
async function readWorkerStatusIfValid(statusPath) {
|
|
1393
|
+
if (!(await pathExists(statusPath))) {
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
try {
|
|
1397
|
+
return await readJson(statusPath, WorkerStatusSchema);
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
}
|