input-kanban 0.0.8 → 0.0.10
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 -4
- package/LICENSE +21 -0
- package/PROJECT_GUIDE.md +124 -12
- package/README.en.md +27 -17
- package/README.md +27 -17
- package/RELEASE_NOTES.md +38 -1
- package/bin/input-kanban.js +277 -58
- package/package.json +5 -3
- package/public/index.html +101 -20
- package/src/orchestrator.js +523 -201
- package/src/scheduler.js +40 -0
- package/src/server.js +13 -6
- package/src/utils.js +7 -1
package/src/orchestrator.js
CHANGED
|
@@ -4,7 +4,7 @@ import fsp from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import {
|
|
7
|
-
DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
|
|
7
|
+
DEFAULT_WORKSPACE, DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
|
|
8
8
|
writeJsonAtomic, fileInfo, readTextMaybe, extractFirstJsonObject, listRunDirs,
|
|
9
9
|
pathForRun, roleDir, safeIdPart, RUNNER
|
|
10
10
|
} from './utils.js';
|
|
@@ -17,6 +17,10 @@ const runner = defaultRunner;
|
|
|
17
17
|
const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
18
18
|
const MISSING_RUNNER_GRACE_MS = 10000;
|
|
19
19
|
const MAX_DERIVED_LABEL_DISPLAY_WIDTH = 40;
|
|
20
|
+
const RUN_STATE_LOCK_NAME = 'run_state.lock';
|
|
21
|
+
const RUN_STATE_LOCK_STALE_MS = 30000;
|
|
22
|
+
const RUN_STATE_LOCK_TIMEOUT_MS = 30000;
|
|
23
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
20
24
|
|
|
21
25
|
function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
22
26
|
const sandbox = String(value || '').trim();
|
|
@@ -26,6 +30,120 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
|
26
30
|
|
|
27
31
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
28
32
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
33
|
+
function lockPath(runDir) { return path.join(runDir, RUN_STATE_LOCK_NAME); }
|
|
34
|
+
function workspacePathOf(state) { return path.resolve(state?.workspacePath || state?.repo || DEFAULT_WORKSPACE || DEFAULT_REPO); }
|
|
35
|
+
function workspaceNameOf(state) { return state?.workspaceName || path.basename(workspacePathOf(state)) || workspacePathOf(state); }
|
|
36
|
+
async function detectWorkspaceMetadata(workspacePath) {
|
|
37
|
+
const resolvedWorkspace = path.resolve(workspacePath || DEFAULT_WORKSPACE || DEFAULT_REPO || process.cwd());
|
|
38
|
+
const metadata = {
|
|
39
|
+
path: resolvedWorkspace,
|
|
40
|
+
name: path.basename(resolvedWorkspace) || resolvedWorkspace,
|
|
41
|
+
isGit: false
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
const { stdout: rootStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'rev-parse', '--show-toplevel'], { timeout: 5000 });
|
|
45
|
+
const gitRoot = rootStdout.trim();
|
|
46
|
+
if (gitRoot) {
|
|
47
|
+
metadata.isGit = true;
|
|
48
|
+
metadata.gitRoot = gitRoot;
|
|
49
|
+
try {
|
|
50
|
+
const { stdout: branchStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'branch', '--show-current'], { timeout: 5000 });
|
|
51
|
+
metadata.branch = branchStdout.trim() || 'detached';
|
|
52
|
+
} catch {
|
|
53
|
+
metadata.branch = 'unknown';
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const { stdout: dirtyStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'status', '--porcelain'], { timeout: 5000 });
|
|
57
|
+
metadata.dirty = dirtyStdout.trim().length > 0;
|
|
58
|
+
} catch {
|
|
59
|
+
metadata.dirty = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
return metadata;
|
|
64
|
+
}
|
|
65
|
+
async function assertWorkspacePath(workspace) {
|
|
66
|
+
const resolvedWorkspace = path.resolve(workspace || DEFAULT_WORKSPACE || DEFAULT_REPO || process.cwd());
|
|
67
|
+
let stat;
|
|
68
|
+
try { stat = await fsp.stat(resolvedWorkspace); }
|
|
69
|
+
catch { throw userInputError(`workspace does not exist: ${resolvedWorkspace}`); }
|
|
70
|
+
if (!stat.isDirectory()) throw userInputError(`workspace is not a directory: ${resolvedWorkspace}`);
|
|
71
|
+
return resolvedWorkspace;
|
|
72
|
+
}
|
|
73
|
+
function normalizeWorkspaceState(state) {
|
|
74
|
+
const workspacePath = path.resolve(state?.workspacePath || state?.repo || DEFAULT_WORKSPACE || DEFAULT_REPO);
|
|
75
|
+
const workspaceName = state.workspaceName || path.basename(workspacePath) || workspacePath;
|
|
76
|
+
const git = state.git || state.workspace?.git || null;
|
|
77
|
+
state.workspacePath = workspacePath;
|
|
78
|
+
state.workspaceName = workspaceName;
|
|
79
|
+
state.repo = state.repo || workspacePath;
|
|
80
|
+
state.git = git;
|
|
81
|
+
state.workspace = state.workspace || {
|
|
82
|
+
path: workspacePath,
|
|
83
|
+
name: workspaceName,
|
|
84
|
+
git
|
|
85
|
+
};
|
|
86
|
+
if (!state.workspace.git && git) state.workspace.git = git;
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function isStaleRunLock(lockFile) {
|
|
91
|
+
const info = await fileInfo(lockFile);
|
|
92
|
+
if (!info.exists) return false;
|
|
93
|
+
const modifiedAt = Date.parse(info.mtime || '');
|
|
94
|
+
if (!Number.isFinite(modifiedAt)) return true;
|
|
95
|
+
if (Date.now() - modifiedAt < RUN_STATE_LOCK_STALE_MS) return false;
|
|
96
|
+
const lockData = await readJson(lockFile, null);
|
|
97
|
+
const pid = Number(lockData?.pid);
|
|
98
|
+
if (!Number.isFinite(pid) || pid <= 0) return true;
|
|
99
|
+
return !isPidAlive(pid);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function acquireRunStateLock(runId, { timeoutMs = RUN_STATE_LOCK_TIMEOUT_MS, staleMs = RUN_STATE_LOCK_STALE_MS } = {}) {
|
|
103
|
+
const runDir = pathForRun(runId);
|
|
104
|
+
await ensureDir(runDir);
|
|
105
|
+
const lockFile = lockPath(runDir);
|
|
106
|
+
const startedAt = Date.now();
|
|
107
|
+
let waitMs = 50;
|
|
108
|
+
while (true) {
|
|
109
|
+
try {
|
|
110
|
+
const handle = await fsp.open(lockFile, 'wx');
|
|
111
|
+
try {
|
|
112
|
+
await handle.writeFile(JSON.stringify({ runId, pid: process.pid, createdAt: nowIso() }, null, 2));
|
|
113
|
+
await handle.sync().catch(() => {});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await handle.close().catch(() => {});
|
|
116
|
+
await fsp.unlink(lockFile).catch(() => {});
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
let released = false;
|
|
120
|
+
return async () => {
|
|
121
|
+
if (released) return;
|
|
122
|
+
released = true;
|
|
123
|
+
try { await handle.close(); } catch {}
|
|
124
|
+
await fsp.unlink(lockFile).catch(() => {});
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
128
|
+
if (await isStaleRunLock(lockFile) && Date.now() - startedAt >= staleMs) {
|
|
129
|
+
await fsp.unlink(lockFile).catch(() => {});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (Date.now() - startedAt >= timeoutMs) throw new Error(`run state lock busy: ${runId}`);
|
|
133
|
+
await sleep(waitMs);
|
|
134
|
+
waitMs = Math.min(waitMs * 1.5, 1000);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function withRunStateLock(runId, fn, options = {}) {
|
|
140
|
+
const release = await acquireRunStateLock(runId, options);
|
|
141
|
+
try {
|
|
142
|
+
return await fn();
|
|
143
|
+
} finally {
|
|
144
|
+
await release();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
29
147
|
|
|
30
148
|
function shouldMarkRunnerUnknown(target) {
|
|
31
149
|
const missingSince = Date.parse(target.missingRunnerAt || '');
|
|
@@ -102,28 +220,29 @@ function deriveRunLabel(label, taskText) {
|
|
|
102
220
|
return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
|
|
103
221
|
}
|
|
104
222
|
|
|
105
|
-
async function assertGitWorkTree(repo) {
|
|
106
|
-
const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
|
|
107
|
-
let stat;
|
|
108
|
-
try { stat = await fsp.stat(resolvedRepo); }
|
|
109
|
-
catch { throw userInputError(`target repository does not exist: ${resolvedRepo}`); }
|
|
110
|
-
if (!stat.isDirectory()) throw userInputError(`target repository is not a directory: ${resolvedRepo}`);
|
|
111
|
-
try {
|
|
112
|
-
const { stdout } = await execFileAsync('git', ['-C', resolvedRepo, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
|
|
113
|
-
if (stdout.trim() === 'true') return resolvedRepo;
|
|
114
|
-
} catch {}
|
|
115
|
-
throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
|
|
116
|
-
}
|
|
117
223
|
|
|
118
|
-
export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
119
|
-
const
|
|
224
|
+
export async function createRun({ label = '', taskText = '', workspace = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
225
|
+
const resolvedWorkspace = await assertWorkspacePath(workspace || repo || DEFAULT_WORKSPACE);
|
|
226
|
+
const workspaceMeta = await detectWorkspaceMetadata(resolvedWorkspace);
|
|
120
227
|
const runLabel = deriveRunLabel(label, taskText);
|
|
121
228
|
const runId = makeRunId(runLabel);
|
|
122
229
|
const runDir = pathForRun(runId);
|
|
123
230
|
await ensureDir(runDir);
|
|
124
231
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
125
232
|
const state = {
|
|
126
|
-
runId,
|
|
233
|
+
runId,
|
|
234
|
+
label: runLabel,
|
|
235
|
+
workspacePath: resolvedWorkspace,
|
|
236
|
+
workspaceName: workspaceMeta.name,
|
|
237
|
+
workspace: {
|
|
238
|
+
path: resolvedWorkspace,
|
|
239
|
+
name: workspaceMeta.name,
|
|
240
|
+
git: workspaceMeta
|
|
241
|
+
},
|
|
242
|
+
git: workspaceMeta,
|
|
243
|
+
repo: resolvedWorkspace,
|
|
244
|
+
maxParallel: Number(maxParallel) || 3,
|
|
245
|
+
workerSandbox: normalizeSandbox(workerSandbox),
|
|
127
246
|
runner: RUNNER,
|
|
128
247
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
129
248
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
@@ -132,23 +251,57 @@ export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO
|
|
|
132
251
|
return state;
|
|
133
252
|
}
|
|
134
253
|
|
|
135
|
-
|
|
254
|
+
function normalizeWorkspaceFilter(workspace) {
|
|
255
|
+
const value = String(workspace || '').trim();
|
|
256
|
+
if (!value || value === 'all') return '';
|
|
257
|
+
return path.resolve(value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function runMatchesWorkspace(run, workspaceFilter) {
|
|
261
|
+
if (!workspaceFilter) return true;
|
|
262
|
+
const runWorkspace = path.resolve(run?.workspacePath || run?.repo || '');
|
|
263
|
+
return runWorkspace === workspaceFilter;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function isTerminalRunStatus(status) {
|
|
267
|
+
return ['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(status);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function isFailureRunStatus(status) {
|
|
271
|
+
return ['judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(status);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function listRuns({ includeArchived = false, workspace = '' } = {}) {
|
|
275
|
+
const workspaceFilter = normalizeWorkspaceFilter(workspace);
|
|
136
276
|
const dirs = await listRunDirs();
|
|
137
277
|
const rows = [];
|
|
138
278
|
for (const dir of dirs) {
|
|
139
|
-
const s = await
|
|
140
|
-
if (s
|
|
279
|
+
const s = await loadRun(path.basename(dir));
|
|
280
|
+
if (!s || (!includeArchived && s.archived)) continue;
|
|
281
|
+
const summary = summaryOfRun(s);
|
|
282
|
+
if (!runMatchesWorkspace(summary, workspaceFilter)) continue;
|
|
283
|
+
rows.push(summary);
|
|
141
284
|
}
|
|
142
285
|
return rows;
|
|
143
286
|
}
|
|
144
287
|
|
|
145
288
|
export async function loadRun(runId) {
|
|
146
289
|
const state = await readJson(statePath(pathForRun(runId)), null);
|
|
147
|
-
if (state)
|
|
290
|
+
if (state) {
|
|
291
|
+
normalizeWorkspaceState(state);
|
|
292
|
+
if (!state.git || typeof state.git.isGit !== 'boolean') {
|
|
293
|
+
const workspaceMeta = await detectWorkspaceMetadata(workspacePathOf(state));
|
|
294
|
+
state.git = workspaceMeta;
|
|
295
|
+
state.workspace = state.workspace || { path: workspacePathOf(state), name: state.workspaceName, git: workspaceMeta };
|
|
296
|
+
state.workspace.git = workspaceMeta;
|
|
297
|
+
}
|
|
298
|
+
ensureBatchShape(state);
|
|
299
|
+
}
|
|
148
300
|
return state;
|
|
149
301
|
}
|
|
150
302
|
|
|
151
303
|
async function saveRun(state) {
|
|
304
|
+
normalizeWorkspaceState(state);
|
|
152
305
|
ensureBatchShape(state);
|
|
153
306
|
state.updatedAt = nowIso();
|
|
154
307
|
await writeJsonAtomic(statePath(pathForRun(state.runId)), state);
|
|
@@ -238,38 +391,42 @@ Plan: ${path.join(pathForRun(state.runId), 'plan.json')}
|
|
|
238
391
|
}
|
|
239
392
|
|
|
240
393
|
export async function startPlanner(runId) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
394
|
+
return await withRunStateLock(runId, async () => {
|
|
395
|
+
const state = await loadRun(runId);
|
|
396
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
397
|
+
if (state.archived) throw new Error('archived run cannot be planned');
|
|
398
|
+
if (state.status === 'stopped') throw new Error('stopped run cannot be planned; create a new run after modifications');
|
|
399
|
+
if (state.planner.status === 'running') throw new Error('planner already running');
|
|
400
|
+
if (hasStartedExecution(state)) throw new Error('planner retry is allowed only before any worker/judge starts');
|
|
401
|
+
const runDir = pathForRun(runId);
|
|
402
|
+
const previousPlanner = state.planner;
|
|
403
|
+
if (previousPlanner?.status && previousPlanner.status !== 'pending') await rotatePlannerAttempt(state, runDir);
|
|
404
|
+
state.batches = [];
|
|
405
|
+
state.tasks = [];
|
|
406
|
+
state.judge = { status: 'pending' };
|
|
407
|
+
const outDir = roleDir(runDir, 'planner');
|
|
408
|
+
await ensureDir(outDir);
|
|
409
|
+
await fsp.rm(planPath(runDir), { force: true });
|
|
410
|
+
const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
|
|
411
|
+
const prompt = defaultPlannerPrompt(state, taskText);
|
|
412
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
|
|
413
|
+
state.status = 'planning';
|
|
414
|
+
state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
|
|
415
|
+
await saveRun(state);
|
|
416
|
+
child.onExit(async code => {
|
|
417
|
+
await withRunStateLock(runId, async () => {
|
|
418
|
+
const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
|
|
419
|
+
s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
|
|
420
|
+
const planResult = await materializePlan(s);
|
|
421
|
+
if (s.planner.status !== 'completed') s.status = 'plan_failed';
|
|
422
|
+
else if (planResult.ok) s.status = 'planned';
|
|
423
|
+
else if (planResult.empty) s.status = 'plan_empty';
|
|
424
|
+
else s.status = 'plan_failed';
|
|
425
|
+
await saveRun(s);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
return state;
|
|
271
429
|
});
|
|
272
|
-
return state;
|
|
273
430
|
}
|
|
274
431
|
|
|
275
432
|
function normalizeExpectedArtifacts(value, runId, taskId) {
|
|
@@ -322,6 +479,43 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
322
479
|
}];
|
|
323
480
|
}
|
|
324
481
|
|
|
482
|
+
async function rotateWorkerAttempt(state, task) {
|
|
483
|
+
const runDir = pathForRun(state.runId);
|
|
484
|
+
const workerDir = roleDir(runDir, 'worker', task.id);
|
|
485
|
+
if (!fs.existsSync(workerDir)) return null;
|
|
486
|
+
const attemptsDir = path.join(runDir, 'worker_attempts', task.id);
|
|
487
|
+
await ensureDir(attemptsDir);
|
|
488
|
+
const attempt = Number(task.retryCount || 0) + 1;
|
|
489
|
+
const archivedDir = path.join(attemptsDir, `attempt-${String(attempt).padStart(2, '0')}`);
|
|
490
|
+
await fsp.rm(archivedDir, { recursive: true, force: true });
|
|
491
|
+
await fsp.rename(workerDir, archivedDir);
|
|
492
|
+
task.retryHistory = [...(task.retryHistory || []), {
|
|
493
|
+
attempt,
|
|
494
|
+
status: task.status,
|
|
495
|
+
exitCode: task.exitCode ?? null,
|
|
496
|
+
startedAt: task.startedAt,
|
|
497
|
+
endedAt: task.endedAt,
|
|
498
|
+
archivedDir,
|
|
499
|
+
archivedAt: nowIso(),
|
|
500
|
+
reason: task.retryReason || null
|
|
501
|
+
}];
|
|
502
|
+
task.retryCount = attempt;
|
|
503
|
+
task.retryReason = null;
|
|
504
|
+
delete task.pid;
|
|
505
|
+
delete task.exitCode;
|
|
506
|
+
delete task.startedAt;
|
|
507
|
+
delete task.endedAt;
|
|
508
|
+
delete task.stoppedAt;
|
|
509
|
+
delete task.missingRunnerAt;
|
|
510
|
+
delete task.manualCompletion;
|
|
511
|
+
delete task.originalStatus;
|
|
512
|
+
delete task.originalExitCode;
|
|
513
|
+
delete task.error;
|
|
514
|
+
delete task.tmux;
|
|
515
|
+
task.status = 'pending';
|
|
516
|
+
return archivedDir;
|
|
517
|
+
}
|
|
518
|
+
|
|
325
519
|
function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write', runId = '') {
|
|
326
520
|
if (Array.isArray(plan.batches)) {
|
|
327
521
|
const batches = plan.batches.map((b, bi) => {
|
|
@@ -378,22 +572,24 @@ async function materializePlan(state) {
|
|
|
378
572
|
}
|
|
379
573
|
|
|
380
574
|
export async function dispatchRun(runId) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
575
|
+
return await withRunStateLock(runId, async () => {
|
|
576
|
+
const state = await loadRun(runId);
|
|
577
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
578
|
+
if (state.archived) throw new Error('archived run cannot be dispatched');
|
|
579
|
+
if (state.status === 'stopped') throw new Error('stopped run cannot be dispatched; create a new run after modifications');
|
|
580
|
+
if (!state.tasks?.length) throw new Error('no tasks in plan');
|
|
581
|
+
if (state.status === 'batch_blocked') throw new Error('current batch is blocked by failed/unknown tasks');
|
|
582
|
+
if (allBatchesCompleted(state)) throw new Error('all batches completed; run final judge next');
|
|
583
|
+
state.status = 'running';
|
|
584
|
+
await scheduleMoreWorkers(state);
|
|
585
|
+
recomputeRunStatus(state);
|
|
586
|
+
await saveRun(state);
|
|
587
|
+
return state;
|
|
588
|
+
});
|
|
393
589
|
}
|
|
394
590
|
|
|
395
591
|
function artifactPathForState(state, rel) {
|
|
396
|
-
return path.isAbsolute(rel) ? rel : path.join(state
|
|
592
|
+
return path.isAbsolute(rel) ? rel : path.join(workspacePathOf(state), rel);
|
|
397
593
|
}
|
|
398
594
|
|
|
399
595
|
function workerArtifactInstructions(state, task) {
|
|
@@ -423,168 +619,252 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
|
|
|
423
619
|
|
|
424
620
|
${task.prompt}${workerArtifactInstructions(state, task)}${upstreamArtifactInstructions(state, task)}
|
|
425
621
|
`;
|
|
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
|
|
622
|
+
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: workspacePathOf(state), outDir });
|
|
427
623
|
Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
|
|
428
624
|
}
|
|
429
625
|
|
|
430
626
|
export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
stoppedPids.
|
|
440
|
-
|
|
627
|
+
return await withRunStateLock(runId, async () => {
|
|
628
|
+
const state = await loadRun(runId);
|
|
629
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
630
|
+
const stoppedAt = nowIso();
|
|
631
|
+
await runner.stopRun(runId);
|
|
632
|
+
const stoppedPids = new Set();
|
|
633
|
+
const stopTargetPid = target => {
|
|
634
|
+
const pid = Number(target?.pid);
|
|
635
|
+
if (Number.isFinite(pid) && pid > 0 && !stoppedPids.has(pid)) {
|
|
636
|
+
stoppedPids.add(pid);
|
|
637
|
+
stopPid(pid);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
for (const roleState of [state.planner, state.judge]) {
|
|
641
|
+
if (roleState?.status === 'running') {
|
|
642
|
+
stopTargetPid(roleState);
|
|
643
|
+
Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
644
|
+
}
|
|
441
645
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
646
|
+
for (const task of state.tasks || []) {
|
|
647
|
+
if (task.status === 'running') {
|
|
648
|
+
stopTargetPid(task);
|
|
649
|
+
Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
650
|
+
}
|
|
447
651
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (task.status === 'running') {
|
|
451
|
-
stopTargetPid(task);
|
|
452
|
-
Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
|
|
652
|
+
for (const batch of state.batches || []) {
|
|
653
|
+
for (const task of batch.tasks || []) if (task.status === 'running') stopTargetPid(task);
|
|
453
654
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
state.stopInfo = { reason, stoppedAt };
|
|
463
|
-
await saveRun(state);
|
|
464
|
-
return state;
|
|
655
|
+
for (const batch of state.batches || []) {
|
|
656
|
+
if ((batch.tasks || []).some(t => t.status === 'stopped')) batch.status = 'stopped';
|
|
657
|
+
}
|
|
658
|
+
state.status = 'stopped';
|
|
659
|
+
state.stopInfo = { reason, stoppedAt };
|
|
660
|
+
await saveRun(state);
|
|
661
|
+
return state;
|
|
662
|
+
});
|
|
465
663
|
}
|
|
466
664
|
|
|
467
665
|
export async function archiveRun(runId, { reason = 'archived by user' } = {}) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
666
|
+
return await withRunStateLock(runId, async () => {
|
|
667
|
+
const state = await loadRun(runId);
|
|
668
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
669
|
+
if ((state.tasks || []).some(t => t.status === 'running') || state.planner?.status === 'running' || state.judge?.status === 'running') {
|
|
670
|
+
throw new Error('cannot archive a run while tasks are running; stop it first');
|
|
671
|
+
}
|
|
672
|
+
state.archived = true;
|
|
673
|
+
state.archivedAt = nowIso();
|
|
674
|
+
state.archiveInfo = { reason, archivedAt: state.archivedAt };
|
|
675
|
+
await saveRun(state);
|
|
676
|
+
return state;
|
|
677
|
+
});
|
|
478
678
|
}
|
|
479
679
|
|
|
480
680
|
export async function renameRun(runId, { label = '' } = {}) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
681
|
+
return await withRunStateLock(runId, async () => {
|
|
682
|
+
const state = await loadRun(runId);
|
|
683
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
684
|
+
const nextLabel = String(label || '').trim();
|
|
685
|
+
if (!nextLabel) throw userInputError('run label cannot be empty');
|
|
686
|
+
state.label = nextLabel;
|
|
687
|
+
state.renamedAt = nowIso();
|
|
688
|
+
await saveRun(state);
|
|
689
|
+
return state;
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export async function retryRun(runId, { taskId = '', reason = 'manual retry', maxRetries = 1, auto = false } = {}) {
|
|
694
|
+
return await withRunStateLock(runId, async () => {
|
|
695
|
+
const state = await loadRun(runId);
|
|
696
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
697
|
+
if (state.archived) throw new Error('archived run cannot be retried');
|
|
698
|
+
if (state.status === 'stopped') throw new Error('stopped run cannot be retried');
|
|
699
|
+
const selectedTaskId = String(taskId || '').trim();
|
|
700
|
+
let taskIds = [];
|
|
701
|
+
if (selectedTaskId) {
|
|
702
|
+
const task = (state.tasks || []).find(item => item.id === selectedTaskId);
|
|
703
|
+
if (!task) throw new Error(`task not found: ${selectedTaskId}`);
|
|
704
|
+
taskIds = [task.id];
|
|
705
|
+
if (!['failed', 'unknown'].includes(task.status)) throw new Error(`task is not retryable: ${selectedTaskId}`);
|
|
706
|
+
} else {
|
|
707
|
+
const batch = currentBlockedBatch(state);
|
|
708
|
+
if (!batch) throw new Error('no blocked batch to retry');
|
|
709
|
+
taskIds = (batch.tasks || []).filter(item => ['failed', 'unknown'].includes(item.status) && (!auto || canAutoRetryTask(item, maxRetries))).map(item => item.id);
|
|
710
|
+
if (!taskIds.length) throw new Error('no retryable tasks in blocked batch');
|
|
711
|
+
}
|
|
712
|
+
const result = await retryTasksInState(state, taskIds, { auto, maxRetries, reason });
|
|
713
|
+
if (!result.retried.length) throw new Error('no tasks were retried');
|
|
714
|
+
await saveRun(state);
|
|
715
|
+
return { ...state, retriedTaskIds: result.retried };
|
|
716
|
+
});
|
|
489
717
|
}
|
|
490
718
|
|
|
491
719
|
export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user', resultText = '' } = {}) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
720
|
+
return await withRunStateLock(runId, async () => {
|
|
721
|
+
const state = await loadRun(runId);
|
|
722
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
723
|
+
const task = (state.tasks || []).find(t => t.id === taskId);
|
|
724
|
+
if (!task) throw new Error(`task not found: ${taskId}`);
|
|
725
|
+
if (task.status === 'running') throw new Error('cannot mark a running task completed');
|
|
726
|
+
const runDir = pathForRun(runId);
|
|
727
|
+
const outDir = roleDir(runDir, 'worker', task.id);
|
|
728
|
+
await ensureDir(outDir);
|
|
729
|
+
if (task.status !== 'completed') {
|
|
730
|
+
const manualResult = String(resultText || '').trim();
|
|
731
|
+
if (manualResult) await fsp.writeFile(path.join(outDir, 'manual_result.md'), manualResult);
|
|
732
|
+
const override = {
|
|
733
|
+
type: 'manual_task_completed',
|
|
734
|
+
runId,
|
|
735
|
+
taskId,
|
|
736
|
+
originalStatus: task.originalStatus || task.status,
|
|
737
|
+
originalExitCode: task.originalExitCode ?? task.exitCode ?? null,
|
|
738
|
+
previousStatus: task.status,
|
|
739
|
+
previousExitCode: task.exitCode ?? null,
|
|
740
|
+
reason,
|
|
741
|
+
hasManualResult: !!manualResult,
|
|
742
|
+
manualResultFile: manualResult ? 'manual_result.md' : null,
|
|
743
|
+
manualResultPreview: manualResult ? manualResult.slice(0, 500) : '',
|
|
744
|
+
markedAt: nowIso()
|
|
745
|
+
};
|
|
746
|
+
await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
|
|
747
|
+
Object.assign(task, {
|
|
748
|
+
status: 'completed',
|
|
749
|
+
originalStatus: override.originalStatus,
|
|
750
|
+
originalExitCode: override.originalExitCode,
|
|
751
|
+
manualCompletion: override,
|
|
752
|
+
completedAt: override.markedAt
|
|
753
|
+
});
|
|
754
|
+
const batch = (state.batches || []).find(b => b.id === task.batchId);
|
|
755
|
+
if (batch) {
|
|
756
|
+
const batchTask = batch.tasks.find(t => t.id === task.id);
|
|
757
|
+
if (batchTask && batchTask !== task) Object.assign(batchTask, task);
|
|
758
|
+
}
|
|
529
759
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
760
|
+
recomputeRunStatus(state);
|
|
761
|
+
if (hasPendingRunnableBatch(state)) state.status = 'running';
|
|
762
|
+
await scheduleMoreWorkers(state);
|
|
763
|
+
recomputeRunStatus(state);
|
|
764
|
+
await saveRun(state);
|
|
765
|
+
return state;
|
|
766
|
+
});
|
|
537
767
|
}
|
|
538
768
|
|
|
539
769
|
export async function startJudge(runId) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
770
|
+
return await withRunStateLock(runId, async () => {
|
|
771
|
+
const state = await loadRun(runId);
|
|
772
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
773
|
+
recomputeRunStatus(state);
|
|
774
|
+
if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
|
|
775
|
+
const outDir = roleDir(pathForRun(runId), 'judge');
|
|
776
|
+
await ensureDir(outDir);
|
|
777
|
+
const judgeInputPath = path.join(outDir, 'judge_input.json');
|
|
778
|
+
const judgeInput = await buildJudgeInput(state);
|
|
779
|
+
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
780
|
+
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
781
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
|
|
782
|
+
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
|
|
783
|
+
state.status = 'judging';
|
|
784
|
+
await saveRun(state);
|
|
785
|
+
child.onExit(async code => {
|
|
786
|
+
await withRunStateLock(runId, async () => {
|
|
787
|
+
const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
|
|
788
|
+
s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
|
|
789
|
+
const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
|
|
790
|
+
const verdict = extractFirstJsonObject(text);
|
|
791
|
+
if (verdict) { s.judge.verdict = verdict; await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict); }
|
|
792
|
+
s.status = s.judge.status === 'completed' ? 'judged' : 'judge_failed';
|
|
793
|
+
await saveRun(s);
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
return state;
|
|
562
797
|
});
|
|
563
|
-
return state;
|
|
564
798
|
}
|
|
565
799
|
|
|
566
800
|
export async function refreshRun(runId, appClient = null) {
|
|
567
801
|
return await loadAndRefreshRun(runId, appClient, { light: false });
|
|
568
802
|
}
|
|
569
803
|
|
|
570
|
-
async function
|
|
571
|
-
|
|
572
|
-
if (!state) return
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
804
|
+
export async function autoAdvanceRun(runId, { appClient = null, startCreated = false, maxRetries = 1, retryReason = 'auto retry from scheduler' } = {}) {
|
|
805
|
+
let state = await refreshRun(runId, appClient);
|
|
806
|
+
if (!state || state.archived || state.status === 'stopped') return state;
|
|
807
|
+
if (startCreated && state.status === 'created') {
|
|
808
|
+
try { state = await startPlanner(runId); }
|
|
809
|
+
catch (error) { if (!/planner already running/i.test(error.message || '')) throw error; }
|
|
810
|
+
state = await refreshRun(runId, appClient);
|
|
811
|
+
}
|
|
812
|
+
if (!state || state.archived || state.status === 'stopped') return state;
|
|
813
|
+
if (state.status === 'batch_blocked') {
|
|
814
|
+
try { state = await retryRun(runId, { reason: retryReason, maxRetries, auto: true }); }
|
|
815
|
+
catch (error) { state.autoAdvanceError = error.message || String(error); }
|
|
816
|
+
return await refreshRun(runId, appClient) || state;
|
|
817
|
+
}
|
|
818
|
+
if (state.status === 'planned') {
|
|
819
|
+
try { state = await dispatchRun(runId); }
|
|
820
|
+
catch (error) { if (!/all batches completed|current batch is blocked/i.test(error.message || '')) throw error; }
|
|
821
|
+
return await refreshRun(runId, appClient) || state;
|
|
822
|
+
}
|
|
823
|
+
if (state.status === 'batches_completed' && state.judge?.status !== 'running' && state.judge?.status !== 'completed') {
|
|
824
|
+
try { state = await startJudge(runId); }
|
|
825
|
+
catch (error) { if (!/final judge is allowed only after all batches completed/i.test(error.message || '')) throw error; }
|
|
826
|
+
return await refreshRun(runId, appClient) || state;
|
|
827
|
+
}
|
|
585
828
|
return state;
|
|
586
829
|
}
|
|
587
830
|
|
|
831
|
+
export async function autoAdvanceActiveRuns({ appClient = null, startCreated = false, maxRetries = 1, retryReason = 'auto retry from scheduler' } = {}) {
|
|
832
|
+
const dirs = await listRunDirs();
|
|
833
|
+
const results = [];
|
|
834
|
+
for (const dir of dirs) {
|
|
835
|
+
const runId = path.basename(dir);
|
|
836
|
+
try {
|
|
837
|
+
const initial = await loadRun(runId);
|
|
838
|
+
if (!initial || initial.archived || ['judged', 'judge_failed', 'plan_failed', 'plan_empty', 'stopped'].includes(initial.status)) continue;
|
|
839
|
+
const state = await autoAdvanceRun(runId, { appClient, startCreated, maxRetries, retryReason });
|
|
840
|
+
if (state) results.push({ runId, status: state.status, ok: true });
|
|
841
|
+
} catch (error) {
|
|
842
|
+
results.push({ runId, ok: false, error: error.message || String(error) });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return results;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
|
|
849
|
+
return await withRunStateLock(runId, async () => {
|
|
850
|
+
const state = await loadRun(runId);
|
|
851
|
+
if (!state) return null;
|
|
852
|
+
state.runner = state.runner || RUNNER;
|
|
853
|
+
await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
|
|
854
|
+
await recoverCompletedPlanner(state);
|
|
855
|
+
for (const task of state.tasks || []) await refreshTask(state, task);
|
|
856
|
+
await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
|
|
857
|
+
await recoverCompletedJudge(state);
|
|
858
|
+
aggregateRunTmuxMetadata(state);
|
|
859
|
+
recomputeRunStatus(state);
|
|
860
|
+
await scheduleMoreWorkers(state);
|
|
861
|
+
recomputeRunStatus(state);
|
|
862
|
+
if (appClient && !light) await enrichFromAppServer(state, appClient).catch(e => { state.appServerError = e.message; });
|
|
863
|
+
await saveRun(state);
|
|
864
|
+
return state;
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
588
868
|
async function recoverCompletedPlanner(state) {
|
|
589
869
|
if (state.planner?.status !== 'completed' || state.tasks?.length || state.batches?.length) return;
|
|
590
870
|
const planResult = await materializePlan(state);
|
|
@@ -650,7 +930,7 @@ async function refreshTask(state, task) {
|
|
|
650
930
|
await attachTmuxMetadata(task, dir);
|
|
651
931
|
delete task.attentionHint;
|
|
652
932
|
task.artifacts = [];
|
|
653
|
-
for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state
|
|
933
|
+
for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(workspacePathOf(state), rel))) });
|
|
654
934
|
const batch = (state.batches || []).find(b => b.id === task.batchId);
|
|
655
935
|
if (batch) {
|
|
656
936
|
const bt = batch.tasks.find(t => t.id === task.id);
|
|
@@ -744,6 +1024,43 @@ function currentBatch(state) {
|
|
|
744
1024
|
return (state.batches || []).find(b => b.status !== 'completed');
|
|
745
1025
|
}
|
|
746
1026
|
|
|
1027
|
+
function currentBlockedBatch(state) {
|
|
1028
|
+
ensureBatchShape(state);
|
|
1029
|
+
return (state.batches || []).find(b => b.status === 'failed' || b.status === 'blocked' || b.status === 'running' && (b.tasks || []).some(t => ['failed', 'unknown'].includes(t.status)));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function canAutoRetryTask(task, maxRetries = 1) {
|
|
1033
|
+
if (!task) return false;
|
|
1034
|
+
if (!['failed', 'unknown'].includes(task.status)) return false;
|
|
1035
|
+
if (Number(task.retryCount || 0) >= Number(maxRetries || 1)) return false;
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function retryTasksInState(state, taskIds = null, { auto = false, maxRetries = 1, reason = 'retry' } = {}) {
|
|
1040
|
+
ensureBatchShape(state);
|
|
1041
|
+
const selectedTaskIds = taskIds ? new Set(taskIds.map(id => safeIdPart(id))) : null;
|
|
1042
|
+
const tasksToRetry = (state.tasks || []).filter(task => {
|
|
1043
|
+
if (selectedTaskIds && !selectedTaskIds.has(task.id)) return false;
|
|
1044
|
+
if (!['failed', 'unknown'].includes(task.status)) return false;
|
|
1045
|
+
if (auto && !canAutoRetryTask(task, maxRetries)) return false;
|
|
1046
|
+
return true;
|
|
1047
|
+
});
|
|
1048
|
+
if (!tasksToRetry.length) return { retried: [], state };
|
|
1049
|
+
for (const task of tasksToRetry) {
|
|
1050
|
+
if (hasLiveRunnerProcess(state, task.id, task)) throw new Error(`task still has a live process: ${task.id}`);
|
|
1051
|
+
const batch = (state.batches || []).find(item => item.id === task.batchId);
|
|
1052
|
+
task.retryReason = reason;
|
|
1053
|
+
await rotateWorkerAttempt(state, task);
|
|
1054
|
+
const batchTask = batch?.tasks?.find(item => item.id === task.id);
|
|
1055
|
+
if (batchTask && batchTask !== task) Object.assign(batchTask, task);
|
|
1056
|
+
}
|
|
1057
|
+
recomputeRunStatus(state);
|
|
1058
|
+
if (hasPendingRunnableBatch(state)) state.status = 'running';
|
|
1059
|
+
await scheduleMoreWorkers(state);
|
|
1060
|
+
recomputeRunStatus(state);
|
|
1061
|
+
return { retried: tasksToRetry.map(task => task.id), state };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
747
1064
|
async function scheduleMoreWorkers(state) {
|
|
748
1065
|
if (state.status !== 'running') return;
|
|
749
1066
|
const batch = currentBatch(state);
|
|
@@ -838,7 +1155,10 @@ async function buildJudgeInput(state) {
|
|
|
838
1155
|
run: {
|
|
839
1156
|
runId: state.runId,
|
|
840
1157
|
label: state.label,
|
|
841
|
-
repo: state
|
|
1158
|
+
repo: workspacePathOf(state),
|
|
1159
|
+
workspacePath: workspacePathOf(state),
|
|
1160
|
+
workspaceName: state.workspaceName || path.basename(workspacePathOf(state)) || workspacePathOf(state),
|
|
1161
|
+
git: state.git || state.workspace?.git || null,
|
|
842
1162
|
status: state.status,
|
|
843
1163
|
runner: state.runner || RUNNER,
|
|
844
1164
|
createdAt: state.createdAt,
|
|
@@ -882,7 +1202,7 @@ function ensureBatchShape(state) {
|
|
|
882
1202
|
}
|
|
883
1203
|
|
|
884
1204
|
async function enrichFromAppServer(state, appClient) {
|
|
885
|
-
const res = await appClient.listThreads({ cwd: state
|
|
1205
|
+
const res = await appClient.listThreads({ cwd: workspacePathOf(state), limit: 100 });
|
|
886
1206
|
const threads = res?.data || [];
|
|
887
1207
|
const all = [{ id: 'planner', target: state.planner }, ...(state.tasks || []).map(t => ({ id: t.id, target: t })), { id: 'judge', target: state.judge }];
|
|
888
1208
|
for (const item of all) {
|
|
@@ -904,9 +1224,11 @@ function runDurationEndOfState(s) {
|
|
|
904
1224
|
return times.length ? new Date(Math.max(...times)).toISOString() : s.updatedAt;
|
|
905
1225
|
}
|
|
906
1226
|
|
|
907
|
-
function summaryOfRun(s) {
|
|
1227
|
+
export function summaryOfRun(s) {
|
|
908
1228
|
const tasks = s.tasks || [];
|
|
909
|
-
|
|
1229
|
+
const workspacePath = s.workspacePath || s.repo || '';
|
|
1230
|
+
const git = s.git || s.workspace?.git || null;
|
|
1231
|
+
return { runId: s.runId, label: s.label, repo: s.repo || workspacePath, workspacePath, workspaceName: s.workspaceName || path.basename(workspacePath || ''), git, 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 })) };
|
|
910
1232
|
}
|
|
911
1233
|
|
|
912
1234
|
export async function readRunTaskText(runId) {
|