input-kanban 0.0.2 → 0.0.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/ENVIRONMENT.md +10 -1
- package/PROJECT_GUIDE.md +36 -4
- package/README.en.md +13 -1
- package/README.md +13 -1
- package/bin/input-kanban-format-events.js +12 -0
- package/bin/input-kanban-tmux-overview.js +66 -0
- package/bin/input-kanban.js +12 -1
- package/package.json +2 -2
- package/public/index.html +166 -29
- package/src/eventFormatter.js +85 -0
- package/src/orchestrator.js +139 -131
- package/src/runners/headlessRunner.js +51 -0
- package/src/runners/index.js +13 -0
- package/src/runners/tmuxRunner.js +220 -0
- package/src/runners/tmuxUtils.js +17 -0
- package/src/server.js +3 -3
- package/src/tmux.js +156 -0
- package/src/utils.js +9 -0
package/src/orchestrator.js
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import fsp from 'node:fs/promises';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import {
|
|
6
|
-
|
|
5
|
+
DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
|
|
7
6
|
writeJsonAtomic, fileInfo, readTextMaybe, extractFirstJsonObject, listRunDirs,
|
|
8
|
-
pathForRun, roleDir, safeIdPart
|
|
7
|
+
pathForRun, roleDir, safeIdPart, RUNNER
|
|
9
8
|
} from './utils.js';
|
|
10
9
|
import { matchThreadToMarkers } from './appServerClient.js';
|
|
10
|
+
import { formatCodexEventsJsonl } from './eventFormatter.js';
|
|
11
|
+
import { defaultRunner } from './runners/index.js';
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
+
const runner = defaultRunner;
|
|
14
|
+
const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
15
|
+
|
|
16
|
+
function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
17
|
+
const sandbox = String(value || '').trim();
|
|
18
|
+
if (VALID_SANDBOXES.has(sandbox)) return sandbox;
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
15
23
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
16
24
|
|
|
17
|
-
export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3 } = {}) {
|
|
25
|
+
export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
18
26
|
const runId = makeRunId(label);
|
|
19
27
|
const runDir = pathForRun(runId);
|
|
20
28
|
await ensureDir(runDir);
|
|
21
29
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
22
30
|
const state = {
|
|
23
|
-
runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3,
|
|
31
|
+
runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
|
|
32
|
+
runner: RUNNER,
|
|
24
33
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
25
34
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
26
35
|
};
|
|
@@ -74,7 +83,7 @@ Preferred schema with blocking batches:
|
|
|
74
83
|
"id": "T-01",
|
|
75
84
|
"name": "short name",
|
|
76
85
|
"prompt": "complete worker prompt",
|
|
77
|
-
"sandbox": "workspace-write",
|
|
86
|
+
"sandbox": "${state.workerSandbox || 'workspace-write'}",
|
|
78
87
|
"expectedArtifacts": []
|
|
79
88
|
}
|
|
80
89
|
]
|
|
@@ -90,7 +99,7 @@ Backward-compatible schema also accepted:
|
|
|
90
99
|
"id": "T-01",
|
|
91
100
|
"name": "short name",
|
|
92
101
|
"prompt": "complete worker prompt",
|
|
93
|
-
"sandbox": "workspace-write",
|
|
102
|
+
"sandbox": "${state.workerSandbox || 'workspace-write'}",
|
|
94
103
|
"expectedArtifacts": []
|
|
95
104
|
}
|
|
96
105
|
]
|
|
@@ -101,6 +110,7 @@ Rules:
|
|
|
101
110
|
- Use batch maxParallel to express whether tasks in the same batch may run concurrently or serially.
|
|
102
111
|
- Keep tasks scoped and independently executable.
|
|
103
112
|
- Include exact output/artifact expectations in each worker prompt.
|
|
113
|
+
- Default worker sandbox for this run is ${state.workerSandbox || 'workspace-write'}; use that sandbox unless a task has a specific safety reason to be stricter.
|
|
104
114
|
- If the input already contains task sections, preserve their ids when practical.
|
|
105
115
|
|
|
106
116
|
User task:
|
|
@@ -132,24 +142,6 @@ Plan: ${path.join(pathForRun(state.runId), 'plan.json')}
|
|
|
132
142
|
`;
|
|
133
143
|
}
|
|
134
144
|
|
|
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
145
|
export async function startPlanner(runId) {
|
|
154
146
|
const state = await loadRun(runId);
|
|
155
147
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
@@ -168,11 +160,11 @@ export async function startPlanner(runId) {
|
|
|
168
160
|
await fsp.rm(planPath(runDir), { force: true });
|
|
169
161
|
const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
|
|
170
162
|
const prompt = defaultPlannerPrompt(state, taskText);
|
|
171
|
-
const child =
|
|
163
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
172
164
|
state.status = 'planning';
|
|
173
165
|
state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
|
|
174
166
|
await saveRun(state);
|
|
175
|
-
child.
|
|
167
|
+
child.onExit(async code => {
|
|
176
168
|
const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
|
|
177
169
|
s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
|
|
178
170
|
const planResult = await materializePlan(s);
|
|
@@ -185,14 +177,14 @@ export async function startPlanner(runId) {
|
|
|
185
177
|
return state;
|
|
186
178
|
}
|
|
187
179
|
|
|
188
|
-
function normalizeTask(t, i, batch) {
|
|
180
|
+
function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
|
|
189
181
|
const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
|
|
190
182
|
return {
|
|
191
183
|
id,
|
|
192
184
|
batchId: batch.id,
|
|
193
185
|
name: t.name || t.id || `Task ${i + 1}`,
|
|
194
186
|
prompt: t.prompt || t.instructions || '',
|
|
195
|
-
sandbox: t.sandbox
|
|
187
|
+
sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
|
|
196
188
|
expectedArtifacts: Array.isArray(t.expectedArtifacts) ? t.expectedArtifacts : [],
|
|
197
189
|
status: 'pending'
|
|
198
190
|
};
|
|
@@ -225,7 +217,7 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
225
217
|
}];
|
|
226
218
|
}
|
|
227
219
|
|
|
228
|
-
function normalizePlan(plan, defaultMaxParallel) {
|
|
220
|
+
function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
|
|
229
221
|
if (Array.isArray(plan.batches)) {
|
|
230
222
|
const batches = plan.batches.map((b, bi) => {
|
|
231
223
|
const batch = {
|
|
@@ -235,14 +227,14 @@ function normalizePlan(plan, defaultMaxParallel) {
|
|
|
235
227
|
status: 'pending',
|
|
236
228
|
tasks: []
|
|
237
229
|
};
|
|
238
|
-
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch));
|
|
230
|
+
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox));
|
|
239
231
|
return batch;
|
|
240
232
|
}).filter(b => b.tasks.length);
|
|
241
233
|
return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
|
|
242
234
|
}
|
|
243
235
|
if (Array.isArray(plan.tasks)) {
|
|
244
236
|
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));
|
|
237
|
+
batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox));
|
|
246
238
|
return { ...plan, batches: [batch], tasks: batch.tasks };
|
|
247
239
|
}
|
|
248
240
|
return null;
|
|
@@ -258,7 +250,7 @@ async function materializePlan(state) {
|
|
|
258
250
|
state.tasks = [];
|
|
259
251
|
return { ok: false, empty: false, error: state.planner.planParseError };
|
|
260
252
|
}
|
|
261
|
-
const normalized = normalizePlan(plan, state.maxParallel);
|
|
253
|
+
const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
|
|
262
254
|
if (!normalized || !Array.isArray(normalized.tasks)) {
|
|
263
255
|
state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
|
|
264
256
|
state.batches = [];
|
|
@@ -304,7 +296,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
|
|
|
304
296
|
|
|
305
297
|
${task.prompt}
|
|
306
298
|
`;
|
|
307
|
-
const child =
|
|
299
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: state.repo, outDir });
|
|
308
300
|
Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
|
|
309
301
|
}
|
|
310
302
|
|
|
@@ -312,12 +304,7 @@ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
|
|
|
312
304
|
const state = await loadRun(runId);
|
|
313
305
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
314
306
|
const stoppedAt = nowIso();
|
|
315
|
-
|
|
316
|
-
if (key.startsWith(`${runId}:`)) {
|
|
317
|
-
try { child.kill('TERM'); } catch {}
|
|
318
|
-
runningChildren.delete(key);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
307
|
+
await runner.stopRun(runId);
|
|
321
308
|
for (const roleState of [state.planner, state.judge]) {
|
|
322
309
|
if (roleState?.status === 'running') Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
323
310
|
}
|
|
@@ -400,11 +387,11 @@ export async function startJudge(runId) {
|
|
|
400
387
|
const judgeInput = await buildJudgeInput(state);
|
|
401
388
|
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
402
389
|
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
403
|
-
const child =
|
|
390
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
404
391
|
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
|
|
405
392
|
state.status = 'judging';
|
|
406
393
|
await saveRun(state);
|
|
407
|
-
child.
|
|
394
|
+
child.onExit(async code => {
|
|
408
395
|
const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
|
|
409
396
|
s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
|
|
410
397
|
const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
|
|
@@ -423,9 +410,13 @@ export async function refreshRun(runId, appClient = null) {
|
|
|
423
410
|
async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
|
|
424
411
|
const state = await loadRun(runId);
|
|
425
412
|
if (!state) return null;
|
|
413
|
+
state.runner = state.runner || RUNNER;
|
|
426
414
|
await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
|
|
415
|
+
await recoverCompletedPlanner(state);
|
|
427
416
|
for (const task of state.tasks || []) await refreshTask(state, task);
|
|
428
417
|
await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
|
|
418
|
+
await recoverCompletedJudge(state);
|
|
419
|
+
aggregateRunTmuxMetadata(state);
|
|
429
420
|
recomputeRunStatus(state);
|
|
430
421
|
await scheduleMoreWorkers(state);
|
|
431
422
|
recomputeRunStatus(state);
|
|
@@ -434,19 +425,42 @@ async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}
|
|
|
434
425
|
return state;
|
|
435
426
|
}
|
|
436
427
|
|
|
428
|
+
async function recoverCompletedPlanner(state) {
|
|
429
|
+
if (state.planner?.status !== 'completed' || state.tasks?.length || state.batches?.length) return;
|
|
430
|
+
const planResult = await materializePlan(state);
|
|
431
|
+
if (planResult.ok) state.status = 'planned';
|
|
432
|
+
else if (planResult.empty) state.status = 'plan_empty';
|
|
433
|
+
else state.status = 'plan_failed';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function recoverCompletedJudge(state) {
|
|
437
|
+
if (!['completed', 'failed'].includes(state.judge?.status)) return;
|
|
438
|
+
if (state.judge.status === 'completed' && !state.judge.verdict) {
|
|
439
|
+
const outDir = roleDir(pathForRun(state.runId), 'judge');
|
|
440
|
+
const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
|
|
441
|
+
const verdict = extractFirstJsonObject(text);
|
|
442
|
+
if (verdict) {
|
|
443
|
+
state.judge.verdict = verdict;
|
|
444
|
+
await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
state.status = state.judge.status === 'completed' ? 'judged' : 'judge_failed';
|
|
448
|
+
}
|
|
449
|
+
|
|
437
450
|
async function refreshRole(state, roleState, dir) {
|
|
438
451
|
if (!roleState) return;
|
|
439
452
|
const exitPath = path.join(dir, 'exit_code');
|
|
440
453
|
const exit = await readTextMaybe(exitPath, 1000);
|
|
441
454
|
const exitInfo = await fileInfo(exitPath);
|
|
442
|
-
const key =
|
|
455
|
+
const key = roleState === state.judge ? 'judge' : 'planner';
|
|
443
456
|
if (exit !== '') {
|
|
444
457
|
roleState.exitCode = Number(exit.trim());
|
|
445
458
|
if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
|
|
446
459
|
if (roleState.status === 'running') roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
|
|
447
460
|
}
|
|
448
|
-
else if (roleState.status === 'running' && !
|
|
461
|
+
else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
|
|
449
462
|
roleState.files = await standardFiles(dir);
|
|
463
|
+
await attachTmuxMetadata(roleState, dir);
|
|
450
464
|
}
|
|
451
465
|
|
|
452
466
|
async function refreshTask(state, task) {
|
|
@@ -454,13 +468,14 @@ async function refreshTask(state, task) {
|
|
|
454
468
|
const exitPath = path.join(dir, 'exit_code');
|
|
455
469
|
const exit = await readTextMaybe(exitPath, 1000);
|
|
456
470
|
const exitInfo = await fileInfo(exitPath);
|
|
457
|
-
const key = `${state.runId}:${task.id}`;
|
|
458
471
|
if (exit !== '') {
|
|
459
472
|
task.exitCode = Number(exit.trim());
|
|
460
473
|
if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
|
|
461
474
|
if (task.status === 'running') task.status = task.exitCode === 0 ? 'completed' : 'failed';
|
|
462
|
-
} else if (task.status === 'running' && !
|
|
475
|
+
} else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
|
|
463
476
|
task.files = await standardFiles(dir);
|
|
477
|
+
await attachTmuxMetadata(task, dir);
|
|
478
|
+
delete task.attentionHint;
|
|
464
479
|
task.artifacts = [];
|
|
465
480
|
for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
|
|
466
481
|
const batch = (state.batches || []).find(b => b.id === task.batchId);
|
|
@@ -470,13 +485,82 @@ async function refreshTask(state, task) {
|
|
|
470
485
|
}
|
|
471
486
|
}
|
|
472
487
|
|
|
488
|
+
async function attachTmuxMetadata(target, dir) {
|
|
489
|
+
const raw = await readJson(path.join(dir, 'tmux.json'), null);
|
|
490
|
+
if (!raw || raw.runner !== 'tmux') {
|
|
491
|
+
delete target.tmux;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (raw.ready !== true) {
|
|
495
|
+
target.tmux = {
|
|
496
|
+
runner: 'tmux',
|
|
497
|
+
ready: false,
|
|
498
|
+
status: raw.status || 'pending',
|
|
499
|
+
sessionName: raw.sessionName || '',
|
|
500
|
+
windowName: raw.windowName || '',
|
|
501
|
+
target: raw.target || '',
|
|
502
|
+
runScript: raw.runScript || '',
|
|
503
|
+
startedAt: raw.startedAt || '',
|
|
504
|
+
error: raw.error || ''
|
|
505
|
+
};
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const selectWindowCommand = raw.selectWindowCommand || raw.selectCommand || '';
|
|
509
|
+
target.tmux = {
|
|
510
|
+
runner: 'tmux',
|
|
511
|
+
ready: true,
|
|
512
|
+
status: raw.status || 'ready',
|
|
513
|
+
sessionName: raw.sessionName || '',
|
|
514
|
+
windowName: raw.windowName || '',
|
|
515
|
+
target: raw.target || '',
|
|
516
|
+
attachCommand: raw.attachCommand || '',
|
|
517
|
+
selectWindowCommand,
|
|
518
|
+
runScript: raw.runScript || '',
|
|
519
|
+
startedAt: raw.startedAt || '',
|
|
520
|
+
readyAt: raw.readyAt || ''
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function aggregateRunTmuxMetadata(state) {
|
|
525
|
+
const roles = [state.planner, ...(state.tasks || []), state.judge].filter(Boolean);
|
|
526
|
+
const entries = roles.map(role => role.tmux).filter(tmux => tmux?.runner === 'tmux');
|
|
527
|
+
const readyEntries = entries.filter(tmux => tmux.ready === true);
|
|
528
|
+
if (!entries.length) {
|
|
529
|
+
if ((state.runner || RUNNER) === 'tmux') {
|
|
530
|
+
state.tmux = {
|
|
531
|
+
runner: 'tmux',
|
|
532
|
+
hasTmuxSession: false
|
|
533
|
+
};
|
|
534
|
+
} else {
|
|
535
|
+
delete state.tmux;
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const withSession = readyEntries.find(tmux => tmux.sessionName || tmux.target || tmux.windowName);
|
|
540
|
+
if (!withSession) {
|
|
541
|
+
state.tmux = {
|
|
542
|
+
runner: 'tmux',
|
|
543
|
+
hasTmuxSession: false
|
|
544
|
+
};
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
state.tmux = {
|
|
548
|
+
runner: 'tmux',
|
|
549
|
+
hasTmuxSession: true,
|
|
550
|
+
tmuxSessionName: withSession.sessionName || ''
|
|
551
|
+
};
|
|
552
|
+
if (withSession.attachCommand) state.tmux.tmuxAttachCommand = withSession.attachCommand;
|
|
553
|
+
}
|
|
554
|
+
|
|
473
555
|
async function standardFiles(dir) {
|
|
474
556
|
return {
|
|
475
557
|
prompt: await fileInfo(path.join(dir, 'prompt.md')),
|
|
476
558
|
events: await fileInfo(path.join(dir, 'events.jsonl')),
|
|
477
559
|
stderr: await fileInfo(path.join(dir, 'stderr.log')),
|
|
478
560
|
lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
|
|
479
|
-
exitCode: await fileInfo(path.join(dir, 'exit_code'))
|
|
561
|
+
exitCode: await fileInfo(path.join(dir, 'exit_code')),
|
|
562
|
+
runScript: await fileInfo(path.join(dir, 'run.sh')),
|
|
563
|
+
tmux: await fileInfo(path.join(dir, 'tmux.json'))
|
|
480
564
|
};
|
|
481
565
|
}
|
|
482
566
|
|
|
@@ -567,6 +651,7 @@ async function buildJudgeInput(state) {
|
|
|
567
651
|
resultJson: await readJson(path.join(dir, 'result.json'), null),
|
|
568
652
|
evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
|
|
569
653
|
manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
|
|
654
|
+
tmux: task.tmux || null,
|
|
570
655
|
stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
|
|
571
656
|
});
|
|
572
657
|
}
|
|
@@ -579,9 +664,11 @@ async function buildJudgeInput(state) {
|
|
|
579
664
|
label: state.label,
|
|
580
665
|
repo: state.repo,
|
|
581
666
|
status: state.status,
|
|
667
|
+
runner: state.runner || RUNNER,
|
|
582
668
|
createdAt: state.createdAt,
|
|
583
669
|
updatedAt: state.updatedAt,
|
|
584
|
-
maxParallel: state.maxParallel
|
|
670
|
+
maxParallel: state.maxParallel,
|
|
671
|
+
workerSandbox: state.workerSandbox || 'workspace-write'
|
|
585
672
|
},
|
|
586
673
|
taskText,
|
|
587
674
|
plan,
|
|
@@ -597,6 +684,7 @@ async function buildJudgeInput(state) {
|
|
|
597
684
|
exitCode: state.planner?.exitCode ?? null,
|
|
598
685
|
planParseError: state.planner?.planParseError,
|
|
599
686
|
planEmpty: !!state.planner?.planEmpty,
|
|
687
|
+
tmux: state.planner?.tmux || null,
|
|
600
688
|
lastMessage: await readTextMaybe(path.join(roleDir(runDir, 'planner'), 'last_message.md'), 200000)
|
|
601
689
|
},
|
|
602
690
|
tasks
|
|
@@ -629,96 +717,16 @@ async function enrichFromAppServer(state, appClient) {
|
|
|
629
717
|
|
|
630
718
|
function summaryOfRun(s) {
|
|
631
719
|
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
|
-
}
|
|
720
|
+
return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', 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 })) };
|
|
664
721
|
}
|
|
665
722
|
|
|
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
723
|
export async function readRunTaskText(runId) {
|
|
716
724
|
return await readTextMaybe(path.join(pathForRun(runId), 'task.md'), 1000000);
|
|
717
725
|
}
|
|
718
726
|
|
|
719
727
|
export async function readRunFile(runId, taskId, name) {
|
|
720
728
|
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']);
|
|
729
|
+
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','run.sh','tmux.json']);
|
|
722
730
|
if (!allowed.has(name)) throw new Error('file not allowed');
|
|
723
731
|
let dir;
|
|
724
732
|
if (taskId === 'planner') dir = roleDir(runDir, 'planner');
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CODEX_BIN } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
function processKey(runId, taskId) {
|
|
7
|
+
return `${runId}:${taskId}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createHeadlessRunner({ codexBin = CODEX_BIN } = {}) {
|
|
11
|
+
const runningProcesses = new Map();
|
|
12
|
+
|
|
13
|
+
function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
|
|
14
|
+
const events = path.join(outDir, 'events.jsonl');
|
|
15
|
+
const stderr = path.join(outDir, 'stderr.log');
|
|
16
|
+
const last = path.join(outDir, 'last_message.md');
|
|
17
|
+
fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
|
|
18
|
+
const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
|
|
19
|
+
const child = spawn(codexBin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
20
|
+
child.stdout.pipe(fs.createWriteStream(events, { flags: 'a' }));
|
|
21
|
+
child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
|
|
22
|
+
const key = processKey(runId, taskId);
|
|
23
|
+
runningProcesses.set(key, child);
|
|
24
|
+
child.on('exit', code => {
|
|
25
|
+
try { fs.writeFileSync(path.join(outDir, 'exit_code'), String(code)); } catch {}
|
|
26
|
+
runningProcesses.delete(key);
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
pid: child.pid,
|
|
30
|
+
onExit(listener) { child.on('exit', listener); },
|
|
31
|
+
stop(signal = 'TERM') { child.kill(signal); }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stopRun(runId, signal = 'TERM') {
|
|
36
|
+
for (const [key, child] of runningProcesses.entries()) {
|
|
37
|
+
if (key.startsWith(`${runId}:`)) {
|
|
38
|
+
try { child.kill(signal); } catch {}
|
|
39
|
+
runningProcesses.delete(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasRunning(runId, taskId) {
|
|
45
|
+
return runningProcesses.has(processKey(runId, taskId));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { kind: 'headless', startCodexTask, stopRun, hasRunning };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const headlessRunner = createHeadlessRunner();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { headlessRunner } from './headlessRunner.js';
|
|
2
|
+
import { createTmuxRunner } from './tmuxRunner.js';
|
|
3
|
+
import { RUNNER } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
export { createHeadlessRunner, headlessRunner } from './headlessRunner.js';
|
|
6
|
+
export { createTmuxRunner } from './tmuxRunner.js';
|
|
7
|
+
|
|
8
|
+
export function createDefaultRunner(runnerMode = RUNNER) {
|
|
9
|
+
if (runnerMode === 'tmux') return createTmuxRunner();
|
|
10
|
+
return headlessRunner;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const defaultRunner = createDefaultRunner();
|