input-kanban 0.0.7 → 0.0.9

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.
@@ -15,6 +15,12 @@ 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;
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));
18
24
 
19
25
  function normalizeSandbox(value, fallback = 'workspace-write') {
20
26
  const sandbox = String(value || '').trim();
@@ -24,6 +30,105 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
24
30
 
25
31
  function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
26
32
  function planPath(runDir) { return path.join(runDir, 'plan.json'); }
33
+ function lockPath(runDir) { return path.join(runDir, RUN_STATE_LOCK_NAME); }
34
+
35
+ async function isStaleRunLock(lockFile) {
36
+ const info = await fileInfo(lockFile);
37
+ if (!info.exists) return false;
38
+ const modifiedAt = Date.parse(info.mtime || '');
39
+ if (!Number.isFinite(modifiedAt)) return true;
40
+ if (Date.now() - modifiedAt < RUN_STATE_LOCK_STALE_MS) return false;
41
+ const lockData = await readJson(lockFile, null);
42
+ const pid = Number(lockData?.pid);
43
+ if (!Number.isFinite(pid) || pid <= 0) return true;
44
+ return !isPidAlive(pid);
45
+ }
46
+
47
+ export async function acquireRunStateLock(runId, { timeoutMs = RUN_STATE_LOCK_TIMEOUT_MS, staleMs = RUN_STATE_LOCK_STALE_MS } = {}) {
48
+ const runDir = pathForRun(runId);
49
+ await ensureDir(runDir);
50
+ const lockFile = lockPath(runDir);
51
+ const startedAt = Date.now();
52
+ let waitMs = 50;
53
+ while (true) {
54
+ try {
55
+ const handle = await fsp.open(lockFile, 'wx');
56
+ try {
57
+ await handle.writeFile(JSON.stringify({ runId, pid: process.pid, createdAt: nowIso() }, null, 2));
58
+ await handle.sync().catch(() => {});
59
+ } catch (error) {
60
+ await handle.close().catch(() => {});
61
+ await fsp.unlink(lockFile).catch(() => {});
62
+ throw error;
63
+ }
64
+ let released = false;
65
+ return async () => {
66
+ if (released) return;
67
+ released = true;
68
+ try { await handle.close(); } catch {}
69
+ await fsp.unlink(lockFile).catch(() => {});
70
+ };
71
+ } catch (error) {
72
+ if (error?.code !== 'EEXIST') throw error;
73
+ if (await isStaleRunLock(lockFile) && Date.now() - startedAt >= staleMs) {
74
+ await fsp.unlink(lockFile).catch(() => {});
75
+ continue;
76
+ }
77
+ if (Date.now() - startedAt >= timeoutMs) throw new Error(`run state lock busy: ${runId}`);
78
+ await sleep(waitMs);
79
+ waitMs = Math.min(waitMs * 1.5, 1000);
80
+ }
81
+ }
82
+ }
83
+
84
+ async function withRunStateLock(runId, fn, options = {}) {
85
+ const release = await acquireRunStateLock(runId, options);
86
+ try {
87
+ return await fn();
88
+ } finally {
89
+ await release();
90
+ }
91
+ }
92
+
93
+ function shouldMarkRunnerUnknown(target) {
94
+ const missingSince = Date.parse(target.missingRunnerAt || '');
95
+ if (!Number.isFinite(missingSince)) {
96
+ target.missingRunnerAt = nowIso();
97
+ return false;
98
+ }
99
+ return Date.now() - missingSince >= MISSING_RUNNER_GRACE_MS;
100
+ }
101
+
102
+ function clearMissingRunner(target) {
103
+ delete target.missingRunnerAt;
104
+ }
105
+
106
+ function isPidAlive(pid) {
107
+ const value = Number(pid);
108
+ if (!Number.isFinite(value) || value <= 0) return false;
109
+ try {
110
+ process.kill(value, 0);
111
+ return true;
112
+ } catch (error) {
113
+ return error?.code === 'EPERM';
114
+ }
115
+ }
116
+
117
+ function hasLiveRunnerProcess(state, id, target) {
118
+ return runner.hasRunning(state.runId, id) || isPidAlive(target?.pid);
119
+ }
120
+
121
+ function stopPid(pid, signal = 'SIGTERM') {
122
+ const value = Number(pid);
123
+ if (!Number.isFinite(value) || value <= 0) return false;
124
+ try {
125
+ process.kill(value, signal);
126
+ if (signal !== 'SIGKILL') setTimeout(() => { if (isPidAlive(value)) stopPid(value, 'SIGKILL'); }, 1000).unref?.();
127
+ return true;
128
+ } catch (error) {
129
+ return error?.code === 'ESRCH';
130
+ }
131
+ }
27
132
 
28
133
  function userInputError(message) {
29
134
  const error = new Error(message);
@@ -31,6 +136,35 @@ function userInputError(message) {
31
136
  return error;
32
137
  }
33
138
 
139
+ function charDisplayWidth(char) {
140
+ return char.codePointAt(0) > 0x2e80 ? 2 : 1;
141
+ }
142
+
143
+ function truncateDisplayWidth(text, maxWidth) {
144
+ let width = 0;
145
+ let result = '';
146
+ for (const char of text) {
147
+ const nextWidth = width + charDisplayWidth(char);
148
+ if (nextWidth > maxWidth) return `${result.trimEnd()}…`;
149
+ result += char;
150
+ width = nextWidth;
151
+ }
152
+ return result;
153
+ }
154
+
155
+ function deriveRunLabel(label, taskText) {
156
+ const explicit = String(label || '').trim();
157
+ if (explicit) return explicit;
158
+ const firstLine = String(taskText || '').split(/\r?\n/).map(line => line.trim()).find(Boolean) || 'task';
159
+ const cleaned = firstLine
160
+ .replace(/^#{1,6}\s+/, '')
161
+ .replace(/^[-*+]\s+/, '')
162
+ .replace(/^\d+[.)、]\s*/, '')
163
+ .replace(/\s+/g, ' ')
164
+ .trim();
165
+ return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
166
+ }
167
+
34
168
  async function assertGitWorkTree(repo) {
35
169
  const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
36
170
  let stat;
@@ -44,14 +178,15 @@ async function assertGitWorkTree(repo) {
44
178
  throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
45
179
  }
46
180
 
47
- export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
181
+ export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
48
182
  const resolvedRepo = await assertGitWorkTree(repo);
49
- const runId = makeRunId(label);
183
+ const runLabel = deriveRunLabel(label, taskText);
184
+ const runId = makeRunId(runLabel);
50
185
  const runDir = pathForRun(runId);
51
186
  await ensureDir(runDir);
52
187
  await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
53
188
  const state = {
54
- runId, label, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
189
+ runId, label: runLabel, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
55
190
  runner: RUNNER,
56
191
  status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
57
192
  planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
@@ -166,41 +301,55 @@ Plan: ${path.join(pathForRun(state.runId), 'plan.json')}
166
301
  }
167
302
 
168
303
  export async function startPlanner(runId) {
169
- const state = await loadRun(runId);
170
- if (!state) throw new Error(`run not found: ${runId}`);
171
- if (state.archived) throw new Error('archived run cannot be planned');
172
- if (state.status === 'stopped') throw new Error('stopped run cannot be planned; create a new run after modifications');
173
- if (state.planner.status === 'running') throw new Error('planner already running');
174
- if (hasStartedExecution(state)) throw new Error('planner retry is allowed only before any worker/judge starts');
175
- const runDir = pathForRun(runId);
176
- const previousPlanner = state.planner;
177
- if (previousPlanner?.status && previousPlanner.status !== 'pending') await rotatePlannerAttempt(state, runDir);
178
- state.batches = [];
179
- state.tasks = [];
180
- state.judge = { status: 'pending' };
181
- const outDir = roleDir(runDir, 'planner');
182
- await ensureDir(outDir);
183
- await fsp.rm(planPath(runDir), { force: true });
184
- const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
185
- const prompt = defaultPlannerPrompt(state, taskText);
186
- const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
187
- state.status = 'planning';
188
- state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
189
- await saveRun(state);
190
- child.onExit(async code => {
191
- const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
192
- s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
193
- const planResult = await materializePlan(s);
194
- if (s.planner.status !== 'completed') s.status = 'plan_failed';
195
- else if (planResult.ok) s.status = 'planned';
196
- else if (planResult.empty) s.status = 'plan_empty';
197
- else s.status = 'plan_failed';
198
- await saveRun(s);
304
+ return await withRunStateLock(runId, async () => {
305
+ const state = await loadRun(runId);
306
+ if (!state) throw new Error(`run not found: ${runId}`);
307
+ if (state.archived) throw new Error('archived run cannot be planned');
308
+ if (state.status === 'stopped') throw new Error('stopped run cannot be planned; create a new run after modifications');
309
+ if (state.planner.status === 'running') throw new Error('planner already running');
310
+ if (hasStartedExecution(state)) throw new Error('planner retry is allowed only before any worker/judge starts');
311
+ const runDir = pathForRun(runId);
312
+ const previousPlanner = state.planner;
313
+ if (previousPlanner?.status && previousPlanner.status !== 'pending') await rotatePlannerAttempt(state, runDir);
314
+ state.batches = [];
315
+ state.tasks = [];
316
+ state.judge = { status: 'pending' };
317
+ const outDir = roleDir(runDir, 'planner');
318
+ await ensureDir(outDir);
319
+ await fsp.rm(planPath(runDir), { force: true });
320
+ const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
321
+ const prompt = defaultPlannerPrompt(state, taskText);
322
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
323
+ state.status = 'planning';
324
+ state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
325
+ await saveRun(state);
326
+ child.onExit(async code => {
327
+ await withRunStateLock(runId, async () => {
328
+ const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
329
+ s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
330
+ const planResult = await materializePlan(s);
331
+ if (s.planner.status !== 'completed') s.status = 'plan_failed';
332
+ else if (planResult.ok) s.status = 'planned';
333
+ else if (planResult.empty) s.status = 'plan_empty';
334
+ else s.status = 'plan_failed';
335
+ await saveRun(s);
336
+ });
337
+ });
338
+ return state;
339
+ });
340
+ }
341
+
342
+ function normalizeExpectedArtifacts(value, runId, taskId) {
343
+ const artifacts = Array.isArray(value) ? value : [];
344
+ return artifacts.map(item => String(item || '').trim()).filter(Boolean).map(item => {
345
+ if (path.isAbsolute(item)) return item;
346
+ const normalized = item.replace(/^\.\//, '');
347
+ if (normalized.includes(runId)) return normalized;
348
+ return path.posix.join('.orchestrator', runId, taskId, normalized);
199
349
  });
200
- return state;
201
350
  }
202
351
 
203
- function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
352
+ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write', runId = '') {
204
353
  const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
205
354
  return {
206
355
  id,
@@ -208,7 +357,7 @@ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
208
357
  name: t.name || t.id || `Task ${i + 1}`,
209
358
  prompt: t.prompt || t.instructions || '',
210
359
  sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
211
- expectedArtifacts: Array.isArray(t.expectedArtifacts) ? t.expectedArtifacts : [],
360
+ expectedArtifacts: normalizeExpectedArtifacts(t.expectedArtifacts, runId, id),
212
361
  status: 'pending'
213
362
  };
214
363
  }
@@ -240,7 +389,44 @@ async function rotatePlannerAttempt(state, runDir) {
240
389
  }];
241
390
  }
242
391
 
243
- function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
392
+ async function rotateWorkerAttempt(state, task) {
393
+ const runDir = pathForRun(state.runId);
394
+ const workerDir = roleDir(runDir, 'worker', task.id);
395
+ if (!fs.existsSync(workerDir)) return null;
396
+ const attemptsDir = path.join(runDir, 'worker_attempts', task.id);
397
+ await ensureDir(attemptsDir);
398
+ const attempt = Number(task.retryCount || 0) + 1;
399
+ const archivedDir = path.join(attemptsDir, `attempt-${String(attempt).padStart(2, '0')}`);
400
+ await fsp.rm(archivedDir, { recursive: true, force: true });
401
+ await fsp.rename(workerDir, archivedDir);
402
+ task.retryHistory = [...(task.retryHistory || []), {
403
+ attempt,
404
+ status: task.status,
405
+ exitCode: task.exitCode ?? null,
406
+ startedAt: task.startedAt,
407
+ endedAt: task.endedAt,
408
+ archivedDir,
409
+ archivedAt: nowIso(),
410
+ reason: task.retryReason || null
411
+ }];
412
+ task.retryCount = attempt;
413
+ task.retryReason = null;
414
+ delete task.pid;
415
+ delete task.exitCode;
416
+ delete task.startedAt;
417
+ delete task.endedAt;
418
+ delete task.stoppedAt;
419
+ delete task.missingRunnerAt;
420
+ delete task.manualCompletion;
421
+ delete task.originalStatus;
422
+ delete task.originalExitCode;
423
+ delete task.error;
424
+ delete task.tmux;
425
+ task.status = 'pending';
426
+ return archivedDir;
427
+ }
428
+
429
+ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write', runId = '') {
244
430
  if (Array.isArray(plan.batches)) {
245
431
  const batches = plan.batches.map((b, bi) => {
246
432
  const batch = {
@@ -250,14 +436,14 @@ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-wri
250
436
  status: 'pending',
251
437
  tasks: []
252
438
  };
253
- batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox));
439
+ batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox, runId));
254
440
  return batch;
255
441
  }).filter(b => b.tasks.length);
