input-kanban 0.0.6 → 0.0.8
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/PROJECT_GUIDE.md +63 -3
- package/README.en.md +41 -1
- package/README.md +41 -1
- package/RELEASE_NOTES.md +86 -0
- package/bin/input-kanban-timestamp-events.js +34 -0
- package/bin/input-kanban.js +532 -22
- package/package.json +3 -3
- package/public/assets/input-kanban-mask-icon.svg +3 -0
- package/public/index.html +301 -59
- package/src/orchestrator.js +186 -20
- package/src/runners/headlessRunner.js +32 -1
- package/src/runners/tmuxRunner.js +6 -3
- package/src/server.js +5 -1
package/src/orchestrator.js
CHANGED
|
@@ -15,6 +15,8 @@ import { defaultRunner } from './runners/index.js';
|
|
|
15
15
|
const execFileAsync = promisify(execFile);
|
|
16
16
|
const runner = defaultRunner;
|
|
17
17
|
const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
18
|
+
const MISSING_RUNNER_GRACE_MS = 10000;
|
|
19
|
+
const MAX_DERIVED_LABEL_DISPLAY_WIDTH = 40;
|
|
18
20
|
|
|
19
21
|
function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
20
22
|
const sandbox = String(value || '').trim();
|
|
@@ -25,12 +27,81 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
|
25
27
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
26
28
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
27
29
|
|
|
30
|
+
function shouldMarkRunnerUnknown(target) {
|
|
31
|
+
const missingSince = Date.parse(target.missingRunnerAt || '');
|
|
32
|
+
if (!Number.isFinite(missingSince)) {
|
|
33
|
+
target.missingRunnerAt = nowIso();
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return Date.now() - missingSince >= MISSING_RUNNER_GRACE_MS;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clearMissingRunner(target) {
|
|
40
|
+
delete target.missingRunnerAt;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPidAlive(pid) {
|
|
44
|
+
const value = Number(pid);
|
|
45
|
+
if (!Number.isFinite(value) || value <= 0) return false;
|
|
46
|
+
try {
|
|
47
|
+
process.kill(value, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return error?.code === 'EPERM';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasLiveRunnerProcess(state, id, target) {
|
|
55
|
+
return runner.hasRunning(state.runId, id) || isPidAlive(target?.pid);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stopPid(pid, signal = 'SIGTERM') {
|
|
59
|
+
const value = Number(pid);
|
|
60
|
+
if (!Number.isFinite(value) || value <= 0) return false;
|
|
61
|
+
try {
|
|
62
|
+
process.kill(value, signal);
|
|
63
|
+
if (signal !== 'SIGKILL') setTimeout(() => { if (isPidAlive(value)) stopPid(value, 'SIGKILL'); }, 1000).unref?.();
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return error?.code === 'ESRCH';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
28
70
|
function userInputError(message) {
|
|
29
71
|
const error = new Error(message);
|
|
30
72
|
error.statusCode = 400;
|
|
31
73
|
return error;
|
|
32
74
|
}
|
|
33
75
|
|
|
76
|
+
function charDisplayWidth(char) {
|
|
77
|
+
return char.codePointAt(0) > 0x2e80 ? 2 : 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function truncateDisplayWidth(text, maxWidth) {
|
|
81
|
+
let width = 0;
|
|
82
|
+
let result = '';
|
|
83
|
+
for (const char of text) {
|
|
84
|
+
const nextWidth = width + charDisplayWidth(char);
|
|
85
|
+
if (nextWidth > maxWidth) return `${result.trimEnd()}…`;
|
|
86
|
+
result += char;
|
|
87
|
+
width = nextWidth;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function deriveRunLabel(label, taskText) {
|
|
93
|
+
const explicit = String(label || '').trim();
|
|
94
|
+
if (explicit) return explicit;
|
|
95
|
+
const firstLine = String(taskText || '').split(/\r?\n/).map(line => line.trim()).find(Boolean) || 'task';
|
|
96
|
+
const cleaned = firstLine
|
|
97
|
+
.replace(/^#{1,6}\s+/, '')
|
|
98
|
+
.replace(/^[-*+]\s+/, '')
|
|
99
|
+
.replace(/^\d+[.)、]\s*/, '')
|
|
100
|
+
.replace(/\s+/g, ' ')
|
|
101
|
+
.trim();
|
|
102
|
+
return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
|
|
103
|
+
}
|
|
104
|
+
|
|
34
105
|
async function assertGitWorkTree(repo) {
|
|
35
106
|
const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
|
|
36
107
|
let stat;
|
|
@@ -44,14 +115,15 @@ async function assertGitWorkTree(repo) {
|
|
|
44
115
|
throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
|
|
45
116
|
}
|
|
46
117
|
|
|
47
|
-
export async function createRun({ label = '
|
|
118
|
+
export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
48
119
|
const resolvedRepo = await assertGitWorkTree(repo);
|
|
49
|
-
const
|
|
120
|
+
const runLabel = deriveRunLabel(label, taskText);
|
|
121
|
+
const runId = makeRunId(runLabel);
|
|
50
122
|
const runDir = pathForRun(runId);
|
|
51
123
|
await ensureDir(runDir);
|
|
52
124
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
53
125
|
const state = {
|
|
54
|
-
runId, label, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
|
|
126
|
+
runId, label: runLabel, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
|
|
55
127
|
runner: RUNNER,
|
|
56
128
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
57
129
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
@@ -200,7 +272,17 @@ export async function startPlanner(runId) {
|
|
|
200
272
|
return state;
|
|
201
273
|
}
|
|
202
274
|
|
|
203
|
-
function
|
|
275
|
+
function normalizeExpectedArtifacts(value, runId, taskId) {
|
|
276
|
+
const artifacts = Array.isArray(value) ? value : [];
|
|
277
|
+
return artifacts.map(item => String(item || '').trim()).filter(Boolean).map(item => {
|
|
278
|
+
if (path.isAbsolute(item)) return item;
|
|
279
|
+
const normalized = item.replace(/^\.\//, '');
|
|
280
|
+
if (normalized.includes(runId)) return normalized;
|
|
281
|
+
return path.posix.join('.orchestrator', runId, taskId, normalized);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write', runId = '') {
|
|
204
286
|
const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
|
|
205
287
|
return {
|
|
206
288
|
id,
|
|
@@ -208,7 +290,7 @@ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
|
|
|
208
290
|
name: t.name || t.id || `Task ${i + 1}`,
|
|
209
291
|
prompt: t.prompt || t.instructions || '',
|
|
210
292
|
sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
|
|
211
|
-
expectedArtifacts:
|
|
293
|
+
expectedArtifacts: normalizeExpectedArtifacts(t.expectedArtifacts, runId, id),
|
|
212
294
|
status: 'pending'
|
|
213
295
|
};
|
|
214
296
|
}
|
|
@@ -240,7 +322,7 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
240
322
|
}];
|
|
241
323
|
}
|
|
242
324
|
|
|
243
|
-
function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
|
|
325
|
+
function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write', runId = '') {
|
|
244
326
|
if (Array.isArray(plan.batches)) {
|
|
245
327
|
const batches = plan.batches.map((b, bi) => {
|
|
246
328
|
const batch = {
|
|
@@ -250,14 +332,14 @@ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-wri
|
|
|
250
332
|
status: 'pending',
|
|
251
333
|
tasks: []
|
|
252
334
|
};
|
|
253
|
-
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox));
|
|
335
|
+
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox, runId));
|
|
254
336
|
return batch;
|
|
255
337
|
}).filter(b => b.tasks.length);
|
|
256
338
|
return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
|
|
257
339
|
}
|
|
258
340
|
if (Array.isArray(plan.tasks)) {
|
|
259
341
|
const batch = { id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(defaultMaxParallel) || 1), status: 'pending', tasks: [] };
|
|
260
|
-
batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox));
|
|
342
|
+
batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox, runId));
|
|
261
343
|
return { ...plan, batches: [batch], tasks: batch.tasks };
|
|
262
344
|
}
|
|
263
345
|
return null;
|
|
@@ -273,7 +355,7 @@ async function materializePlan(state) {
|
|
|
273
355
|
state.tasks = [];
|
|
274
356
|
return { ok: false, empty: false, error: state.planner.planParseError };
|
|
275
357
|
}
|
|
276
|
-
const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
|
|
358
|
+
const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write', state.runId);
|
|
277
359
|
if (!normalized || !Array.isArray(normalized.tasks)) {
|
|
278
360
|
state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
|
|
279
361
|
state.batches = [];
|
|
@@ -310,6 +392,28 @@ export async function dispatchRun(runId) {
|
|
|
310
392
|
return state;
|
|
311
393
|
}
|
|
312
394
|
|
|
395
|
+
function artifactPathForState(state, rel) {
|
|
396
|
+
return path.isAbsolute(rel) ? rel : path.join(state.repo, rel);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function workerArtifactInstructions(state, task) {
|
|
400
|
+
const artifacts = task.expectedArtifacts || [];
|
|
401
|
+
if (!artifacts.length) return '';
|
|
402
|
+
const lines = artifacts.map(rel => `- ${artifactPathForState(state, rel)}`);
|
|
403
|
+
return `\n\nRequired output artifacts:\nWrite the following artifact path(s) exactly. Create parent directories if needed.\n${lines.join('\n')}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function upstreamArtifactInstructions(state, task) {
|
|
407
|
+
const currentBatchIndex = (state.batches || []).findIndex(batch => batch.id === task.batchId);
|
|
408
|
+
if (currentBatchIndex <= 0) return '';
|
|
409
|
+
const previousTaskIds = new Set((state.batches || []).slice(0, currentBatchIndex).flatMap(batch => (batch.tasks || []).map(item => item.id)));
|
|
410
|
+
const lines = (state.tasks || [])
|
|
411
|
+
.filter(item => previousTaskIds.has(item.id))
|
|
412
|
+
.flatMap(item => (item.expectedArtifacts || []).map(rel => `- ${item.id}: ${artifactPathForState(state, rel)}`));
|
|
413
|
+
if (!lines.length) return '';
|
|
414
|
+
return `\n\nAvailable upstream artifacts from completed earlier batches:\n${lines.join('\n')}\nUse only artifacts from this run id: ${state.runId}.`;
|
|
415
|
+
}
|
|
416
|
+
|
|
313
417
|
async function startWorkerInState(state, task) {
|
|
314
418
|
const runDir = pathForRun(state.runId);
|
|
315
419
|
const outDir = roleDir(runDir, 'worker', task.id);
|
|
@@ -317,7 +421,7 @@ async function startWorkerInState(state, task) {
|
|
|
317
421
|
const fullPrompt = `${marker(state.runId, task.id, 'worker')}
|
|
318
422
|
ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
|
|
319
423
|
|
|
320
|
-
${task.prompt}
|
|
424
|
+
${task.prompt}${workerArtifactInstructions(state, task)}${upstreamArtifactInstructions(state, task)}
|
|
321
425
|
`;
|
|
322
426
|
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 });
|
|
323
427
|
Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
|
|
@@ -328,11 +432,28 @@ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
|
|
|
328
432
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
329
433
|
const stoppedAt = nowIso();
|
|
330
434
|
await runner.stopRun(runId);
|
|
435
|
+
const stoppedPids = new Set();
|
|
436
|
+
const stopTargetPid = target => {
|
|
437
|
+
const pid = Number(target?.pid);
|
|
438
|
+
if (Number.isFinite(pid) && pid > 0 && !stoppedPids.has(pid)) {
|
|
439
|
+
stoppedPids.add(pid);
|
|
440
|
+
stopPid(pid);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
331
443
|
for (const roleState of [state.planner, state.judge]) {
|
|
332
|
-
if (roleState?.status === 'running')
|
|
444
|
+
if (roleState?.status === 'running') {
|
|
445
|
+
stopTargetPid(roleState);
|
|
446
|
+
Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
447
|
+
}
|
|
333
448
|
}
|
|
334
449
|
for (const task of state.tasks || []) {
|
|
335
|
-
if (task.status === 'running')
|
|
450
|
+
if (task.status === 'running') {
|
|
451
|
+
stopTargetPid(task);
|
|
452
|
+
Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const batch of state.batches || []) {
|
|
456
|
+
for (const task of batch.tasks || []) if (task.status === 'running') stopTargetPid(task);
|
|
336
457
|
}
|
|
337
458
|
for (const batch of state.batches || []) {
|
|
338
459
|
if ((batch.tasks || []).some(t => t.status === 'stopped')) batch.status = 'stopped';
|
|
@@ -356,7 +477,18 @@ export async function archiveRun(runId, { reason = 'archived by user' } = {}) {
|
|
|
356
477
|
return state;
|
|
357
478
|
}
|
|
358
479
|
|
|
359
|
-
export async function
|
|
480
|
+
export async function renameRun(runId, { label = '' } = {}) {
|
|
481
|
+
const state = await loadRun(runId);
|
|
482
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
483
|
+
const nextLabel = String(label || '').trim();
|
|
484
|
+
if (!nextLabel) throw userInputError('run label cannot be empty');
|
|
485
|
+
state.label = nextLabel;
|
|
486
|
+
state.renamedAt = nowIso();
|
|
487
|
+
await saveRun(state);
|
|
488
|
+
return state;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user', resultText = '' } = {}) {
|
|
360
492
|
const state = await loadRun(runId);
|
|
361
493
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
362
494
|
const task = (state.tasks || []).find(t => t.id === taskId);
|
|
@@ -366,6 +498,8 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
|
|
|
366
498
|
const outDir = roleDir(runDir, 'worker', task.id);
|
|
367
499
|
await ensureDir(outDir);
|
|
368
500
|
if (task.status !== 'completed') {
|
|
501
|
+
const manualResult = String(resultText || '').trim();
|
|
502
|
+
if (manualResult) await fsp.writeFile(path.join(outDir, 'manual_result.md'), manualResult);
|
|
369
503
|
const override = {
|
|
370
504
|
type: 'manual_task_completed',
|
|
371
505
|
runId,
|
|
@@ -375,6 +509,9 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
|
|
|
375
509
|
previousStatus: task.status,
|
|
376
510
|
previousExitCode: task.exitCode ?? null,
|
|
377
511
|
reason,
|
|
512
|
+
hasManualResult: !!manualResult,
|
|
513
|
+
manualResultFile: manualResult ? 'manual_result.md' : null,
|
|
514
|
+
manualResultPreview: manualResult ? manualResult.slice(0, 500) : '',
|
|
378
515
|
markedAt: nowIso()
|
|
379
516
|
};
|
|
380
517
|
await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
|
|
@@ -479,9 +616,16 @@ async function refreshRole(state, roleState, dir) {
|
|
|
479
616
|
if (exit !== '') {
|
|
480
617
|
roleState.exitCode = Number(exit.trim());
|
|
481
618
|
if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
|
|
482
|
-
|
|
619
|
+
clearMissingRunner(roleState);
|
|
620
|
+
if (['running', 'unknown'].includes(roleState.status)) roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
|
|
621
|
+
}
|
|
622
|
+
else if (['running', 'unknown'].includes(roleState.status) && hasLiveRunnerProcess(state, key, roleState)) {
|
|
623
|
+
roleState.status = 'running';
|
|
624
|
+
clearMissingRunner(roleState);
|
|
483
625
|
}
|
|
484
|
-
else if (roleState.status === 'running' && !
|
|
626
|
+
else if (roleState.status === 'running' && !hasLiveRunnerProcess(state, key, roleState)) {
|
|
627
|
+
if (shouldMarkRunnerUnknown(roleState)) roleState.status = 'unknown';
|
|
628
|
+
} else if (roleState.status === 'running') clearMissingRunner(roleState);
|
|
485
629
|
roleState.files = await standardFiles(dir);
|
|
486
630
|
await attachTmuxMetadata(roleState, dir);
|
|
487
631
|
}
|
|
@@ -494,8 +638,14 @@ async function refreshTask(state, task) {
|
|
|
494
638
|
if (exit !== '') {
|
|
495
639
|
task.exitCode = Number(exit.trim());
|
|
496
640
|
if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
|
|
497
|
-
|
|
498
|
-
|
|
641
|
+
clearMissingRunner(task);
|
|
642
|
+
if (['pending', 'running', 'unknown'].includes(task.status)) task.status = task.exitCode === 0 ? 'completed' : 'failed';
|
|
643
|
+
} else if (['running', 'unknown'].includes(task.status) && hasLiveRunnerProcess(state, task.id, task)) {
|
|
644
|
+
task.status = 'running';
|
|
645
|
+
clearMissingRunner(task);
|
|
646
|
+
} else if (task.status === 'running' && !hasLiveRunnerProcess(state, task.id, task)) {
|
|
647
|
+
if (shouldMarkRunnerUnknown(task)) task.status = 'unknown';
|
|
648
|
+
} else if (task.status === 'running') clearMissingRunner(task);
|
|
499
649
|
task.files = await standardFiles(dir);
|
|
500
650
|
await attachTmuxMetadata(task, dir);
|
|
501
651
|
delete task.attentionHint;
|
|
@@ -579,11 +729,13 @@ async function standardFiles(dir) {
|
|
|
579
729
|
return {
|
|
580
730
|
prompt: await fileInfo(path.join(dir, 'prompt.md')),
|
|
581
731
|
events: await fileInfo(path.join(dir, 'events.jsonl')),
|
|
732
|
+
timedEvents: await fileInfo(path.join(dir, 'events_timed.jsonl')),
|
|
582
733
|
stderr: await fileInfo(path.join(dir, 'stderr.log')),
|
|
583
734
|
lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
|
|
584
735
|
exitCode: await fileInfo(path.join(dir, 'exit_code')),
|
|
585
736
|
runScript: await fileInfo(path.join(dir, 'run.sh')),
|
|
586
|
-
tmux: await fileInfo(path.join(dir, 'tmux.json'))
|
|
737
|
+
tmux: await fileInfo(path.join(dir, 'tmux.json')),
|
|
738
|
+
manualResult: await fileInfo(path.join(dir, 'manual_result.md'))
|
|
587
739
|
};
|
|
588
740
|
}
|
|
589
741
|
|
|
@@ -674,6 +826,7 @@ async function buildJudgeInput(state) {
|
|
|
674
826
|
resultJson: await readJson(path.join(dir, 'result.json'), null),
|
|
675
827
|
evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
|
|
676
828
|
manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
|
|
829
|
+
manualResult: await readTextMaybe(path.join(dir, 'manual_result.md'), 200000),
|
|
677
830
|
tmux: task.tmux || null,
|
|
678
831
|
stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
|
|
679
832
|
});
|
|
@@ -738,9 +891,22 @@ async function enrichFromAppServer(state, appClient) {
|
|
|
738
891
|
}
|
|
739
892
|
}
|
|
740
893
|
|
|
894
|
+
function runDurationEndOfState(s) {
|
|
895
|
+
const terminalStatuses = new Set(['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped']);
|
|
896
|
+
if (!terminalStatuses.has(s.status)) return null;
|
|
897
|
+
const times = [
|
|
898
|
+
s.stoppedAt,
|
|
899
|
+
s.stopInfo?.stoppedAt,
|
|
900
|
+
s.judge?.endedAt,
|
|
901
|
+
s.planner?.endedAt,
|
|
902
|
+
...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
|
|
903
|
+
].map(value => Date.parse(value || '')).filter(Number.isFinite);
|
|
904
|
+
return times.length ? new Date(Math.max(...times)).toISOString() : s.updatedAt;
|
|
905
|
+
}
|
|
906
|
+
|
|
741
907
|
function summaryOfRun(s) {
|
|
742
908
|
const tasks = s.tasks || [];
|
|
743
|
-
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 })) };
|
|
909
|
+
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, durationEnd: runDurationEndOfState(s), 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 })) };
|
|
744
910
|
}
|
|
745
911
|
|
|
746
912
|
export async function readRunTaskText(runId) {
|
|
@@ -749,7 +915,7 @@ export async function readRunTaskText(runId) {
|
|
|
749
915
|
|
|
750
916
|
export async function readRunFile(runId, taskId, name) {
|
|
751
917
|
const runDir = pathForRun(runId);
|
|
752
|
-
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']);
|
|
918
|
+
const allowed = new Set(['prompt.md','events.jsonl','events_timed.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json','manual_result.md','run.sh','tmux.json']);
|
|
753
919
|
if (!allowed.has(name)) throw new Error('file not allowed');
|
|
754
920
|
let dir;
|
|
755
921
|
if (taskId === 'planner') dir = roleDir(runDir, 'planner');
|
|
@@ -7,17 +7,48 @@ function processKey(runId, taskId) {
|
|
|
7
7
|
return `${runId}:${taskId}`;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function captureEventsWithTimestamps(stream, eventsFile, timedEventsFile) {
|
|
11
|
+
const events = fs.createWriteStream(eventsFile, { flags: 'a' });
|
|
12
|
+
const timedEvents = fs.createWriteStream(timedEventsFile, { flags: 'a' });
|
|
13
|
+
let buffer = '';
|
|
14
|
+
const writeLine = line => {
|
|
15
|
+
events.write(`${line}\n`);
|
|
16
|
+
const receivedAt = new Date().toISOString();
|
|
17
|
+
try {
|
|
18
|
+
timedEvents.write(`${JSON.stringify({ receivedAt, event: JSON.parse(line) })}\n`);
|
|
19
|
+
} catch {
|
|
20
|
+
timedEvents.write(`${JSON.stringify({ receivedAt, rawLine: line })}\n`);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
stream.setEncoding('utf8');
|
|
24
|
+
stream.on('data', chunk => {
|
|
25
|
+
buffer += chunk;
|
|
26
|
+
let newlineIndex;
|
|
27
|
+
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
|
|
28
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '');
|
|
29
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
30
|
+
if (line) writeLine(line);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
stream.on('end', () => {
|
|
34
|
+
if (buffer) writeLine(buffer.replace(/\r$/, ''));
|
|
35
|
+
events.end();
|
|
36
|
+
timedEvents.end();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
export function createHeadlessRunner({ codexBin = CODEX_BIN } = {}) {
|
|
11
41
|
const runningProcesses = new Map();
|
|
12
42
|
|
|
13
43
|
function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
|
|
14
44
|
const events = path.join(outDir, 'events.jsonl');
|
|
45
|
+
const timedEvents = path.join(outDir, 'events_timed.jsonl');
|
|
15
46
|
const stderr = path.join(outDir, 'stderr.log');
|
|
16
47
|
const last = path.join(outDir, 'last_message.md');
|
|
17
48
|
fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
|
|
18
49
|
const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
|
|
19
50
|
const child = spawn(codexBin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
20
|
-
child.stdout
|
|
51
|
+
captureEventsWithTimestamps(child.stdout, events, timedEvents);
|
|
21
52
|
child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
|
|
22
53
|
const key = processKey(runId, taskId);
|
|
23
54
|
runningProcesses.set(key, child);
|
|
@@ -46,6 +46,7 @@ function shellQuote(value) {
|
|
|
46
46
|
|
|
47
47
|
const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin');
|
|
48
48
|
const FORMATTER_BIN = path.join(BIN_DIR, 'input-kanban-format-events.js');
|
|
49
|
+
const TIMESTAMP_BIN = path.join(BIN_DIR, 'input-kanban-timestamp-events.js');
|
|
49
50
|
const OVERVIEW_BIN = path.join(BIN_DIR, 'input-kanban-tmux-overview.js');
|
|
50
51
|
|
|
51
52
|
function buildOverviewCommand(runStatePath) {
|
|
@@ -54,7 +55,7 @@ function buildOverviewCommand(runStatePath) {
|
|
|
54
55
|
return `while true; do clear; node ${quotedOverviewBin} ${quotedStatePath}; sleep 2; done`;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
|
|
58
|
+
function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, timestampBin = TIMESTAMP_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
|
|
58
59
|
return `#!/usr/bin/env bash
|
|
59
60
|
set -u
|
|
60
61
|
|
|
@@ -67,15 +68,17 @@ TASK_ID=${shellQuote(taskId)}
|
|
|
67
68
|
ROLE=${shellQuote(role)}
|
|
68
69
|
PROMPT_FILE="$OUT_DIR/prompt.md"
|
|
69
70
|
EVENTS="$OUT_DIR/events.jsonl"
|
|
71
|
+
TIMED_EVENTS="$OUT_DIR/events_timed.jsonl"
|
|
70
72
|
STDERR_LOG="$OUT_DIR/stderr.log"
|
|
71
73
|
FORMATTER_BIN=${shellQuote(formatterBin)}
|
|
74
|
+
TIMESTAMP_BIN=${shellQuote(timestampBin)}
|
|
72
75
|
LAST_MESSAGE="$OUT_DIR/last_message.md"
|
|
73
76
|
EXIT_CODE="$OUT_DIR/exit_code"
|
|
74
77
|
|
|
75
78
|
cd "$CWD"
|
|
76
79
|
rm -f "$EXIT_CODE"
|
|
77
|
-
touch "$EVENTS" "$STDERR_LOG"
|
|
78
|
-
"$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(
|
|
80
|
+
touch "$EVENTS" "$TIMED_EVENTS" "$STDERR_LOG"
|
|
81
|
+
"$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(node "$TIMESTAMP_BIN" "$EVENTS" "$TIMED_EVENTS" | node "$FORMATTER_BIN") 2> >(tee -a "$STDERR_LOG" >&2)
|
|
79
82
|
code=$?
|
|
80
83
|
printf '%s' "$code" > "$EXIT_CODE"
|
|
81
84
|
printf '\\nInput Kanban tmux task completed.\\n'
|
package/src/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { CodexAppServerClient } from './appServerClient.js';
|
|
6
6
|
import { APP_ROOT, DEFAULT_REPO, RUNNER, RUNS_DIR } from './utils.js';
|
|
7
|
-
import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun } from './orchestrator.js';
|
|
7
|
+
import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun, renameRun } from './orchestrator.js';
|
|
8
8
|
|
|
9
9
|
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
|
10
10
|
|
|
@@ -66,6 +66,10 @@ async function handleApi(req, res, url, appClient) {
|
|
|
66
66
|
const body = await readBody(req);
|
|
67
67
|
return send(res, 200, await archiveRun(runId, body));
|
|
68
68
|
}
|
|
69
|
+
if (parts.length === 4 && parts[3] === 'label' && req.method === 'PATCH') {
|
|
70
|
+
const body = await readBody(req);
|
|
71
|
+
return send(res, 200, await renameRun(runId, body));
|
|
72
|
+
}
|
|
69
73
|
if (parts.length === 4 && parts[3] === 'task-text' && req.method === 'GET') return send(res, 200, await readRunTaskText(runId), 'text/plain');
|
|
70
74
|
if (parts.length === 6 && parts[3] === 'tasks' && parts[5] === 'file' && req.method === 'GET') {
|
|
71
75
|
const text = await readRunFile(runId, parts[4], url.searchParams.get('name') || 'last_message.md');
|