input-kanban 0.0.1 → 0.0.3
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 +7 -1
- package/PROJECT_GUIDE.md +24 -0
- package/README.en.md +148 -0
- package/README.md +87 -96
- package/bin/input-kanban.js +12 -1
- package/package.json +3 -2
- package/public/index.html +85 -8
- package/src/orchestrator.js +126 -41
- package/src/runners/headlessRunner.js +51 -0
- package/src/runners/index.js +13 -0
- package/src/runners/tmuxRunner.js +170 -0
- package/src/runners/tmuxUtils.js +15 -0
- package/src/server.js +3 -3
- package/src/tmux.js +139 -0
- package/src/utils.js +9 -0
package/public/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>
|
|
6
|
+
<title>Input 看板</title>
|
|
7
7
|
<style>
|
|
8
8
|
:root { --bg:#0b1220; --panel:#111827; --panel-2:#0f172a; --line:#334155; --line-strong:#64748b; --text:#e2e8f0; --muted:#94a3b8; --blue:#2563eb; --green:#166534; --red:#991b1b; --gray:#475569; --orange:#b45309; }
|
|
9
9
|
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: var(--bg); color: var(--text); }
|
|
@@ -32,11 +32,14 @@
|
|
|
32
32
|
th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
|
|
33
33
|
th:nth-child(5), td:nth-child(5) { width: 70px; }
|
|
34
34
|
th:nth-child(6), td:nth-child(6) { width: 58px; }
|
|
35
|
-
th:nth-child(8), td:nth-child(8) { width:
|
|
36
|
-
th:nth-child(9), td:nth-child(9) { width:
|
|
35
|
+
th:nth-child(8), td:nth-child(8) { width: 118px; }
|
|
36
|
+
th:nth-child(9), td:nth-child(9) { width: 66px; }
|
|
37
|
+
th:nth-child(10), td:nth-child(10) { width: 94px; }
|
|
37
38
|
th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
|
38
39
|
tr:hover { background: #162033; cursor: pointer; }
|
|
39
40
|
.pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
|
|
41
|
+
.attention-hint { display: block; margin-top: 6px; color: #fbbf24; font-size: 12px; font-weight: 700; white-space: normal; line-height: 1.35; }
|
|
42
|
+
.attention-hint code { color: #fde68a; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; word-break: break-all; }
|
|
40
43
|
.completed, .judged, .workers_completed, .planned, .batches_completed { background: var(--green); }
|
|
41
44
|
.running, .planning, .judging { background: var(--blue); }
|
|
42
45
|
.failed, .workers_failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
|
|
@@ -75,6 +78,12 @@
|
|
|
75
78
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
76
79
|
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
77
80
|
.notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
|
|
81
|
+
.tmux-box { margin: 8px 0 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #020617; color: #cbd5e1; font-size: 12px; }
|
|
82
|
+
.tmux-box.hidden { display: none; }
|
|
83
|
+
.tmux-box-title { font-weight: 800; color: var(--text); margin-bottom: 5px; }
|
|
84
|
+
.tmux-actions { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
85
|
+
.tmux-actions button { margin: 0; }
|
|
86
|
+
.tmux-inline { display: block; margin-top: 3px; word-break: break-all; }
|
|
78
87
|
.file-content-wrap { position: relative; }
|
|
79
88
|
.floating-copy-btn { position: absolute; top: 8px; left: 8px; z-index: 2; opacity: 0; pointer-events: none; transition: opacity .15s; padding: 5px 8px; background: rgba(71,85,105,.92); }
|
|
80
89
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
@@ -82,7 +91,7 @@
|
|
|
82
91
|
</style>
|
|
83
92
|
</head>
|
|
84
93
|
<body>
|
|
85
|
-
<header><h1>
|
|
94
|
+
<header><h1>Input 看板</h1></header>
|
|
86
95
|
<main>
|
|
87
96
|
<div class="sidebar">
|
|
88
97
|
<section>
|
|
@@ -144,6 +153,7 @@
|
|
|
144
153
|
<button class="secondary" onclick="loadFile('verdict.json')">验收结论</button>
|
|
145
154
|
</div>
|
|
146
155
|
<div id="executionSummary" class="execution-summary hidden"></div>
|
|
156
|
+
<div id="tmuxPanel" class="tmux-box hidden"></div>
|
|
147
157
|
<div class="file-content-wrap">
|
|
148
158
|
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制最终回复内容" onclick="copyFileContent(event)">⧉</button>
|
|
149
159
|
<pre id="fileContent"></pre>
|
|
@@ -192,6 +202,13 @@ function durationSeconds(start, end) {
|
|
|
192
202
|
return Math.max(0, Math.round((endMs - new Date(start).getTime()) / 1000));
|
|
193
203
|
}
|
|
194
204
|
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, s.updatedAt)} 秒`; }
|
|
205
|
+
function isTmuxMode() { return currentState?.runner === 'tmux'; }
|
|
206
|
+
function taskById(id) {
|
|
207
|
+
if (!currentState) return null;
|
|
208
|
+
if (id === 'planner') return currentState.planner;
|
|
209
|
+
if (id === 'judge') return currentState.judge;
|
|
210
|
+
return (currentState.tasks || []).find(t => t.id === id) || null;
|
|
211
|
+
}
|
|
195
212
|
|
|
196
213
|
async function loadHealth() {
|
|
197
214
|
const h = await api('/api/health');
|
|
@@ -292,7 +309,14 @@ function taskStatusCell(t) {
|
|
|
292
309
|
const original = t.originalStatus || t.manualCompletion.originalStatus || t.manualCompletion.previousStatus || 'unknown';
|
|
293
310
|
return `${pill(original)} <span class="pill completed">手动标记成功已完成</span>`;
|
|
294
311
|
}
|
|
295
|
-
|
|
312
|
+
const hint = attentionHintCell(t);
|
|
313
|
+
return `${pill(t?.status)}${hint}`;
|
|
314
|
+
}
|
|
315
|
+
function attentionHintCell(t) {
|
|
316
|
+
if (!t?.attentionHint) return '';
|
|
317
|
+
const command = t.attentionHint.attachCommand ? ` <code>${esc(t.attentionHint.attachCommand)}</code>` : '';
|
|
318
|
+
const reasons = Array.isArray(t.attentionHint.reasons) && t.attentionHint.reasons.length ? `|${esc(t.attentionHint.reasons.join(' / '))}` : '';
|
|
319
|
+
return `<span class="attention-hint" title="${esc(t.attentionHint.message || '')}">可能需要人工介入;请 attach tmux 检查。${command}${reasons}</span>`;
|
|
296
320
|
}
|
|
297
321
|
function taskActionCell(id, t) {
|
|
298
322
|
if (!t || id === 'planner' || id === 'judge') return '-';
|
|
@@ -304,6 +328,13 @@ function sessionCell(thread) {
|
|
|
304
328
|
if (!thread) return '-';
|
|
305
329
|
return `<span class="session-cell">${esc(thread)}</span><button class="copy-btn" title="复制 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button>`;
|
|
306
330
|
}
|
|
331
|
+
function tmuxCell(t) {
|
|
332
|
+
if (!isTmuxMode()) return '-';
|
|
333
|
+
const tmux = t?.tmux;
|
|
334
|
+
if (!tmux) return '<span class="muted">未启动终端</span>';
|
|
335
|
+
const label = tmux.windowName || tmux.target || 'tmux';
|
|
336
|
+
return `<span class="session-cell" title="${esc(tmux.target || '')}">${esc(label)}</span>`;
|
|
337
|
+
}
|
|
307
338
|
function taskStartedCell(t) {
|
|
308
339
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
309
340
|
}
|
|
@@ -314,7 +345,7 @@ function taskDurationCell(t) {
|
|
|
314
345
|
}
|
|
315
346
|
function taskRow(id, role, t) {
|
|
316
347
|
const thread = t?.codexThread?.id || '';
|
|
317
|
-
return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${esc(t?.pid || '-')}</td><td>${esc(t?.exitCode ?? '-')}</td><td>${sessionCell(thread)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionCell(id, t)}</td></tr>`;
|
|
348
|
+
return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${esc(t?.pid || '-')}</td><td>${esc(t?.exitCode ?? '-')}</td><td>${sessionCell(thread)}</td><td>${tmuxCell(t)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionCell(id, t)}</td></tr>`;
|
|
318
349
|
}
|
|
319
350
|
function renderTasks() {
|
|
320
351
|
const s = currentState;
|
|
@@ -322,12 +353,12 @@ function renderTasks() {
|
|
|
322
353
|
if (Array.isArray(s.batches) && s.batches.length) {
|
|
323
354
|
for (const b of s.batches) {
|
|
324
355
|
const done = (b.tasks || []).filter(t => t.status === 'completed').length;
|
|
325
|
-
rows.push(`<tr class="batch-row"><td colspan="
|
|
356
|
+
rows.push(`<tr class="batch-row"><td colspan="10">${esc(b.name || b.id)} ${pill(b.status)} <span class="muted">${esc(b.id)}|最大并发 ${esc(b.maxParallel || '-')}|${done}/${(b.tasks || []).length}</span></td></tr>`);
|
|
326
357
|
for (const t of b.tasks || []) rows.push(taskRow(t.id, t.name, t));
|
|
327
358
|
}
|
|
328
359
|
} else rows.push(...(s.tasks||[]).map(t => taskRow(t.id, t.name, t)));
|
|
329
360
|
rows.push(taskRow('judge','最终验收',s.judge));
|
|
330
|
-
document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号</th><th>退出码</th><th>Codex 会话ID</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
|
|
361
|
+
document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号</th><th>退出码</th><th>Codex 会话ID</th><th>终端</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
|
|
331
362
|
}
|
|
332
363
|
async function selectTask(id) {
|
|
333
364
|
selectedTask = id;
|
|
@@ -347,12 +378,14 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
347
378
|
else pre.scrollTop = 0;
|
|
348
379
|
if (name === 'events.pretty') await renderExecutionSummary();
|
|
349
380
|
else hideExecutionSummary();
|
|
381
|
+
renderTmuxPanel();
|
|
350
382
|
updateCopyLastMessageButton();
|
|
351
383
|
}
|
|
352
384
|
function clearFileView() {
|
|
353
385
|
document.getElementById('fileTitle').textContent = '点击任务后选择文件';
|
|
354
386
|
document.getElementById('fileContent').textContent = '';
|
|
355
387
|
hideExecutionSummary();
|
|
388
|
+
hideTmuxPanel();
|
|
356
389
|
updateCopyLastMessageButton();
|
|
357
390
|
}
|
|
358
391
|
function updateCopyLastMessageButton() {
|
|
@@ -378,6 +411,50 @@ function hideExecutionSummary() {
|
|
|
378
411
|
el.classList.add('hidden');
|
|
379
412
|
el.innerHTML = '';
|
|
380
413
|
}
|
|
414
|
+
function hideTmuxPanel() {
|
|
415
|
+
const el = document.getElementById('tmuxPanel');
|
|
416
|
+
el.classList.add('hidden');
|
|
417
|
+
el.innerHTML = '';
|
|
418
|
+
}
|
|
419
|
+
function renderTmuxPanel() {
|
|
420
|
+
const el = document.getElementById('tmuxPanel');
|
|
421
|
+
if (!el) return;
|
|
422
|
+
if (!selectedTask || !currentState) { hideTmuxPanel(); return; }
|
|
423
|
+
if (!isTmuxMode()) {
|
|
424
|
+
el.classList.remove('hidden');
|
|
425
|
+
el.innerHTML = '<div class="tmux-box-title">终端模式</div><span class="muted">当前 runner 为 headless,无需终端附加操作。</span>';
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const tmux = taskById(selectedTask)?.tmux;
|
|
429
|
+
if (!tmux) {
|
|
430
|
+
el.classList.remove('hidden');
|
|
431
|
+
el.innerHTML = '<div class="tmux-box-title">tmux 终端</div><span class="muted">该任务尚未生成 tmux window。</span>';
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
el.classList.remove('hidden');
|
|
435
|
+
el.innerHTML = `
|
|
436
|
+
<div class="tmux-box-title">tmux 终端</div>
|
|
437
|
+
<span class="tmux-inline">session:${esc(tmux.sessionName || '-')}</span>
|
|
438
|
+
<span class="tmux-inline">window:${esc(tmux.windowName || '-')}</span>
|
|
439
|
+
<span class="tmux-inline">target:${esc(tmux.target || '-')}</span>
|
|
440
|
+
<div class="tmux-actions">
|
|
441
|
+
${tmux.attachCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'attach')">复制 attach</button>` : ''}
|
|
442
|
+
${tmux.selectWindowCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'select')">复制 select-window</button>` : ''}
|
|
443
|
+
</div>`;
|
|
444
|
+
}
|
|
445
|
+
async function copyTmuxCommand(event, kind) {
|
|
446
|
+
event.stopPropagation();
|
|
447
|
+
const tmux = taskById(selectedTask)?.tmux;
|
|
448
|
+
const command = kind === 'attach' ? tmux?.attachCommand : tmux?.selectWindowCommand;
|
|
449
|
+
if (!command) return;
|
|
450
|
+
try {
|
|
451
|
+
await navigator.clipboard.writeText(command);
|
|
452
|
+
event.currentTarget.textContent = '已复制';
|
|
453
|
+
setTimeout(() => { event.currentTarget.textContent = kind === 'attach' ? '复制 attach' : '复制 select-window'; }, 900);
|
|
454
|
+
} catch {
|
|
455
|
+
prompt(kind === 'attach' ? '复制 attachCommand' : '复制 selectWindowCommand', command);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
381
458
|
async function renderExecutionSummary() {
|
|
382
459
|
const el = document.getElementById('executionSummary');
|
|
383
460
|
let raw = '';
|
package/src/orchestrator.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
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';
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
import { defaultRunner } from './runners/index.js';
|
|
11
|
+
|
|
12
|
+
const runner = defaultRunner;
|
|
13
|
+
const ATTENTION_IDLE_MS = 5 * 60 * 1000;
|
|
14
|
+
const ATTENTION_MIN_RUNTIME_MS = 10 * 60 * 1000;
|
|
15
|
+
const ATTENTION_KEYWORDS = [
|
|
16
|
+
'permission',
|
|
17
|
+
'approval',
|
|
18
|
+
'approve',
|
|
19
|
+
'confirm',
|
|
20
|
+
'continue',
|
|
21
|
+
'password',
|
|
22
|
+
'authentication',
|
|
23
|
+
'authenticate'
|
|
24
|
+
];
|
|
13
25
|
|
|
14
26
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
15
27
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
@@ -21,6 +33,7 @@ export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_
|
|
|
21
33
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
22
34
|
const state = {
|
|
23
35
|
runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3,
|
|
36
|
+
runner: RUNNER,
|
|
24
37
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
25
38
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
26
39
|
};
|
|
@@ -132,24 +145,6 @@ Plan: ${path.join(pathForRun(state.runId), 'plan.json')}
|
|
|
132
145
|
`;
|
|
133
146
|
}
|
|
134
147
|
|
|
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
148
|
export async function startPlanner(runId) {
|
|
154
149
|
const state = await loadRun(runId);
|
|
155
150
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
@@ -168,11 +163,11 @@ export async function startPlanner(runId) {
|
|
|
168
163
|
await fsp.rm(planPath(runDir), { force: true });
|
|
169
164
|
const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
|
|
170
165
|
const prompt = defaultPlannerPrompt(state, taskText);
|
|
171
|
-
const child =
|
|
166
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
172
167
|
state.status = 'planning';
|
|
173
168
|
state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
|
|
174
169
|
await saveRun(state);
|
|
175
|
-
child.
|
|
170
|
+
child.onExit(async code => {
|
|
176
171
|
const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
|
|
177
172
|
s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
|
|
178
173
|
const planResult = await materializePlan(s);
|
|
@@ -304,7 +299,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
|
|
|
304
299
|
|
|
305
300
|
${task.prompt}
|
|
306
301
|
`;
|
|
307
|
-
const child =
|
|
302
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, prompt: fullPrompt, sandbox: task.sandbox || 'workspace-write', cwd: state.repo, outDir });
|
|
308
303
|
Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
|
|
309
304
|
}
|
|
310
305
|
|
|
@@ -312,12 +307,7 @@ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
|
|
|
312
307
|
const state = await loadRun(runId);
|
|
313
308
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
314
309
|
const stoppedAt = nowIso();
|
|
315
|
-
|
|
316
|
-
if (key.startsWith(`${runId}:`)) {
|
|
317
|
-
try { child.kill('TERM'); } catch {}
|
|
318
|
-
runningChildren.delete(key);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
310
|
+
await runner.stopRun(runId);
|
|
321
311
|
for (const roleState of [state.planner, state.judge]) {
|
|
322
312
|
if (roleState?.status === 'running') Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
323
313
|
}
|
|
@@ -400,11 +390,11 @@ export async function startJudge(runId) {
|
|
|
400
390
|
const judgeInput = await buildJudgeInput(state);
|
|
401
391
|
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
402
392
|
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
403
|
-
const child =
|
|
393
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
404
394
|
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
|
|
405
395
|
state.status = 'judging';
|
|
406
396
|
await saveRun(state);
|
|
407
|
-
child.
|
|
397
|
+
child.onExit(async code => {
|
|
408
398
|
const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
|
|
409
399
|
s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
|
|
410
400
|
const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
|
|
@@ -423,9 +413,12 @@ export async function refreshRun(runId, appClient = null) {
|
|
|
423
413
|
async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
|
|
424
414
|
const state = await loadRun(runId);
|
|
425
415
|
if (!state) return null;
|
|
416
|
+
state.runner = state.runner || RUNNER;
|
|
426
417
|
await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
|
|
418
|
+
await recoverCompletedPlanner(state);
|
|
427
419
|
for (const task of state.tasks || []) await refreshTask(state, task);
|
|
428
420
|
await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
|
|
421
|
+
await recoverCompletedJudge(state);
|
|
429
422
|
recomputeRunStatus(state);
|
|
430
423
|
await scheduleMoreWorkers(state);
|
|
431
424
|
recomputeRunStatus(state);
|
|
@@ -434,19 +427,43 @@ async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}
|
|
|
434
427
|
return state;
|
|
435
428
|
}
|
|
436
429
|
|
|
430
|
+
async function recoverCompletedPlanner(state) {
|
|
431
|
+
if (state.planner?.status !== 'completed' || state.tasks?.length || state.batches?.length) return;
|
|
432
|
+
const planResult = await materializePlan(state);
|
|
433
|
+
if (planResult.ok) state.status = 'planned';
|
|
434
|
+
else if (planResult.empty) state.status = 'plan_empty';
|
|
435
|
+
else state.status = 'plan_failed';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function recoverCompletedJudge(state) {
|
|
439
|
+
if (!['completed', 'failed'].includes(state.judge?.status)) return;
|
|
440
|
+
if (state.judge.status === 'completed' && !state.judge.verdict) {
|
|
441
|
+
const outDir = roleDir(pathForRun(state.runId), 'judge');
|
|
442
|
+
const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
|
|
443
|
+
const verdict = extractFirstJsonObject(text);
|
|
444
|
+
if (verdict) {
|
|
445
|
+
state.judge.verdict = verdict;
|
|
446
|
+
await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
state.status = state.judge.status === 'completed' ? 'judged' : 'judge_failed';
|
|
450
|
+
}
|
|
451
|
+
|
|
437
452
|
async function refreshRole(state, roleState, dir) {
|
|
438
453
|
if (!roleState) return;
|
|
439
454
|
const exitPath = path.join(dir, 'exit_code');
|
|
440
455
|
const exit = await readTextMaybe(exitPath, 1000);
|
|
441
456
|
const exitInfo = await fileInfo(exitPath);
|
|
442
|
-
const key =
|
|
457
|
+
const key = roleState === state.judge ? 'judge' : 'planner';
|
|
443
458
|
if (exit !== '') {
|
|
444
459
|
roleState.exitCode = Number(exit.trim());
|
|
445
460
|
if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
|
|
446
461
|
if (roleState.status === 'running') roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
|
|
447
462
|
}
|
|
448
|
-
else if (roleState.status === 'running' && !
|
|
463
|
+
else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
|
|
449
464
|
roleState.files = await standardFiles(dir);
|
|
465
|
+
await attachTmuxMetadata(roleState, dir);
|
|
466
|
+
roleState.attentionHint = await buildAttentionHint({ state, target: roleState, dir });
|
|
450
467
|
}
|
|
451
468
|
|
|
452
469
|
async function refreshTask(state, task) {
|
|
@@ -454,13 +471,14 @@ async function refreshTask(state, task) {
|
|
|
454
471
|
const exitPath = path.join(dir, 'exit_code');
|
|
455
472
|
const exit = await readTextMaybe(exitPath, 1000);
|
|
456
473
|
const exitInfo = await fileInfo(exitPath);
|
|
457
|
-
const key = `${state.runId}:${task.id}`;
|
|
458
474
|
if (exit !== '') {
|
|
459
475
|
task.exitCode = Number(exit.trim());
|
|
460
476
|
if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
|
|
461
477
|
if (task.status === 'running') task.status = task.exitCode === 0 ? 'completed' : 'failed';
|
|
462
|
-
} else if (task.status === 'running' && !
|
|
478
|
+
} else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
|
|
463
479
|
task.files = await standardFiles(dir);
|
|
480
|
+
await attachTmuxMetadata(task, dir);
|
|
481
|
+
task.attentionHint = await buildAttentionHint({ state, target: task, dir });
|
|
464
482
|
task.artifacts = [];
|
|
465
483
|
for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
|
|
466
484
|
const batch = (state.batches || []).find(b => b.id === task.batchId);
|
|
@@ -470,13 +488,77 @@ async function refreshTask(state, task) {
|
|
|
470
488
|
}
|
|
471
489
|
}
|
|
472
490
|
|
|
491
|
+
async function attachTmuxMetadata(target, dir) {
|
|
492
|
+
const raw = await readJson(path.join(dir, 'tmux.json'), null);
|
|
493
|
+
if (!raw || raw.runner !== 'tmux') {
|
|
494
|
+
delete target.tmux;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const selectWindowCommand = raw.selectWindowCommand || raw.selectCommand || '';
|
|
498
|
+
target.tmux = {
|
|
499
|
+
runner: 'tmux',
|
|
500
|
+
sessionName: raw.sessionName || '',
|
|
501
|
+
windowName: raw.windowName || '',
|
|
502
|
+
target: raw.target || '',
|
|
503
|
+
attachCommand: raw.attachCommand || '',
|
|
504
|
+
selectWindowCommand,
|
|
505
|
+
runScript: raw.runScript || '',
|
|
506
|
+
startedAt: raw.startedAt || ''
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function buildAttentionHint({ state, target, dir }) {
|
|
511
|
+
if (!['running', 'unknown'].includes(target?.status) || state?.status === 'stopped') return null;
|
|
512
|
+
if ((state?.runner || RUNNER) !== 'tmux' && !target.tmux) return null;
|
|
513
|
+
const reasons = [];
|
|
514
|
+
const textTail = [
|
|
515
|
+
await readTextMaybe(path.join(dir, 'stderr.log'), 20000),
|
|
516
|
+
await readTextMaybe(path.join(dir, 'events.jsonl'), 20000)
|
|
517
|
+
].join('\n');
|
|
518
|
+
const keyword = findAttentionKeyword(textTail);
|
|
519
|
+
if (keyword) reasons.push(`log tail contains "${keyword}"`);
|
|
520
|
+
const idle = taskIdleSnapshot(target);
|
|
521
|
+
if (idle.isLongIdle || (target.status === 'unknown' && idle.isIdle)) reasons.push(`no recent log updates for ${Math.round(idle.idleMs / 1000)}s`);
|
|
522
|
+
if (!reasons.length) return null;
|
|
523
|
+
return {
|
|
524
|
+
message: 'This task may need manual intervention; attach to tmux to inspect.',
|
|
525
|
+
reasons,
|
|
526
|
+
attachCommand: target.tmux?.attachCommand || '',
|
|
527
|
+
detectedAt: nowIso()
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function findAttentionKeyword(text) {
|
|
532
|
+
const lower = String(text || '').toLowerCase();
|
|
533
|
+
return ATTENTION_KEYWORDS.find(keyword => lower.includes(keyword)) || null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function taskIdleSnapshot(target) {
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
const startedMs = Date.parse(target?.startedAt || '');
|
|
539
|
+
const runtimeMs = Number.isFinite(startedMs) ? now - startedMs : 0;
|
|
540
|
+
const recentMs = [target?.files?.events, target?.files?.stderr, target?.files?.lastMessage]
|
|
541
|
+
.filter(info => info?.exists && Number.isFinite(info.mtimeMs))
|
|
542
|
+
.map(info => info.mtimeMs)
|
|
543
|
+
.sort((a, b) => b - a)[0];
|
|
544
|
+
const idleMs = Number.isFinite(recentMs) ? now - recentMs : runtimeMs;
|
|
545
|
+
return {
|
|
546
|
+
idleMs,
|
|
547
|
+
runtimeMs,
|
|
548
|
+
isIdle: idleMs >= ATTENTION_IDLE_MS,
|
|
549
|
+
isLongIdle: runtimeMs >= ATTENTION_MIN_RUNTIME_MS && idleMs >= ATTENTION_IDLE_MS
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
473
553
|
async function standardFiles(dir) {
|
|
474
554
|
return {
|
|
475
555
|
prompt: await fileInfo(path.join(dir, 'prompt.md')),
|
|
476
556
|
events: await fileInfo(path.join(dir, 'events.jsonl')),
|
|
477
557
|
stderr: await fileInfo(path.join(dir, 'stderr.log')),
|
|
478
558
|
lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
|
|
479
|
-
exitCode: await fileInfo(path.join(dir, 'exit_code'))
|
|
559
|
+
exitCode: await fileInfo(path.join(dir, 'exit_code')),
|
|
560
|
+
runScript: await fileInfo(path.join(dir, 'run.sh')),
|
|
561
|
+
tmux: await fileInfo(path.join(dir, 'tmux.json'))
|
|
480
562
|
};
|
|
481
563
|
}
|
|
482
564
|
|
|
@@ -567,6 +649,7 @@ async function buildJudgeInput(state) {
|
|
|
567
649
|
resultJson: await readJson(path.join(dir, 'result.json'), null),
|
|
568
650
|
evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
|
|
569
651
|
manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
|
|
652
|
+
tmux: task.tmux || null,
|
|
570
653
|
stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
|
|
571
654
|
});
|
|
572
655
|
}
|
|
@@ -579,6 +662,7 @@ async function buildJudgeInput(state) {
|
|
|
579
662
|
label: state.label,
|
|
580
663
|
repo: state.repo,
|
|
581
664
|
status: state.status,
|
|
665
|
+
runner: state.runner || RUNNER,
|
|
582
666
|
createdAt: state.createdAt,
|
|
583
667
|
updatedAt: state.updatedAt,
|
|
584
668
|
maxParallel: state.maxParallel
|
|
@@ -597,6 +681,7 @@ async function buildJudgeInput(state) {
|
|
|
597
681
|
exitCode: state.planner?.exitCode ?? null,
|
|
598
682
|
planParseError: state.planner?.planParseError,
|
|
599
683
|
planEmpty: !!state.planner?.planEmpty,
|
|
684
|
+
tmux: state.planner?.tmux || null,
|
|
600
685
|
lastMessage: await readTextMaybe(path.join(roleDir(runDir, 'planner'), 'last_message.md'), 200000)
|
|
601
686
|
},
|
|
602
687
|
tasks
|
|
@@ -629,7 +714,7 @@ async function enrichFromAppServer(state, appClient) {
|
|
|
629
714
|
|
|
630
715
|
function summaryOfRun(s) {
|
|
631
716
|
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 })) };
|
|
717
|
+
return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, 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
718
|
}
|
|
634
719
|
|
|
635
720
|
function formatCodexEventsJsonl(text) {
|
|
@@ -718,7 +803,7 @@ export async function readRunTaskText(runId) {
|
|
|
718
803
|
|
|
719
804
|
export async function readRunFile(runId, taskId, name) {
|
|
720
805
|
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']);
|
|
806
|
+
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
807
|
if (!allowed.has(name)) throw new Error('file not allowed');
|
|
723
808
|
let dir;
|
|
724
809
|
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();
|