256
442
  return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
257
443
  }
258
444
  if (Array.isArray(plan.tasks)) {
259
445
  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));
446
+ batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox, runId));
261
447
  return { ...plan, batches: [batch], tasks: batch.tasks };
262
448
  }
263
449
  return null;
@@ -273,7 +459,7 @@ async function materializePlan(state) {
273
459
  state.tasks = [];
274
460
  return { ok: false, empty: false, error: state.planner.planParseError };
275
461
  }
276
- const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
462
+ const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write', state.runId);
277
463
  if (!normalized || !Array.isArray(normalized.tasks)) {
278
464
  state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
279
465
  state.batches = [];
@@ -296,18 +482,42 @@ async function materializePlan(state) {
296
482
  }
297
483
 
298
484
  export async function dispatchRun(runId) {
299
- const state = await loadRun(runId);
300
- if (!state) throw new Error(`run not found: ${runId}`);
301
- if (state.archived) throw new Error('archived run cannot be dispatched');
302
- if (state.status === 'stopped') throw new Error('stopped run cannot be dispatched; create a new run after modifications');
303
- if (!state.tasks?.length) throw new Error('no tasks in plan');
304
- if (state.status === 'batch_blocked') throw new Error('current batch is blocked by failed/unknown tasks');
305
- if (allBatchesCompleted(state)) throw new Error('all batches completed; run final judge next');
306
- state.status = 'running';
307
- await scheduleMoreWorkers(state);
308
- recomputeRunStatus(state);
309
- await saveRun(state);
310
- return state;
485
+ return await withRunStateLock(runId, async () => {
486
+ const state = await loadRun(runId);
487
+ if (!state) throw new Error(`run not found: ${runId}`);
488
+ if (state.archived) throw new Error('archived run cannot be dispatched');
489
+ if (state.status === 'stopped') throw new Error('stopped run cannot be dispatched; create a new run after modifications');
490
+ if (!state.tasks?.length) throw new Error('no tasks in plan');
491
+ if (state.status === 'batch_blocked') throw new Error('current batch is blocked by failed/unknown tasks');
492
+ if (allBatchesCompleted(state)) throw new Error('all batches completed; run final judge next');
493
+ state.status = 'running';
494
+ await scheduleMoreWorkers(state);
495
+ recomputeRunStatus(state);
496
+ await saveRun(state);
497
+ return state;
498
+ });
499
+ }
500
+
501
+ function artifactPathForState(state, rel) {
502
+ return path.isAbsolute(rel) ? rel : path.join(state.repo, rel);
503
+ }
504
+
505
+ function workerArtifactInstructions(state, task) {
506
+ const artifacts = task.expectedArtifacts || [];
507
+ if (!artifacts.length) return '';
508
+ const lines = artifacts.map(rel => `- ${artifactPathForState(state, rel)}`);
509
+ return `\n\nRequired output artifacts:\nWrite the following artifact path(s) exactly. Create parent directories if needed.\n${lines.join('\n')}`;
510
+ }
511
+
512
+ function upstreamArtifactInstructions(state, task) {
513
+ const currentBatchIndex = (state.batches || []).findIndex(batch => batch.id === task.batchId);
514
+ if (currentBatchIndex <= 0) return '';
515
+ const previousTaskIds = new Set((state.batches || []).slice(0, currentBatchIndex).flatMap(batch => (batch.tasks || []).map(item => item.id)));
516
+ const lines = (state.tasks || [])
517
+ .filter(item => previousTaskIds.has(item.id))
518
+ .flatMap(item => (item.expectedArtifacts || []).map(rel => `- ${item.id}: ${artifactPathForState(state, rel)}`));
519
+ if (!lines.length) return '';
520
+ return `\n\nAvailable upstream artifacts from completed earlier batches:\n${lines.join('\n')}\nUse only artifacts from this run id: ${state.runId}.`;
311
521
  }
312
522
 
313
523
  async function startWorkerInState(state, task) {
@@ -317,129 +527,184 @@ async function startWorkerInState(state, task) {
317
527
  const fullPrompt = `${marker(state.runId, task.id, 'worker')}
318
528
  ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
319
529
 
320
- ${task.prompt}
530
+ ${task.prompt}${workerArtifactInstructions(state, task)}${upstreamArtifactInstructions(state, task)}
321
531
  `;
322
532
  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
533
  Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
324
534
  }
325
535
 
326
536
  export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
327
- const state = await loadRun(runId);
328
- if (!state) throw new Error(`run not found: ${runId}`);
329
- const stoppedAt = nowIso();
330
- await runner.stopRun(runId);
331
- for (const roleState of [state.planner, state.judge]) {
332
- if (roleState?.status === 'running') Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
333
- }
334
- for (const task of state.tasks || []) {
335
- if (task.status === 'running') Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
336
- }
337
- for (const batch of state.batches || []) {
338
- if ((batch.tasks || []).some(t => t.status === 'stopped')) batch.status = 'stopped';
339
- }
340
- state.status = 'stopped';
341
- state.stopInfo = { reason, stoppedAt };
342
- await saveRun(state);
343
- return state;
537
+ return await withRunStateLock(runId, async () => {
538
+ const state = await loadRun(runId);
539
+ if (!state) throw new Error(`run not found: ${runId}`);
540
+ const stoppedAt = nowIso();
541
+ await runner.stopRun(runId);
542
+ const stoppedPids = new Set();
543
+ const stopTargetPid = target => {
544
+ const pid = Number(target?.pid);
545
+ if (Number.isFinite(pid) && pid > 0 && !stoppedPids.has(pid)) {
546
+ stoppedPids.add(pid);
547
+ stopPid(pid);
548
+ }
549
+ };
550
+ for (const roleState of [state.planner, state.judge]) {
551
+ if (roleState?.status === 'running') {
552
+ stopTargetPid(roleState);
553
+ Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
554
+ }
555
+ }
556
+ for (const task of state.tasks || []) {
557
+ if (task.status === 'running') {
558
+ stopTargetPid(task);
559
+ Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
560
+ }
561
+ }
562
+ for (const batch of state.batches || []) {
563
+ for (const task of batch.tasks || []) if (task.status === 'running') stopTargetPid(task);
564
+ }
565
+ for (const batch of state.batches || []) {
566
+ if ((batch.tasks || []).some(t => t.status === 'stopped')) batch.status = 'stopped';
567
+ }
568
+ state.status = 'stopped';
569
+ state.stopInfo = { reason, stoppedAt };
570
+ await saveRun(state);
571
+ return state;
572
+ });
344
573
  }
345
574
 
346
575
  export async function archiveRun(runId, { reason = 'archived by user' } = {}) {
347
- const state = await loadRun(runId);
348
- if (!state) throw new Error(`run not found: ${runId}`);
349
- if ((state.tasks || []).some(t => t.status === 'running') || state.planner?.status === 'running' || state.judge?.status === 'running') {
350
- throw new Error('cannot archive a run while tasks are running; stop it first');
351
- }
352
- state.archived = true;
353
- state.archivedAt = nowIso();
354
- state.archiveInfo = { reason, archivedAt: state.archivedAt };
355
- await saveRun(state);
356
- return state;
576
+ return await withRunStateLock(runId, async () => {
577
+ const state = await loadRun(runId);
578
+ if (!state) throw new Error(`run not found: ${runId}`);
579
+ if ((state.tasks || []).some(t => t.status === 'running') || state.planner?.status === 'running' || state.judge?.status === 'running') {
580
+ throw new Error('cannot archive a run while tasks are running; stop it first');
581
+ }
582
+ state.archived = true;
583
+ state.archivedAt = nowIso();
584
+ state.archiveInfo = { reason, archivedAt: state.archivedAt };
585
+ await saveRun(state);
586
+ return state;
587
+ });
357
588
  }
358
589
 
359
590
  export async function renameRun(runId, { label = '' } = {}) {
360
- const state = await loadRun(runId);
361
- if (!state) throw new Error(`run not found: ${runId}`);
362
- const nextLabel = String(label || '').trim();
363
- if (!nextLabel) throw userInputError('run label cannot be empty');
364
- state.label = nextLabel;
365
- state.renamedAt = nowIso();
366
- await saveRun(state);
367
- return state;
591
+ return await withRunStateLock(runId, async () => {
592
+ const state = await loadRun(runId);
593
+ if (!state) throw new Error(`run not found: ${runId}`);
594
+ const nextLabel = String(label || '').trim();
595
+ if (!nextLabel) throw userInputError('run label cannot be empty');
596
+ state.label = nextLabel;
597
+ state.renamedAt = nowIso();
598
+ await saveRun(state);
599
+ return state;
600
+ });
601
+ }
602
+
603
+ export async function retryRun(runId, { taskId = '', reason = 'manual retry', maxRetries = 1, auto = false } = {}) {
604
+ return await withRunStateLock(runId, async () => {
605
+ const state = await loadRun(runId);
606
+ if (!state) throw new Error(`run not found: ${runId}`);
607
+ if (state.archived) throw new Error('archived run cannot be retried');
608
+ if (state.status === 'stopped') throw new Error('stopped run cannot be retried');
609
+ const selectedTaskId = String(taskId || '').trim();
610
+ let taskIds = [];
611
+ if (selectedTaskId) {
612
+ const task = (state.tasks || []).find(item => item.id === selectedTaskId);
613
+ if (!task) throw new Error(`task not found: ${selectedTaskId}`);
614
+ taskIds = [task.id];
615
+ if (!['failed', 'unknown'].includes(task.status)) throw new Error(`task is not retryable: ${selectedTaskId}`);
616
+ } else {
617
+ const batch = currentBlockedBatch(state);
618
+ if (!batch) throw new Error('no blocked batch to retry');
619
+ taskIds = (batch.tasks || []).filter(item => ['failed', 'unknown'].includes(item.status) && (!auto || canAutoRetryTask(item, maxRetries))).map(item => item.id);
620
+ if (!taskIds.length) throw new Error('no retryable tasks in blocked batch');
621
+ }
622
+ const result = await retryTasksInState(state, taskIds, { auto, maxRetries, reason });
623
+ if (!result.retried.length) throw new Error('no tasks were retried');
624
+ await saveRun(state);
625
+ return { ...state, retriedTaskIds: result.retried };
626
+ });
368
627
  }
369
628
 
370
629
  export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user', resultText = '' } = {}) {
371
- const state = await loadRun(runId);
372
- if (!state) throw new Error(`run not found: ${runId}`);
373
- const task = (state.tasks || []).find(t => t.id === taskId);
374
- if (!task) throw new Error(`task not found: ${taskId}`);
375
- if (task.status === 'running') throw new Error('cannot mark a running task completed');
376
- const runDir = pathForRun(runId);
377
- const outDir = roleDir(runDir, 'worker', task.id);
378
- await ensureDir(outDir);
379
- if (task.status !== 'completed') {
380
- const manualResult = String(resultText || '').trim();
381
- if (manualResult) await fsp.writeFile(path.join(outDir, 'manual_result.md'), manualResult);
382
- const override = {
383
- type: 'manual_task_completed',
384
- runId,
385
- taskId,
386
- originalStatus: task.originalStatus || task.status,
387
- originalExitCode: task.originalExitCode ?? task.exitCode ?? null,
388
- previousStatus: task.status,
389
- previousExitCode: task.exitCode ?? null,
390
- reason,
391
- hasManualResult: !!manualResult,
392
- manualResultFile: manualResult ? 'manual_result.md' : null,
393
- manualResultPreview: manualResult ? manualResult.slice(0, 500) : '',
394
- markedAt: nowIso()
395
- };
396
- await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
397
- Object.assign(task, {
398
- status: 'completed',
399
- originalStatus: override.originalStatus,
400
- originalExitCode: override.originalExitCode,
401
- manualCompletion: override,
402
- completedAt: override.markedAt
403
- });
404
- const batch = (state.batches || []).find(b => b.id === task.batchId);
405
- if (batch) {
406
- const batchTask = batch.tasks.find(t => t.id === task.id);
407
- if (batchTask && batchTask !== task) Object.assign(batchTask, task);
630
+ return await withRunStateLock(runId, async () => {
631
+ const state = await loadRun(runId);
632
+ if (!state) throw new Error(`run not found: ${runId}`);
633
+ const task = (state.tasks || []).find(t => t.id === taskId);
634
+ if (!task) throw new Error(`task not found: ${taskId}`);
635
+ if (task.status === 'running') throw new Error('cannot mark a running task completed');
636
+ const runDir = pathForRun(runId);
637
+ const outDir = roleDir(runDir, 'worker', task.id);
638
+ await ensureDir(outDir);
639
+ if (task.status !== 'completed') {
640
+ const manualResult = String(resultText || '').trim();
641
+ if (manualResult) await fsp.writeFile(path.join(outDir, 'manual_result.md'), manualResult);
642
+ const override = {
643
+ type: 'manual_task_completed',
644
+ runId,
645
+ taskId,
646
+ originalStatus: task.originalStatus || task.status,
647
+ originalExitCode: task.originalExitCode ?? task.exitCode ?? null,
648
+ previousStatus: task.status,
649
+ previousExitCode: task.exitCode ?? null,
650
+ reason,
651
+ hasManualResult: !!manualResult,
652
+ manualResultFile: manualResult ? 'manual_result.md' : null,
653
+ manualResultPreview: manualResult ? manualResult.slice(0, 500) : '',
654
+ markedAt: nowIso()
655
+ };
656
+ await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
657
+ Object.assign(task, {
658
+ status: 'completed',
659
+ originalStatus: override.originalStatus,
660
+ originalExitCode: override.originalExitCode,
661
+ manualCompletion: override,
662
+ completedAt: override.markedAt
663
+ });
664
+ const batch = (state.batches || []).find(b => b.id === task.batchId);
665
+ if (batch) {
666
+ const batchTask = batch.tasks.find(t => t.id === task.id);
667
+ if (batchTask && batchTask !== task) Object.assign(batchTask, task);
668
+ }
408
669
  }
409
- }
410
- recomputeRunStatus(state);
411
- if (hasPendingRunnableBatch(state)) state.status = 'running';
412
- await scheduleMoreWorkers(state);
413
- recomputeRunStatus(state);
414
- await saveRun(state);
415
- return state;
670
+ recomputeRunStatus(state);
671
+ if (hasPendingRunnableBatch(state)) state.status = 'running';
672
+ await scheduleMoreWorkers(state);
673
+ recomputeRunStatus(state);
674
+ await saveRun(state);
675
+ return state;
676
+ });
416
677
  }
417
678
 
418
679
  export async function startJudge(runId) {
419
- const state = await loadRun(runId);
420
- if (!state) throw new Error(`run not found: ${runId}`);
421
- recomputeRunStatus(state);
422
- if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
423
- const outDir = roleDir(pathForRun(runId), 'judge');
424
- await ensureDir(outDir);
425
- const judgeInputPath = path.join(outDir, 'judge_input.json');
426
- const judgeInput = await buildJudgeInput(state);
427
- await writeJsonAtomic(judgeInputPath, judgeInput);
428
- const prompt = defaultJudgePrompt(state, judgeInputPath);
429
- const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
430
- state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
431
- state.status = 'judging';
432
- await saveRun(state);
433
- child.onExit(async code => {
434
- const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
435
- s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
436
- const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
437
- const verdict = extractFirstJsonObject(text);
438
- if (verdict) { s.judge.verdict = verdict; await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict); }
439
- s.status = s.judge.status === 'completed' ? 'judged' : 'judge_failed';
440
- await saveRun(s);
680
+ return await withRunStateLock(runId, async () => {
681
+ const state = await loadRun(runId);
682
+ if (!state) throw new Error(`run not found: ${runId}`);
683
+ recomputeRunStatus(state);
684
+ if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
685
+ const outDir = roleDir(pathForRun(runId), 'judge');
686
+ await ensureDir(outDir);
687
+ const judgeInputPath = path.join(outDir, 'judge_input.json');
688
+ const judgeInput = await buildJudgeInput(state);
689
+ await writeJsonAtomic(judgeInputPath, judgeInput);
690
+ const prompt = defaultJudgePrompt(state, judgeInputPath);
691
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
692
+ state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
693
+ state.status = 'judging';
694
+ await saveRun(state);
695
+ child.onExit(async code => {
696
+ await withRunStateLock(runId, async () => {
697
+ const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
698
+ s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
699
+ const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
700
+ const verdict = extractFirstJsonObject(text);
701
+ if (verdict) { s.judge.verdict = verdict; await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict); }
702
+ s.status = s.judge.status === 'completed' ? 'judged' : 'judge_failed';
703
+ await saveRun(s);
704
+ });
705
+ });
706
+ return state;
441
707
  });
442
- return state;
443
708
  }
444
709
 
445
710
  export async function refreshRun(runId, appClient = null) {
@@ -447,21 +712,23 @@ export async function refreshRun(runId, appClient = null) {
447
712
  }
448
713
 
449
714
  async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
450
- const state = await loadRun(runId);
451
- if (!state) return null;
452
- state.runner = state.runner || RUNNER;
453
- await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
454
- await recoverCompletedPlanner(state);
455
- for (const task of state.tasks || []) await refreshTask(state, task);
456
- await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
457
- await recoverCompletedJudge(state);
458
- aggregateRunTmuxMetadata(state);
459
- recomputeRunStatus(state);
460
- await scheduleMoreWorkers(state);
461
- recomputeRunStatus(state);
462
- if (appClient && !light) await enrichFromAppServer(state, appClient).catch(e => { state.appServerError = e.message; });
463
- await saveRun(state);
464
- return state;
715
+ return await withRunStateLock(runId, async () => {
716
+ const state = await loadRun(runId);
717
+ if (!state) return null;
718
+ state.runner = state.runner || RUNNER;
719
+ await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
720
+ await recoverCompletedPlanner(state);
721
+ for (const task of state.tasks || []) await refreshTask(state, task);
722
+ await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
723
+ await recoverCompletedJudge(state);
724
+ aggregateRunTmuxMetadata(state);
725
+ recomputeRunStatus(state);
726
+ await scheduleMoreWorkers(state);
727
+ recomputeRunStatus(state);
728
+ if (appClient && !light) await enrichFromAppServer(state, appClient).catch(e => { state.appServerError = e.message; });
729
+ await saveRun(state);
730
+ return state;
731
+ });
465
732
  }
466
733
 
467
734
  async function recoverCompletedPlanner(state) {
@@ -495,9 +762,16 @@ async function refreshRole(state, roleState, dir) {
495
762
  if (exit !== '') {
496
763
  roleState.exitCode = Number(exit.trim());
497
764
  if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
498
- if (roleState.status === 'running') roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
765
+ clearMissingRunner(roleState);
766
+ if (['running', 'unknown'].includes(roleState.status)) roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
499
767
  }
500
- else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
768
+ else if (['running', 'unknown'].includes(roleState.status) && hasLiveRunnerProcess(state, key, roleState)) {
769
+ roleState.status = 'running';
770
+ clearMissingRunner(roleState);
771
+ }
772
+ else if (roleState.status === 'running' && !hasLiveRunnerProcess(state, key, roleState)) {
773
+ if (shouldMarkRunnerUnknown(roleState)) roleState.status = 'unknown';
774
+ } else if (roleState.status === 'running') clearMissingRunner(roleState);
501
775
  roleState.files = await standardFiles(dir);
502
776
  await attachTmuxMetadata(roleState, dir);
503
777
  }
@@ -510,8 +784,14 @@ async function refreshTask(state, task) {
510
784
  if (exit !== '') {
511
785
  task.exitCode = Number(exit.trim());
512
786
  if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
513
- if (task.status === 'running') task.status = task.exitCode === 0 ? 'completed' : 'failed';
514
- } else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
787
+ clearMissingRunner(task);
788
+ if (['pending', 'running', 'unknown'].includes(task.status)) task.status = task.exitCode === 0 ? 'completed' : 'failed';
789
+ } else if (['running', 'unknown'].includes(task.status) && hasLiveRunnerProcess(state, task.id, task)) {
790
+ task.status = 'running';
791
+ clearMissingRunner(task);
792
+ } else if (task.status === 'running' && !hasLiveRunnerProcess(state, task.id, task)) {
793
+ if (shouldMarkRunnerUnknown(task)) task.status = 'unknown';
794
+ } else if (task.status === 'running') clearMissingRunner(task);
515
795
  task.files = await standardFiles(dir);
516
796
  await attachTmuxMetadata(task, dir);
517
797
  delete task.attentionHint;
@@ -610,6 +890,43 @@ function currentBatch(state) {
610
890
  return (state.batches || []).find(b => b.status !== 'completed');
611
891
  }
612
892
 
893
+ function currentBlockedBatch(state) {
894
+ ensureBatchShape(state);
895
+ return (state.batches || []).find(b => b.status === 'failed' || b.status === 'blocked' || b.status === 'running' && (b.tasks || []).some(t => ['failed', 'unknown'].includes(t.status)));
896
+ }
897
+
898
+ function canAutoRetryTask(task, maxRetries = 1) {
899
+ if (!task) return false;
900
+ if (!['failed', 'unknown'].includes(task.status)) return false;
901
+ if (Number(task.retryCount || 0) >= Number(maxRetries || 1)) return false;
902
+ return true;
903
+ }
904
+
905
+ async function retryTasksInState(state, taskIds = null, { auto = false, maxRetries = 1, reason = 'retry' } = {}) {
906
+ ensureBatchShape(state);
907
+ const selectedTaskIds = taskIds ? new Set(taskIds.map(id => safeIdPart(id))) : null;
908
+ const tasksToRetry = (state.tasks || []).filter(task => {
909
+ if (selectedTaskIds && !selectedTaskIds.has(task.id)) return false;
910
+ if (!['failed', 'unknown'].includes(task.status)) return false;
911
+ if (auto && !canAutoRetryTask(task, maxRetries)) return false;
912
+ return true;
913
+ });
914
+ if (!tasksToRetry.length) return { retried: [], state };
915
+ for (const task of tasksToRetry) {
916
+ if (hasLiveRunnerProcess(state, task.id, task)) throw new Error(`task still has a live process: ${task.id}`);
917
+ const batch = (state.batches || []).find(item => item.id === task.batchId);
918
+ task.retryReason = reason;
919
+ await rotateWorkerAttempt(state, task);
920
+ const batchTask = batch?.tasks?.find(item => item.id === task.id);
921
+ if (batchTask && batchTask !== task) Object.assign(batchTask, task);
922
+ }
923
+ recomputeRunStatus(state);
924
+ if (hasPendingRunnableBatch(state)) state.status = 'running';
925
+ await scheduleMoreWorkers(state);
926
+ recomputeRunStatus(state);
927
+ return { retried: tasksToRetry.map(task => task.id), state };
928
+ }
929
+
613
930
  async function scheduleMoreWorkers(state) {
614
931
  if (state.status !== 'running') return;
615
932
  const batch = currentBatch(state);
@@ -770,7 +1087,7 @@ function runDurationEndOfState(s) {
770
1087
  return times.length ? new Date(Math.max(...times)).toISOString() : s.updatedAt;
771
1088
  }
772
1089
 
773
- function summaryOfRun(s) {
1090
+ export function summaryOfRun(s) {
774
1091
  const tasks = s.tasks || [];
775
1092
  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 })) };
776
1093
  }