input-kanban 0.0.1

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.
@@ -0,0 +1,734 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {
6
+ CODEX_BIN, DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
7
+ writeJsonAtomic, fileInfo, readTextMaybe, extractFirstJsonObject, listRunDirs,
8
+ pathForRun, roleDir, safeIdPart
9
+ } from './utils.js';
10
+ import { matchThreadToMarkers } from './appServerClient.js';
11
+
12
+ const runningChildren = new Map(); // key: `${runId}:${taskId}` -> child
13
+
14
+ function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
15
+ function planPath(runDir) { return path.join(runDir, 'plan.json'); }
16
+
17
+ export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3 } = {}) {
18
+ const runId = makeRunId(label);
19
+ const runDir = pathForRun(runId);
20
+ await ensureDir(runDir);
21
+ await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
22
+ const state = {
23
+ runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3,
24
+ status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
25
+ planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
26
+ };
27
+ await writeJsonAtomic(statePath(runDir), state);
28
+ return state;
29
+ }
30
+
31
+ export async function listRuns({ includeArchived = false } = {}) {
32
+ const dirs = await listRunDirs();
33
+ const rows = [];
34
+ for (const dir of dirs) {
35
+ const s = await loadAndRefreshRun(path.basename(dir), null, { light: true });
36
+ if (s && (includeArchived || !s.archived)) rows.push(summaryOfRun(s));
37
+ }
38
+ return rows;
39
+ }
40
+
41
+ export async function loadRun(runId) {
42
+ const state = await readJson(statePath(pathForRun(runId)), null);
43
+ if (state) ensureBatchShape(state);
44
+ return state;
45
+ }
46
+
47
+ async function saveRun(state) {
48
+ ensureBatchShape(state);
49
+ state.updatedAt = nowIso();
50
+ await writeJsonAtomic(statePath(pathForRun(state.runId)), state);
51
+ return state;
52
+ }
53
+
54
+ function marker(runId, taskId, role) {
55
+ return `ORCHESTRATOR_RUN_ID: ${runId}\nORCHESTRATOR_TASK_ID: ${taskId}\nORCHESTRATOR_ROLE: ${role}`;
56
+ }
57
+
58
+ function defaultPlannerPrompt(state, taskText) {
59
+ return `${marker(state.runId, 'planner', 'planner')}
60
+
61
+ You are the planner for a local Codex orchestrator dashboard.
62
+ Split the user's task into scoped Codex worker tasks.
63
+ Return ONLY one JSON object. No markdown.
64
+
65
+ Preferred schema with blocking batches:
66
+ {
67
+ "batches": [
68
+ {
69
+ "id": "batch-1",
70
+ "name": "first batch name",
71
+ "maxParallel": 3,
72
+ "tasks": [
73
+ {
74
+ "id": "T-01",
75
+ "name": "short name",
76
+ "prompt": "complete worker prompt",
77
+ "sandbox": "workspace-write",
78
+ "expectedArtifacts": []
79
+ }
80
+ ]
81
+ }
82
+ ],
83
+ "finalJudgeRequired": true
84
+ }
85
+
86
+ Backward-compatible schema also accepted:
87
+ {
88
+ "tasks": [
89
+ {
90
+ "id": "T-01",
91
+ "name": "short name",
92
+ "prompt": "complete worker prompt",
93
+ "sandbox": "workspace-write",
94
+ "expectedArtifacts": []
95
+ }
96
+ ]
97
+ }
98
+
99
+ Rules:
100
+ - Batches are strict barriers: a later batch must not start before all tasks in earlier batches complete.
101
+ - Use batch maxParallel to express whether tasks in the same batch may run concurrently or serially.
102
+ - Keep tasks scoped and independently executable.
103
+ - Include exact output/artifact expectations in each worker prompt.
104
+ - If the input already contains task sections, preserve their ids when practical.
105
+
106
+ User task:
107
+ ${taskText}
108
+ `;
109
+ }
110
+
111
+ function defaultJudgePrompt(state, judgeInputPath) {
112
+ return `${marker(state.runId, 'judge', 'judge')}
113
+
114
+ You are an independent final judge for a Codex orchestrator run.
115
+ Use the judge input manifest as the primary source of truth. Inspect additional run artifacts only if needed.
116
+ Do not modify files. Return ONLY JSON with:
117
+ {
118
+ "verdict": "passed|partial|failed|blocked",
119
+ "completedTasks": [],
120
+ "failedTasks": [],
121
+ "blockedTasks": [],
122
+ "missingArtifacts": [],
123
+ "scopeViolations": [],
124
+ "residualRisk": [],
125
+ "recommendedNextActions": []
126
+ }
127
+
128
+ Judge input manifest: ${judgeInputPath}
129
+ Run directory: ${pathForRun(state.runId)}
130
+ Original task: ${path.join(pathForRun(state.runId), 'task.md')}
131
+ Plan: ${path.join(pathForRun(state.runId), 'plan.json')}
132
+ `;
133
+ }
134
+
135
+ function spawnCodex({ state, taskId, prompt, sandbox, cwd, outDir }) {
136
+ const events = path.join(outDir, 'events.jsonl');
137
+ const stderr = path.join(outDir, 'stderr.log');
138
+ const last = path.join(outDir, 'last_message.md');
139
+ fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
140
+ const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
141
+ const child = spawn(CODEX_BIN, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
142
+ child.stdout.pipe(fs.createWriteStream(events, { flags: 'a' }));
143
+ child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
144
+ const key = `${state.runId}:${taskId}`;
145
+ runningChildren.set(key, child);
146
+ child.on('exit', code => {
147
+ try { fs.writeFileSync(path.join(outDir, 'exit_code'), String(code)); } catch {}
148
+ runningChildren.delete(key);
149
+ });
150
+ return child;
151
+ }
152
+
153
+ export async function startPlanner(runId) {
154
+ const state = await loadRun(runId);
155
+ if (!state) throw new Error(`run not found: ${runId}`);
156
+ if (state.archived) throw new Error('archived run cannot be planned');
157
+ if (state.status === 'stopped') throw new Error('stopped run cannot be planned; create a new run after modifications');
158
+ if (state.planner.status === 'running') throw new Error('planner already running');
159
+ if (hasStartedExecution(state)) throw new Error('planner retry is allowed only before any worker/judge starts');
160
+ const runDir = pathForRun(runId);
161
+ const previousPlanner = state.planner;
162
+ if (previousPlanner?.status && previousPlanner.status !== 'pending') await rotatePlannerAttempt(state, runDir);
163
+ state.batches = [];
164
+ state.tasks = [];
165
+ state.judge = { status: 'pending' };
166
+ const outDir = roleDir(runDir, 'planner');
167
+ await ensureDir(outDir);
168
+ await fsp.rm(planPath(runDir), { force: true });
169
+ const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
170
+ const prompt = defaultPlannerPrompt(state, taskText);
171
+ const child = spawnCodex({ state, taskId: 'planner', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
172
+ state.status = 'planning';
173
+ state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
174
+ await saveRun(state);
175
+ child.on('exit', async code => {
176
+ const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
177
+ s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
178
+ const planResult = await materializePlan(s);
179
+ if (s.planner.status !== 'completed') s.status = 'plan_failed';
180
+ else if (planResult.ok) s.status = 'planned';
181
+ else if (planResult.empty) s.status = 'plan_empty';
182
+ else s.status = 'plan_failed';
183
+ await saveRun(s);
184
+ });
185
+ return state;
186
+ }
187
+
188
+ function normalizeTask(t, i, batch) {
189
+ const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
190
+ return {
191
+ id,
192
+ batchId: batch.id,
193
+ name: t.name || t.id || `Task ${i + 1}`,
194
+ prompt: t.prompt || t.instructions || '',
195
+ sandbox: t.sandbox || 'workspace-write',
196
+ expectedArtifacts: Array.isArray(t.expectedArtifacts) ? t.expectedArtifacts : [],
197
+ status: 'pending'
198
+ };
199
+ }
200
+
201
+ function hasStartedExecution(state) {
202
+ return (state.tasks || []).some(t => ['running', 'completed', 'failed', 'unknown', 'stopped'].includes(t.status)) ||
203
+ ['running', 'completed', 'failed', 'unknown', 'stopped'].includes(state.judge?.status);
204
+ }
205
+
206
+ async function rotatePlannerAttempt(state, runDir) {
207
+ const plannerDir = roleDir(runDir, 'planner');
208
+ if (!fs.existsSync(plannerDir)) return;
209
+ const attemptsDir = path.join(runDir, 'planner_attempts');
210
+ await ensureDir(attemptsDir);
211
+ const attempt = (state.plannerAttempts?.length || 0) + 1;
212
+ const archivedDir = path.join(attemptsDir, `attempt-${String(attempt).padStart(2, '0')}`);
213
+ await fsp.rm(archivedDir, { recursive: true, force: true });
214
+ await fsp.rename(plannerDir, archivedDir);
215
+ state.plannerAttempts = [...(state.plannerAttempts || []), {
216
+ attempt,
217
+ status: state.planner?.status,
218
+ exitCode: state.planner?.exitCode ?? null,
219
+ startedAt: state.planner?.startedAt,
220
+ endedAt: state.planner?.endedAt,
221
+ archivedDir,
222
+ archivedAt: nowIso(),
223
+ planParseError: state.planner?.planParseError,
224
+ planEmpty: !!state.planner?.planEmpty
225
+ }];
226
+ }
227
+
228
+ function normalizePlan(plan, defaultMaxParallel) {
229
+ if (Array.isArray(plan.batches)) {
230
+ const batches = plan.batches.map((b, bi) => {
231
+ const batch = {
232
+ id: safeIdPart(b.id || `batch-${bi + 1}`),
233
+ name: b.name || `批次 ${bi + 1}`,
234
+ maxParallel: Math.max(1, Number(b.maxParallel || defaultMaxParallel) || 1),
235
+ status: 'pending',
236
+ tasks: []
237
+ };
238
+ batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch));
239
+ return batch;
240
+ }).filter(b => b.tasks.length);
241
+ return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
242
+ }
243
+ if (Array.isArray(plan.tasks)) {
244
+ const batch = { id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(defaultMaxParallel) || 1), status: 'pending', tasks: [] };
245
+ batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch));
246
+ return { ...plan, batches: [batch], tasks: batch.tasks };
247
+ }
248
+ return null;
249
+ }
250
+
251
+ async function materializePlan(state) {
252
+ const last = path.join(roleDir(pathForRun(state.runId), 'planner'), 'last_message.md');
253
+ const text = await readTextMaybe(last, 1000000);
254
+ const plan = extractFirstJsonObject(text);
255
+ if (!plan) {
256
+ state.planner.planParseError = 'planner last_message did not contain a JSON object';
257
+ state.batches = [];
258
+ state.tasks = [];
259
+ return { ok: false, empty: false, error: state.planner.planParseError };
260
+ }
261
+ const normalized = normalizePlan(plan, state.maxParallel);
262
+ if (!normalized || !Array.isArray(normalized.tasks)) {
263
+ state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
264
+ state.batches = [];
265
+ state.tasks = [];
266
+ return { ok: false, empty: false, error: state.planner.planParseError };
267
+ }
268
+ if (!normalized.tasks.length) {
269
+ state.planner.planEmpty = true;
270
+ state.planner.planParseError = 'planner returned zero tasks; retry planning after adjusting the task description or prompt';
271
+ state.batches = [];
272
+ state.tasks = [];
273
+ return { ok: false, empty: true, error: state.planner.planParseError };
274
+ }
275
+ delete state.planner.planEmpty;
276
+ delete state.planner.planParseError;
277
+ await writeJsonAtomic(planPath(pathForRun(state.runId)), normalized);
278
+ state.batches = normalized.batches;
279
+ state.tasks = normalized.tasks;
280
+ return { ok: true, empty: false };
281
+ }
282
+
283
+ export async function dispatchRun(runId) {
284
+ const state = await loadRun(runId);
285
+ if (!state) throw new Error(`run not found: ${runId}`);
286
+ if (state.archived) throw new Error('archived run cannot be dispatched');
287
+ if (state.status === 'stopped') throw new Error('stopped run cannot be dispatched; create a new run after modifications');
288
+ if (!state.tasks?.length) throw new Error('no tasks in plan');
289
+ if (state.status === 'batch_blocked') throw new Error('current batch is blocked by failed/unknown tasks');
290
+ if (allBatchesCompleted(state)) throw new Error('all batches completed; run final judge next');
291
+ state.status = 'running';
292
+ await scheduleMoreWorkers(state);
293
+ recomputeRunStatus(state);
294
+ await saveRun(state);
295
+ return state;
296
+ }
297
+
298
+ async function startWorkerInState(state, task) {
299
+ const runDir = pathForRun(state.runId);
300
+ const outDir = roleDir(runDir, 'worker', task.id);
301
+ await ensureDir(outDir);
302
+ const fullPrompt = `${marker(state.runId, task.id, 'worker')}
303
+ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
304
+
305
+ ${task.prompt}
306
+ `;
307
+ const child = spawnCodex({ state, taskId: task.id, prompt: fullPrompt, sandbox: task.sandbox || 'workspace-write', cwd: state.repo, outDir });
308
+ Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
309
+ }
310
+
311
+ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
312
+ const state = await loadRun(runId);
313
+ if (!state) throw new Error(`run not found: ${runId}`);
314
+ const stoppedAt = nowIso();
315
+ for (const [key, child] of runningChildren.entries()) {
316
+ if (key.startsWith(`${runId}:`)) {
317
+ try { child.kill('TERM'); } catch {}
318
+ runningChildren.delete(key);
319
+ }
320
+ }
321
+ for (const roleState of [state.planner, state.judge]) {
322
+ if (roleState?.status === 'running') Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
323
+ }
324
+ for (const task of state.tasks || []) {
325
+ if (task.status === 'running') Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
326
+ }
327
+ for (const batch of state.batches || []) {
328
+ if ((batch.tasks || []).some(t => t.status === 'stopped')) batch.status = 'stopped';
329
+ }
330
+ state.status = 'stopped';
331
+ state.stopInfo = { reason, stoppedAt };
332
+ await saveRun(state);
333
+ return state;
334
+ }
335
+
336
+ export async function archiveRun(runId, { reason = 'archived by user' } = {}) {
337
+ const state = await loadRun(runId);
338
+ if (!state) throw new Error(`run not found: ${runId}`);
339
+ if ((state.tasks || []).some(t => t.status === 'running') || state.planner?.status === 'running' || state.judge?.status === 'running') {
340
+ throw new Error('cannot archive a run while tasks are running; stop it first');
341
+ }
342
+ state.archived = true;
343
+ state.archivedAt = nowIso();
344
+ state.archiveInfo = { reason, archivedAt: state.archivedAt };
345
+ await saveRun(state);
346
+ return state;
347
+ }
348
+
349
+ export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user' } = {}) {
350
+ const state = await loadRun(runId);
351
+ if (!state) throw new Error(`run not found: ${runId}`);
352
+ const task = (state.tasks || []).find(t => t.id === taskId);
353
+ if (!task) throw new Error(`task not found: ${taskId}`);
354
+ if (task.status === 'running') throw new Error('cannot mark a running task completed');
355
+ const runDir = pathForRun(runId);
356
+ const outDir = roleDir(runDir, 'worker', task.id);
357
+ await ensureDir(outDir);
358
+ if (task.status !== 'completed') {
359
+ const override = {
360
+ type: 'manual_task_completed',
361
+ runId,
362
+ taskId,
363
+ originalStatus: task.originalStatus || task.status,
364
+ originalExitCode: task.originalExitCode ?? task.exitCode ?? null,
365
+ previousStatus: task.status,
366
+ previousExitCode: task.exitCode ?? null,
367
+ reason,
368
+ markedAt: nowIso()
369
+ };
370
+ await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
371
+ Object.assign(task, {
372
+ status: 'completed',
373
+ originalStatus: override.originalStatus,
374
+ originalExitCode: override.originalExitCode,
375
+ manualCompletion: override,
376
+ completedAt: override.markedAt
377
+ });
378
+ const batch = (state.batches || []).find(b => b.id === task.batchId);
379
+ if (batch) {
380
+ const batchTask = batch.tasks.find(t => t.id === task.id);
381
+ if (batchTask && batchTask !== task) Object.assign(batchTask, task);
382
+ }
383
+ }
384
+ recomputeRunStatus(state);
385
+ if (hasPendingRunnableBatch(state)) state.status = 'running';
386
+ await scheduleMoreWorkers(state);
387
+ recomputeRunStatus(state);
388
+ await saveRun(state);
389
+ return state;
390
+ }
391
+
392
+ export async function startJudge(runId) {
393
+ const state = await loadRun(runId);
394
+ if (!state) throw new Error(`run not found: ${runId}`);
395
+ recomputeRunStatus(state);
396
+ if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
397
+ const outDir = roleDir(pathForRun(runId), 'judge');
398
+ await ensureDir(outDir);
399
+ const judgeInputPath = path.join(outDir, 'judge_input.json');
400
+ const judgeInput = await buildJudgeInput(state);
401
+ await writeJsonAtomic(judgeInputPath, judgeInput);
402
+ const prompt = defaultJudgePrompt(state, judgeInputPath);
403
+ const child = spawnCodex({ state, taskId: 'judge', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
404
+ state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
405
+ state.status = 'judging';
406
+ await saveRun(state);
407
+ child.on('exit', async code => {
408
+ const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
409
+ s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
410
+ const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
411
+ const verdict = extractFirstJsonObject(text);
412
+ if (verdict) { s.judge.verdict = verdict; await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict); }
413
+ s.status = s.judge.status === 'completed' ? 'judged' : 'judge_failed';
414
+ await saveRun(s);
415
+ });
416
+ return state;
417
+ }
418
+
419
+ export async function refreshRun(runId, appClient = null) {
420
+ return await loadAndRefreshRun(runId, appClient, { light: false });
421
+ }
422
+
423
+ async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
424
+ const state = await loadRun(runId);
425
+ if (!state) return null;
426
+ await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
427
+ for (const task of state.tasks || []) await refreshTask(state, task);
428
+ await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
429
+ recomputeRunStatus(state);
430
+ await scheduleMoreWorkers(state);
431
+ recomputeRunStatus(state);
432
+ if (appClient && !light) await enrichFromAppServer(state, appClient).catch(e => { state.appServerError = e.message; });
433
+ await saveRun(state);
434
+ return state;
435
+ }
436
+
437
+ async function refreshRole(state, roleState, dir) {
438
+ if (!roleState) return;
439
+ const exitPath = path.join(dir, 'exit_code');
440
+ const exit = await readTextMaybe(exitPath, 1000);
441
+ const exitInfo = await fileInfo(exitPath);
442
+ const key = `${state.runId}:${roleState === state.judge ? 'judge' : 'planner'}`;
443
+ if (exit !== '') {
444
+ roleState.exitCode = Number(exit.trim());
445
+ if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
446
+ if (roleState.status === 'running') roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
447
+ }
448
+ else if (roleState.status === 'running' && !runningChildren.has(key)) roleState.status = 'unknown';
449
+ roleState.files = await standardFiles(dir);
450
+ }
451
+
452
+ async function refreshTask(state, task) {
453
+ const dir = roleDir(pathForRun(state.runId), 'worker', task.id);
454
+ const exitPath = path.join(dir, 'exit_code');
455
+ const exit = await readTextMaybe(exitPath, 1000);
456
+ const exitInfo = await fileInfo(exitPath);
457
+ const key = `${state.runId}:${task.id}`;
458
+ if (exit !== '') {
459
+ task.exitCode = Number(exit.trim());
460
+ if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
461
+ if (task.status === 'running') task.status = task.exitCode === 0 ? 'completed' : 'failed';
462
+ } else if (task.status === 'running' && !runningChildren.has(key)) task.status = 'unknown';
463
+ task.files = await standardFiles(dir);
464
+ task.artifacts = [];
465
+ for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
466
+ const batch = (state.batches || []).find(b => b.id === task.batchId);
467
+ if (batch) {
468
+ const bt = batch.tasks.find(t => t.id === task.id);
469
+ if (bt && bt !== task) Object.assign(bt, task);
470
+ }
471
+ }
472
+
473
+ async function standardFiles(dir) {
474
+ return {
475
+ prompt: await fileInfo(path.join(dir, 'prompt.md')),
476
+ events: await fileInfo(path.join(dir, 'events.jsonl')),
477
+ stderr: await fileInfo(path.join(dir, 'stderr.log')),
478
+ lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
479
+ exitCode: await fileInfo(path.join(dir, 'exit_code'))
480
+ };
481
+ }
482
+
483
+ function currentBatch(state) {
484
+ ensureBatchShape(state);
485
+ return (state.batches || []).find(b => b.status !== 'completed');
486
+ }
487
+
488
+ async function scheduleMoreWorkers(state) {
489
+ if (state.status !== 'running') return;
490
+ const batch = currentBatch(state);
491
+ if (!batch) return;
492
+ if (batch.status === 'failed' || batch.status === 'blocked') return;
493
+ batch.status = 'running';
494
+ const maxParallel = Math.max(1, Number(batch.maxParallel || state.maxParallel) || 1);
495
+ let active = batch.tasks.filter(t => t.status === 'running').length;
496
+ for (const task of batch.tasks) {
497
+ if (active >= maxParallel) break;
498
+ if (task.status !== 'pending') continue;
499
+ try { await startWorkerInState(state, task); syncFlatTask(state, task); }
500
+ catch (e) { task.status = 'failed'; task.error = e.message; syncFlatTask(state, task); }
501
+ active++;
502
+ }
503
+ }
504
+
505
+ function syncFlatTask(state, task) {
506
+ const i = (state.tasks || []).findIndex(t => t.id === task.id);
507
+ if (i >= 0) state.tasks[i] = task;
508
+ }
509
+
510
+ function recomputeRunStatus(state) {
511
+ ensureBatchShape(state);
512
+ if (state.archived || state.status === 'stopped' || state.status === 'created' || state.status === 'planning' || state.status === 'judging') return;
513
+ for (const batch of state.batches || []) {
514
+ const tasks = batch.tasks || [];
515
+ if (!tasks.length) { batch.status = 'completed'; continue; }
516
+ if (tasks.some(t => t.status === 'running')) { batch.status = 'running'; continue; }
517
+ if (tasks.some(t => ['failed', 'unknown'].includes(t.status))) { batch.status = 'failed'; continue; }
518
+ if (tasks.every(t => t.status === 'completed')) { batch.status = 'completed'; continue; }
519
+ batch.status = 'pending';
520
+ }
521
+ const failedBatch = (state.batches || []).find(b => b.status === 'failed');
522
+ if (failedBatch) { state.status = 'batch_blocked'; return; }
523
+ if (allBatchesCompleted(state)) {
524
+ if (state.judge?.status === 'completed') state.status = 'judged';
525
+ else state.status = 'batches_completed';
526
+ return;
527
+ }
528
+ if ((state.batches || []).some(b => b.status === 'running')) state.status = 'running';
529
+ else if ((state.batches || []).some(b => b.status === 'pending')) {
530
+ state.status = state.status === 'running' ? 'running' : 'planned';
531
+ }
532
+ }
533
+
534
+ function hasPendingRunnableBatch(state) {
535
+ if (state.archived || state.status === 'stopped') return false;
536
+ const batch = currentBatch(state);
537
+ if (!batch) return false;
538
+ if (batch.status === 'failed' || batch.status === 'blocked') return false;
539
+ return (batch.tasks || []).some(t => t.status === 'pending');
540
+ }
541
+
542
+ function allBatchesCompleted(state) {
543
+ return !!(state.batches?.length) && state.batches.every(b => b.status === 'completed');
544
+ }
545
+
546
+ async function buildJudgeInput(state) {
547
+ const runDir = pathForRun(state.runId);
548
+ const taskText = await readTextMaybe(path.join(runDir, 'task.md'), 1000000);
549
+ const plan = await readJson(planPath(runDir), null);
550
+ const tasks = [];
551
+ for (const task of state.tasks || []) {
552
+ const dir = roleDir(runDir, 'worker', task.id);
553
+ tasks.push({
554
+ id: task.id,
555
+ name: task.name,
556
+ batchId: task.batchId,
557
+ status: task.status,
558
+ originalStatus: task.originalStatus,
559
+ exitCode: task.exitCode ?? null,
560
+ originalExitCode: task.originalExitCode ?? null,
561
+ startedAt: task.startedAt,
562
+ endedAt: task.endedAt,
563
+ completedAt: task.completedAt,
564
+ expectedArtifacts: task.expectedArtifacts || [],
565
+ artifacts: task.artifacts || [],
566
+ lastMessage: await readTextMaybe(path.join(dir, 'last_message.md'), 200000),
567
+ resultJson: await readJson(path.join(dir, 'result.json'), null),
568
+ evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
569
+ manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
570
+ stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
571
+ });
572
+ }
573
+ return {
574
+ type: 'codex_orchestrator_judge_input',
575
+ version: 1,
576
+ generatedAt: nowIso(),
577
+ run: {
578
+ runId: state.runId,
579
+ label: state.label,
580
+ repo: state.repo,
581
+ status: state.status,
582
+ createdAt: state.createdAt,
583
+ updatedAt: state.updatedAt,
584
+ maxParallel: state.maxParallel
585
+ },
586
+ taskText,
587
+ plan,
588
+ batches: (state.batches || []).map(batch => ({
589
+ id: batch.id,
590
+ name: batch.name,
591
+ status: batch.status,
592
+ maxParallel: batch.maxParallel,
593
+ taskIds: (batch.tasks || []).map(task => task.id)
594
+ })),
595
+ planner: {
596
+ status: state.planner?.status,
597
+ exitCode: state.planner?.exitCode ?? null,
598
+ planParseError: state.planner?.planParseError,
599
+ planEmpty: !!state.planner?.planEmpty,
600
+ lastMessage: await readTextMaybe(path.join(roleDir(runDir, 'planner'), 'last_message.md'), 200000)
601
+ },
602
+ tasks
603
+ };
604
+ }
605
+
606
+ function ensureBatchShape(state) {
607
+ if (!Array.isArray(state.batches) || !state.batches.length) {
608
+ if (Array.isArray(state.tasks) && state.tasks.length) {
609
+ state.batches = [{ id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(state.maxParallel) || 1), status: 'pending', tasks: state.tasks }];
610
+ for (const t of state.tasks) t.batchId = t.batchId || 'batch-1';
611
+ } else state.batches = [];
612
+ }
613
+ state.tasks = (state.batches || []).flatMap(b => {
614
+ b.tasks = Array.isArray(b.tasks) ? b.tasks : [];
615
+ for (const t of b.tasks) t.batchId = t.batchId || b.id;
616
+ return b.tasks;
617
+ });
618
+ }
619
+
620
+ async function enrichFromAppServer(state, appClient) {
621
+ const res = await appClient.listThreads({ cwd: state.repo, limit: 100 });
622
+ const threads = res?.data || [];
623
+ const all = [{ id: 'planner', target: state.planner }, ...(state.tasks || []).map(t => ({ id: t.id, target: t })), { id: 'judge', target: state.judge }];
624
+ for (const item of all) {
625
+ const thread = threads.find(th => matchThreadToMarkers(th, state.runId, item.id));
626
+ if (thread && item.target) item.target.codexThread = { id: thread.id, sessionId: thread.sessionId, source: thread.source, status: thread.status, preview: thread.preview, updatedAt: thread.updatedAt };
627
+ }
628
+ }
629
+
630
+ function summaryOfRun(s) {
631
+ const tasks = s.tasks || [];
632
+ return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
633
+ }
634
+
635
+ function formatCodexEventsJsonl(text) {
636
+ if (!text.trim()) return '暂无事件日志。';
637
+ const lines = text.split(/\r?\n/).filter(Boolean);
638
+ return lines.map((line, index) => {
639
+ const seq = String(index + 1).padStart(3, '0');
640
+ let event;
641
+ try { event = JSON.parse(line); }
642
+ catch { return `[${seq}] 无法解析事件\n${line}`; }
643
+ return formatCodexEvent(seq, event);
644
+ }).join('\n\n');
645
+ }
646
+
647
+ function formatCodexEvent(seq, event) {
648
+ switch (event.type) {
649
+ case 'thread.started':
650
+ return `[${seq}] Codex 会话开始\n 会话ID: ${event.thread_id || '-'}`;
651
+ case 'turn.started':
652
+ return `[${seq}] 回合开始`;
653
+ case 'turn.completed':
654
+ return `[${seq}] 回合完成\n${formatKnownFields(event, ['status', 'error', 'usage'])}`.trimEnd();
655
+ case 'item.started':
656
+ return formatCodexItem(seq, '开始', event.item);
657
+ case 'item.completed':
658
+ return formatCodexItem(seq, '完成', event.item);
659
+ case 'error':
660
+ return `[${seq}] 错误\n${formatJson(event)}`;
661
+ default:
662
+ return `[${seq}] ${event.type || '未知事件'}\n${formatJson(event)}`;
663
+ }
664
+ }
665
+
666
+ function formatCodexItem(seq, action, item = {}) {
667
+ const type = item.type || 'unknown';
668
+ const title = `[${seq}] ${action}: ${displayItemType(type)}`;
669
+ if (type === 'command_execution') {
670
+ const parts = [title];
671
+ if (item.command) parts.push(` 命令: ${item.command}`);
672
+ if (item.status) parts.push(` 状态: ${item.status}`);
673
+ if (item.exit_code !== undefined && item.exit_code !== null) parts.push(` 退出码: ${item.exit_code}`);
674
+ if (item.aggregated_output) parts.push(` 输出:\n${indentText(truncateText(item.aggregated_output))}`);
675
+ return parts.join('\n');
676
+ }
677
+ if (type === 'agent_message' || type === 'agentMessage') {
678
+ const text = item.text || item.message || item.content || '';
679
+ return text ? `${title}\n 内容:\n${indentText(truncateText(String(text)))}` : title;
680
+ }
681
+ if (type === 'reasoning') {
682
+ const summary = item.summary || item.content || '';
683
+ return summary ? `${title}\n 摘要:\n${indentText(truncateText(Array.isArray(summary) ? summary.join('\n') : String(summary)))}` : title;
684
+ }
685
+ if (type === 'file_change' || type === 'fileChange') {
686
+ return `${title}\n${formatKnownFields(item, ['status', 'path', 'changes'])}`.trimEnd();
687
+ }
688
+ return `${title}\n${formatJson(item)}`;
689
+ }
690
+
691
+ function displayItemType(type) {
692
+ return {
693
+ command_execution: '命令执行',
694
+ agent_message: '模型回复',
695
+ agentMessage: '模型回复',
696
+ reasoning: '推理',
697
+ file_change: '文件变更',
698
+ fileChange: '文件变更',
699
+ mcp_tool_call: 'MCP 工具调用',
700
+ mcpToolCall: 'MCP 工具调用'
701
+ }[type] || type;
702
+ }
703
+
704
+ function formatKnownFields(obj, fields) {
705
+ return fields
706
+ .filter(field => obj[field] !== undefined && obj[field] !== null)
707
+ .map(field => ` ${field}: ${typeof obj[field] === 'string' ? obj[field] : JSON.stringify(obj[field], null, 2)}`)
708
+ .join('\n');
709
+ }
710
+
711
+ function formatJson(value) { return indentText(JSON.stringify(value, null, 2)); }
712
+ function indentText(text) { return String(text).split('\n').map(line => ` ${line}`).join('\n'); }
713
+ function truncateText(text, max = 12000) { return text.length > max ? `${text.slice(0, max)}\n...<已截断 ${text.length - max} 字符>` : text; }
714
+
715
+ export async function readRunTaskText(runId) {
716
+ return await readTextMaybe(path.join(pathForRun(runId), 'task.md'), 1000000);
717
+ }
718
+
719
+ export async function readRunFile(runId, taskId, name) {
720
+ const runDir = pathForRun(runId);
721
+ const allowed = new Set(['prompt.md','events.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json']);
722
+ if (!allowed.has(name)) throw new Error('file not allowed');
723
+ let dir;
724
+ if (taskId === 'planner') dir = roleDir(runDir, 'planner');
725
+ else if (taskId === 'judge') dir = roleDir(runDir, 'judge');
726
+ else dir = roleDir(runDir, 'worker', taskId);
727
+ if (name === 'events.pretty') {
728
+ const text = await readTextMaybe(path.join(dir, 'events.jsonl'), 1000000);
729
+ return formatCodexEventsJsonl(text);
730
+ }
731
+ return await readTextMaybe(path.join(dir, name), 1000000);
732
+ }
733
+
734
+ export { RUNS_DIR };