@volter/supercode-orchestrator 0.4.17 → 0.4.19

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,12 +15,13 @@
15
15
  // subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
16
16
  // launch and a crash between the two steps is answered by the next tick.
17
17
  import { spawn as spawnChild, spawnSync } from 'node:child_process';
18
- import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
19
19
  import { hostname, tmpdir } from 'node:os';
20
20
  import { basename, dirname, join } from 'node:path';
21
21
  import { randomUUID } from 'node:crypto';
22
22
  import { fileURLToPath } from 'node:url';
23
- import { event, json, listBoards, now, openBoard, tx } from './store.mjs';
23
+ import { boardPath, event, json, listBoards, now, openBoard, tx } from './store.mjs';
24
+ import { holdBoardTickLock } from '../hermes-locks.mjs';
24
25
  import {
25
26
  cardContext, claimIn, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
26
27
  recomputeReady, recordFailure, release, setRunSession, subscribeIn, subscriptions,
@@ -106,6 +107,16 @@ export function boardSettings(kanban = {}) {
106
107
  grace: pos(kanban.session_grace_seconds) ?? 120,
107
108
  machines: Object.fromEntries(Object.entries(kanban.machines ?? {}).map(([name, m]) => [normalMachine(name), { capacity: pos(m?.capacity) }])),
108
109
  interval: Math.max(Number(kanban.dispatch_interval_seconds) || 60, 1),
110
+ staleTimeout: pos(kanban.dispatch_stale_timeout_seconds),
111
+ autoDecompose: kanban.auto_decompose !== false,
112
+ autoDecomposePerTick: pos(kanban.auto_decompose_per_tick) ?? 3,
113
+ goalMaxTurns: pos(kanban.goal_max_turns) ?? 20,
114
+ maxSpawn: pos(kanban.max_spawn),
115
+ reconcileOrphans: kanban.reconcile_orphans !== false,
116
+ logRotateBytes: pos(kanban.worker_log_rotate_bytes) ?? 2 * 1024 * 1024,
117
+ logBackups: Number.isInteger(Number(kanban.worker_log_backup_count)) && Number(kanban.worker_log_backup_count) >= 0 ? Number(kanban.worker_log_backup_count) : 1,
118
+ hooks: kanban.hooks && typeof kanban.hooks === 'object' ? kanban.hooks : {},
119
+ rateLimitCooldown: Number.isInteger(Number(process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS)) && process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS !== '' ? Number(process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS) : 300,
109
120
  };
110
121
  }
111
122
 
@@ -132,6 +143,14 @@ function supervise(board, fleet, settings, report) {
132
143
  const runRow = currentRun(db, id);
133
144
  const session = runRow?.metadata?.supercode ?? null;
134
145
  const t = now();
146
+ // Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
147
+ const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
148
+ if (settings.reconcileOrphans && (!task.claim_lock || !task.claim_expires) && !live) {
149
+ release(db, id, { outcome: 'reclaimed', error: 'orphaned running card (broken claim bookkeeping)', kind: 'reconciled', payload: { reason: 'orphaned_running', claim_lock: task.claim_lock, claim_expires: task.claim_expires } });
150
+ db.prepare('INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)').run(id, 'dispatcher', 'Requeued: the card was running with no claim and nothing live behind it.', t);
151
+ report.reclaimed.push(id);
152
+ return;
153
+ }
135
154
  // a card past its max runtime is stopped and counted, as Hermes times its workers out
136
155
  if (task.max_runtime_seconds && runRow && t - Number(runRow.started_at) > Number(task.max_runtime_seconds)) {
137
156
  stop.push(session);
@@ -141,13 +160,32 @@ function supervise(board, fleet, settings, report) {
141
160
  return;
142
161
  }
143
162
  if (session?.surface === 'headless') {
163
+ // Hermes's stale check: a worker running past the timeout with no heartbeat in the last hour is stopped
164
+ if (settings.staleTimeout && runRow && t - Number(runRow.started_at) >= settings.staleTimeout && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
165
+ stop.push(session);
166
+ release(db, id, { outcome: 'stale', error: `no heartbeat after ${t - Number(runRow.started_at)}s running`, kind: 'stale', payload: { timeout_seconds: settings.staleTimeout, pid: session.pid } });
167
+ report.reclaimed.push(id);
168
+ return;
169
+ }
144
170
  if (pidAlive(session.pid)) {
145
171
  if (Number(task.claim_expires) <= t + 60) extendClaim(db, id, { ttl: settings.ttl, reason: 'pid_alive', extra: { worker_pid: session.pid } });
146
172
  return;
147
173
  }
148
174
  if (t - Number(session.launched_at ?? 0) < 30) return;
149
- release(db, id, { outcome: 'crashed', error: `worker ${session.pid} exited without completing the card`, kind: 'crashed', payload: { worker_pid: session.pid } });
150
- if (recordFailure(db, id, `worker ${session.pid} exited without completing the card`, { failureLimit: settings.failureLimit })) report.auto_blocked.push(id);
175
+ // how the worker ended, from the exit code its wrapper wrote (Hermes reads its reaped children's)
176
+ const code = session.exit_file && existsSync(session.exit_file) ? Number(readFileSync(session.exit_file, 'utf8').trim()) : null;
177
+ if (code === 75) {
178
+ // a quota wall: back to ready with no failure counted; the respawn guard waits out the cooldown
179
+ release(db, id, { outcome: 'rate_limited', error: 'worker hit a rate limit (exit 75)', kind: 'rate_limited', payload: { worker_pid: session.pid, exit_code: 75 } });
180
+ report.crashed.push(id);
181
+ return;
182
+ }
183
+ const clean = code === 0;
184
+ const why = clean ? `worker ${session.pid} exited cleanly (rc=0) without completing or blocking the card` : `worker ${session.pid} exited${code === null ? '' : ` with ${code}`} without completing the card`;
185
+ release(db, id, { outcome: 'crashed', error: why, kind: 'crashed', payload: { worker_pid: session.pid, exit_code: code, protocol_violation: clean } });
186
+ // a clean exit that left the card running is a protocol violation: Hermes trips the breaker on the first
187
+ if (clean) db.prepare('UPDATE tasks SET consecutive_failures = ? WHERE id = ?').run(Math.max(0, (task.max_retries ?? settings.failureLimit) - 1), id);
188
+ if (recordFailure(db, id, why, { failureLimit: settings.failureLimit })) report.auto_blocked.push(id);
151
189
  report.crashed.push(id);
152
190
  return;
153
191
  }
@@ -190,7 +228,14 @@ function supervise(board, fleet, settings, report) {
190
228
 
191
229
  function stopSession(session) {
192
230
  if (!session) return;
193
- if (session.surface === 'headless' && pidAlive(session.pid)) { try { process.kill(session.pid, 'SIGTERM'); } catch { /* gone */ } return; }
231
+ if (session.surface === 'headless' && pidAlive(session.pid)) {
232
+ // SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
233
+ const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
234
+ signal('SIGTERM');
235
+ for (let waited = 0; waited < 10 && pidAlive(session.pid); waited += 1) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
236
+ if (pidAlive(session.pid)) signal('SIGKILL');
237
+ return;
238
+ }
194
239
  if (session.pane) sc(['teams', 'panes', 'close', session.pane, ...(session.machine ? ['--machine', session.machine] : [])], { timeout: 60_000 });
195
240
  }
196
241
 
@@ -212,8 +257,8 @@ function machineLoad(db, machine, fleet) {
212
257
  }
213
258
 
214
259
  /** Why a card cannot start now, or null. */
215
- function guard(db, task, lane, fleet, settings) {
216
- if (settings.maxInProgress && running(db, '') >= settings.maxInProgress) return `the board runs ${settings.maxInProgress} cards at most (kanban.max_in_progress)`;
260
+ function guard(db, task, lane, fleet, settings, budget = null) {
261
+ if (budget && budget.left <= 0) return budget.why;
217
262
  const cap = lane.capacity ?? settings.perProfile;
218
263
  if (cap && running(db, 'AND t.assignee = ?', lane.name) >= cap) return `${lane.name} is at capacity (${cap}); the card starts when one of its sessions ends`;
219
264
  if (lane.surface !== 'pane') return null;
@@ -223,11 +268,45 @@ function guard(db, task, lane, fleet, settings) {
223
268
  if (mcap && machineLoad(db, machine, fleet) >= mcap) return `machine ${machine} holds ${mcap} of the board's sessions (its capacity); the card starts when one is closed`;
224
269
  if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
225
270
  if (!fleet.online.has(machine)) return `machine ${machine} is offline; the card starts when it is back`;
226
- if (task.workspace_kind !== 'dir' || !task.workspace_path) return `a pane session starts in a directory on ${machine}: the card's workspace is dir:<absolute path>, not ${task.workspace_kind}`;
271
+ // a card on this machine gets its workspace made here (scratch, dir or worktree, as Hermes makes it); another
272
+ // machine's card names its directory there
273
+ if (machine !== localMachine() && (task.workspace_kind !== 'dir' || !task.workspace_path)) return `a pane session on ${machine} starts in a directory there: the card's workspace is dir:<absolute path>, not ${task.workspace_kind}`;
227
274
  if (lane.harness !== 'claude-code') return `a pane session's id is chosen at launch, which ${lane.harness} does not take; the lane's worker is claude-code`;
228
275
  return null;
229
276
  }
230
277
 
278
+ // Hermes's respawn guard, for a one-shot (headless) worker: no respawn while the last failure is an auth or quota
279
+ // wall, after a completion nobody requeued, or while a pull request is open on the card. A pane session is resumed
280
+ // rather than respawned, so it never runs twice and these do not apply.
281
+ const BLOCKER = /\b(quota|rate[\s_-]?limit|429|403|auth\w*|unauthorized|forbidden|billing|subscription|access[\s_]denied|permission[\s_]denied|invalid[\s_]api[\s_]key)\b/i;
282
+ const PR_URL = /https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i;
283
+
284
+ function respawnGuard(db, task, settings, lane) {
285
+ const t = now();
286
+ const last = db.prepare('SELECT outcome, ended_at FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT 1').get(task.id);
287
+ if (last?.outcome === 'rate_limited') return settings.rateLimitCooldown > 0 && t - Number(last.ended_at) < settings.rateLimitCooldown ? 'rate_limit_cooldown' : null;
288
+ if (task.last_failure_error && BLOCKER.test(task.last_failure_error)) return 'blocker_auth';
289
+ if (lane === 'review') return null;
290
+ const done = db.prepare("SELECT ended_at FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ? ORDER BY ended_at DESC LIMIT 1").get(task.id, t - 3600);
291
+ if (done && !db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND created_at >= ? AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') LIMIT 1").get(task.id, done.ended_at)) return 'recent_success';
292
+ for (const c of db.prepare('SELECT body FROM task_comments WHERE task_id = ? AND created_at >= ?').all(task.id, t - 86400)) if (PR_URL.test(c.body ?? '')) return 'active_pr';
293
+ return null;
294
+ }
295
+
296
+ /** This machine's memory pressure as Hermes reads it (Linux's MemAvailable; `unknown` elsewhere, which restricts nothing). */
297
+ function memoryPressure() {
298
+ try {
299
+ const info = Object.fromEntries(readFileSync('/proc/meminfo', 'utf8').split('\n').map((l) => /^(\w+):\s+(\d+)/.exec(l)).filter(Boolean).map((m) => [m[1], Number(m[2])]));
300
+ const avail = info.MemAvailable;
301
+ const total = info.MemTotal;
302
+ if (!Number.isFinite(avail)) return 'unknown';
303
+ const fraction = total > 0 ? avail / total : null;
304
+ if (avail < 64 * 1024 || (fraction !== null && fraction < 0.05)) return 'critical';
305
+ if (avail < 128 * 1024 || (fraction !== null && fraction < 0.15)) return 'elevated';
306
+ return 'ok';
307
+ } catch { return 'unknown'; }
308
+ }
309
+
231
310
  /** Record a guard reason once per change, not once per tick. */
232
311
  function noteGuard(db, id, reason) {
233
312
  const last = db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('respawn_guarded', 'claimed') ORDER BY id DESC LIMIT 1").get(id);
@@ -235,10 +314,39 @@ function noteGuard(db, id, reason) {
235
314
  event(db, id, 'respawn_guarded', { reason });
236
315
  }
237
316
 
238
- function start(board, fleet, lanes, settings, report, { only = null } = {}) {
239
- const lanesWanted = settings.reviewDispatch ? "('ready', 'review')" : "('ready')";
240
- const rows = board.db.prepare(`SELECT id, status, assignee FROM tasks WHERE status IN ${lanesWanted} AND claim_lock IS NULL ORDER BY priority DESC, created_at ASC`).all();
241
- for (const row of rows) {
317
+ /** Hermes's emergency stop: the `ESTOP` sentinel in the home or in `~/.hermes` stops new starts (running ones go on). */
318
+ export function paused(root) {
319
+ return [join(root, 'ESTOP'), join(process.env.HOME ?? '', '.hermes', 'ESTOP')].find((p) => existsSync(p)) ?? null;
320
+ }
321
+
322
+ /**
323
+ * The tick's spawn budget, as Hermes counts it: `kanban.max_spawn` against this board's running cards, and
324
+ * `kanban.max_in_progress` against every board of the home (workers share one machine's memory).
325
+ */
326
+ function spawnBudget(board, settings, hostRunning) {
327
+ const here = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE status = 'running'").get().n);
328
+ const limits = [];
329
+ if (settings.maxSpawn) limits.push([settings.maxSpawn - here, `the board runs ${settings.maxSpawn} cards at most (kanban.max_spawn)`]);
330
+ if (settings.maxInProgress) limits.push([settings.maxInProgress - hostRunning, `the home runs ${settings.maxInProgress} cards at most across its boards (kanban.max_in_progress)`]);
331
+ if (!limits.length) return null;
332
+ const [left, why] = limits.sort((a, b) => a[0] - b[0])[0];
333
+ return { left, why };
334
+ }
335
+
336
+ function start(board, fleet, lanes, settings, report, { only = null, hostRunning = 0 } = {}) {
337
+ const stop = paused(board.root);
338
+ if (stop) { report.paused = stop; return; }
339
+ const pressure = memoryPressure();
340
+ let headlessStarted = 0;
341
+ const order = 'ORDER BY priority DESC, created_at ASC';
342
+ const ready = board.db.prepare(`SELECT id, status, assignee FROM tasks WHERE status = 'ready' AND claim_lock IS NULL ${order}`).all();
343
+ const review = settings.reviewDispatch ? board.db.prepare(`SELECT id, status, assignee FROM tasks WHERE status = 'review' AND claim_lock IS NULL ${order}`).all() : [];
344
+ const budget = spawnBudget(board, settings, hostRunning);
345
+ // Hermes's review reservation: while review work waits, one slot of the budget is held back from the ready lane
346
+ const reviewWaits = review.some((r) => lanes[r.assignee || settings.defaultAssignee]);
347
+ const readyBudget = budget && budget.left > 0 && reviewWaits ? { left: budget.left - 1, why: `${budget.why}; one slot is held for review` } : budget;
348
+ for (const row of [...ready, ...review]) {
349
+ const lanesBudget = row.status === 'review' ? budget : readyBudget;
242
350
  if (only && row.id !== only) continue;
243
351
  if (board.db.prepare('SELECT subtask_of FROM supercode_cards WHERE task_id = ?').get(row.id)?.subtask_of) continue; // worked by its arc's session
244
352
  const assignee = row.assignee || settings.defaultAssignee;
@@ -248,7 +356,12 @@ function start(board, fleet, lanes, settings, report, { only = null } = {}) {
248
356
  const claimed = tx(board, (db) => {
249
357
  const task = getTask(db, row.id);
250
358
  if (!task || task.status !== row.status || task.claim_lock) return null;
251
- const why = guard(db, task, lane, fleet, settings);
359
+ let why = guard(db, task, lane, fleet, settings, lanesBudget);
360
+ if (!why && lane.surface !== 'pane') {
361
+ // a one-shot worker runs on this machine: its memory, and Hermes's respawn guard
362
+ if (pressure === 'critical' || (pressure === 'elevated' && headlessStarted >= 1)) why = `this machine's memory pressure is ${pressure}; the card starts when it eases`;
363
+ else why = respawnGuard(db, task, settings, row.status);
364
+ }
252
365
  if (why) { noteGuard(db, row.id, why); report.queued.push([row.id, why]); return null; }
253
366
  if (!row.assignee) {
254
367
  db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, row.id);
@@ -259,7 +372,12 @@ function start(board, fleet, lanes, settings, report, { only = null } = {}) {
259
372
  });
260
373
  if (!claimed) continue;
261
374
  try {
262
- const session = lane.surface === 'pane' ? launchPane(board, claimed.task, claimed.runId, lane, fleet) : launchHeadless(board, claimed.task, claimed.runId, lane, settings);
375
+ const session = lane.surface === 'pane'
376
+ ? launchPane(board, claimed.task, claimed.runId, lane, fleet, { input: row.status === 'review' ? reviewAsk(board, claimed.task) : null })
377
+ : launchHeadless(board, claimed.task, claimed.runId, lane, settings);
378
+ if (lane.surface !== 'pane') headlessStarted += 1;
379
+ if (budget) budget.left -= 1;
380
+ if (readyBudget && readyBudget !== budget) readyBudget.left -= 1;
263
381
  report.started.push([row.id, session.address ?? `pid ${session.pid}`, session.mode]);
264
382
  } catch (error) {
265
383
  tx(board, (db) => {
@@ -271,6 +389,50 @@ function start(board, fleet, lanes, settings, report, { only = null } = {}) {
271
389
  }
272
390
  }
273
391
 
392
+ /** What a session started from the review lane is asked to do: verify the handoff and give the verdict. */
393
+ function reviewAsk(board, task) {
394
+ const local = normalMachine(task.machine) === localMachine();
395
+ const where = local ? `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}` : `--at ${boardAddress(board.label)}`;
396
+ return `You are reviewing card ${task.id} on the ${board.label} board: its implementer asked for review. Verify the handoff against the card's acceptance, `
397
+ + `then give the verdict: \`supercode workflow complete ${task.id} --summary "<what you verified>" ${where}\` approves it, and `
398
+ + `\`supercode workflow request-changes ${task.id} "<what must change>" ${where}\` sends it back.\n\n${tx(board, (db) => cardContext(db, task.id))}`;
399
+ }
400
+
401
+ /** What a session started on a triage card is asked to do: write its spec, or split it into cards it waits on. */
402
+ function triageAsk(board, task, label, purpose) {
403
+ const local = normalMachine(task.machine) === localMachine();
404
+ const where = local ? `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}` : `--at ${boardAddress(label)}`;
405
+ const context = tx(board, (db) => cardContext(db, task.id));
406
+ return purpose === 'decompose'
407
+ ? `Card ${task.id} on the ${label} board is in triage: a rough idea to split. Read it, then file the cards it breaks into and make it wait on them: `
408
+ + `\`supercode workflow decompose ${task.id} --child "<title>[@<profile>]" --child "..." ${where}\` (each child gets this card's machine and directory). `
409
+ + `The card itself stays and wakes when they are done.\n\n${context}`
410
+ : `Card ${task.id} on the ${label} board is in triage: a rough idea. Write its spec (the goal, the approach, what will show it is done), then file it: `
411
+ + `\`supercode workflow specify ${task.id} --title "<a tightened title>" --body "<the spec>" ${where}\`. The card then waits for its parents and starts.\n\n${context}`;
412
+ }
413
+
414
+ /**
415
+ * Start a session on a triage card to specify or decompose it (where Hermes asks an auxiliary model, supercode asks a
416
+ * session in the card's lane, which then keeps the card's memory when the card starts). Answers the session.
417
+ */
418
+ export function startTriageSession(board, id, lanes, settings, purpose) {
419
+ const fleet = observeFleet();
420
+ const task = tx(board, (db) => getTask(db, id));
421
+ if (!task) throw new Error(`no card ${id}`);
422
+ if (task.status !== 'triage') throw new Error(`${id} is ${task.status}; only a triage card is ${purpose === 'decompose' ? 'decomposed' : 'specified'}`);
423
+ const lane = lanes[task.assignee || settings.defaultAssignee];
424
+ if (!lane || lane.surface !== 'pane') throw new Error(`${id} has no pane lane to start a session in; give its spec directly (${purpose === 'decompose' ? '--child' : '--title/--body'})`);
425
+ const runId = tx(board, (db) => {
426
+ const why = guard(db, task, lane, fleet, settings);
427
+ if (why) throw new Error(`${id} cannot start a session now: ${why}`);
428
+ return claimIn(db, id, { ttl: settings.ttl, expectStatus: 'triage' });
429
+ });
430
+ if (!runId) throw new Error(`${id} could not be claimed`);
431
+ return launchPane(board, getTaskNow(board, id), runId, lane, fleet, { input: triageAsk(board, task, board.label, purpose) });
432
+ }
433
+
434
+ const getTaskNow = (board, id) => tx(board, (db) => getTask(db, id));
435
+
274
436
  function opening(board, task, label) {
275
437
  const context = tx(board, (db) => cardContext(db, task.id));
276
438
  const skills = task.skills?.length ? `\nSkills pinned to this card: ${task.skills.join(', ')}.` : '';
@@ -279,14 +441,19 @@ function opening(board, task, label) {
279
441
  const local = normalMachine(task.machine) === localMachine();
280
442
  const where = local ? at : `--at ${boardAddress(label)}`;
281
443
  const door = `\nYour card is yours to keep current: \`supercode workflow comment ${task.id} "<text>" ${where}\`, \`block ${task.id} "<why>" --kind <needs_input|capability|transient|dependency> ${where}\` `
282
- + `and \`review ${task.id} --summary "<what>" ${where}\` update it; \`create "<title>" --subtask-of ${task.id} ${where}\` files a subtask, and \`complete <subtask> ${where}\` marks one done`
444
+ + `and \`request-review ${task.id} --summary "<what>" ${where}\` update it; \`create "<title>" --subtask-of ${task.id} ${where}\` files a subtask, and \`complete <subtask> ${where}\` marks one done`
283
445
  + `${local ? '' : ' (each is filed with the board and answered by a board notice)'}. Its reviewers, not you, mark the card itself done.`;
284
446
  return `You are the session working card ${task.id} on the ${label} board. The board started you; updates to the card `
285
447
  + `(comments, reassignment, blocks, completion) and to the cards it waits on reach you as board notices.${door}${skills}\n\n${context}`;
286
448
  }
287
449
 
288
- function launchPane(board, task, runId, lane, fleet) {
450
+ function launchPane(board, task, runId, lane, fleet, { input = null } = {}) {
289
451
  const machine = normalMachine(task.machine);
452
+ if (machine === localMachine() && !(task.workspace_kind === 'dir' && task.workspace_path && existsSync(task.workspace_path))) {
453
+ const made = resolveLocalWorkspace(board, task);
454
+ tx(board, (db) => db.prepare('UPDATE tasks SET workspace_path = ? WHERE id = ?').run(made, task.id));
455
+ task = { ...task, workspace_path: made };
456
+ }
290
457
  const model = task.model_override ?? lane.model;
291
458
  const tail = model ? ['--model', model] : [];
292
459
  const base = ['open', '--new', 'claude', '--detach', '--on', task.machine, '--cwd', task.workspace_path];
@@ -309,7 +476,7 @@ function launchPane(board, task, runId, lane, fleet) {
309
476
  } else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
310
477
  if (!session) {
311
478
  const sid = randomUUID();
312
- const r = sc([...base, '--key', `board-${task.id}-r${runId}-${sid.slice(0, 8)}`, '--input', opening(board, task, board.label), '--', '--session-id', sid, ...tail], { timeout: 180_000 });
479
+ const r = sc([...base, '--key', `board-${task.id}-r${runId}-${sid.slice(0, 8)}`, '--input', input ?? opening(board, task, board.label), '--', '--session-id', sid, ...tail], { timeout: 180_000 });
313
480
  if (r.code !== 0) throw new Error(`supercode open --on ${task.machine}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
314
481
  session = { surface: 'pane', session_id: sid, machine, pane: lastJson(r.stdout)?.pane ?? null, mode: 'fresh', launched_at: now(), ...(replaced ? { replaces: replaced.session_id } : {}) };
315
482
  }
@@ -342,15 +509,23 @@ function paneSurvives(machine, pane) {
342
509
  /** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
343
510
  function launchHeadless(board, task, runId, lane, settings) {
344
511
  const workspace = resolveLocalWorkspace(board, task);
345
- const args = ['-p', lane.name, '--cli', '--accept-hooks', ...task.skills.flatMap((s) => ['--skills', s]),
512
+ const reviewing = tx(board, (db) => json(db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND run_id = ? AND kind = 'claimed'").get(task.id, runId)?.payload, {})?.source_status === 'review');
513
+ const skills = reviewing ? [...new Set([...task.skills, 'sdlc-review'])] : task.skills;
514
+ const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
346
515
  ...(task.model_override ? ['-m', task.model_override, ...(task.provider_override ? ['--provider', task.provider_override] : [])] : []),
347
516
  ...(task.reasoning_effort ? ['--reasoning', task.reasoning_effort] : []), 'chat', '-q', `work kanban task ${task.id}`];
348
517
  const logDir = join(dirname(board.path), board.slug === 'default' ? 'kanban' : '', 'logs');
349
518
  mkdirSync(logDir, { recursive: true });
350
- const out = openSync(join(logDir, `${task.id}.log`), 'a');
351
- const child = spawnChild(process.execPath, [WORKER, ...args], {
519
+ const logFile = join(logDir, `${task.id}.log`);
520
+ rotateLog(logFile, settings.logRotateBytes, settings.logBackups);
521
+ const out = openSync(logFile, 'a');
522
+ const exitFile = join(logDir, `${task.id}.r${runId}.exit`);
523
+ // the worker runs under a shell that records its exit code, so a later tick can tell a clean exit from a crash
524
+ const child = spawnChild('/bin/sh', ['-c', '"$0" "$@"; echo $? > "$SUPERCODE_BOARD_EXIT_FILE"', process.execPath, WORKER, ...args], {
352
525
  cwd: workspace, detached: true, stdio: ['ignore', out, out],
353
526
  env: {
527
+ SUPERCODE_BOARD_EXIT_FILE: exitFile,
528
+ ...(task.goal_mode ? { HERMES_KANBAN_GOAL_MODE: '1', ...(task.goal_max_turns ? { HERMES_KANBAN_GOAL_MAX_TURNS: String(task.goal_max_turns) } : {}) } : {}),
354
529
  ...process.env, HERMES_HOME: lane.dir, HERMES_PROFILE: lane.name, HERMES_KANBAN_TASK: task.id, HERMES_KANBAN_WORKSPACE: workspace,
355
530
  HERMES_KANBAN_RUN_ID: String(runId), HERMES_KANBAN_DB: board.path, HERMES_KANBAN_BOARD: board.slug, HERMES_SESSION_SOURCE: 'kanban',
356
531
  HERMES_KANBAN_CLAIM_LOCK: `${hostname()}:${process.pid}`, ...(task.tenant ? { HERMES_TENANT: task.tenant } : {}),
@@ -358,7 +533,7 @@ function launchHeadless(board, task, runId, lane, settings) {
358
533
  },
359
534
  });
360
535
  child.unref();
361
- const session = { surface: 'headless', pid: child.pid, machine: normalMachine(hostname()), mode: 'fresh', launched_at: now() };
536
+ const session = { surface: 'headless', pid: child.pid, machine: localMachine(), mode: 'fresh', launched_at: now(), exit_file: exitFile };
362
537
  tx(board, (db) => {
363
538
  db.prepare('UPDATE tasks SET worker_pid = ? WHERE id = ?').run(child.pid, task.id);
364
539
  db.prepare('UPDATE task_runs SET worker_pid = ? WHERE id = ?').run(child.pid, runId);
@@ -368,6 +543,16 @@ function launchHeadless(board, task, runId, lane, settings) {
368
543
  return session;
369
544
  }
370
545
 
546
+ /** Hermes's worker log rotation: past `max` bytes the log moves to `.1` (… `.n`), the oldest dropped. */
547
+ function rotateLog(file, max, backups) {
548
+ try {
549
+ if (!existsSync(file) || statSync(file).size <= max) return;
550
+ if (backups === 0) { unlinkSync(file); return; }
551
+ for (let n = backups - 1; n >= 1; n -= 1) if (existsSync(`${file}.${n}`)) renameSync(`${file}.${n}`, `${file}.${n + 1}`);
552
+ renameSync(file, `${file}.1`);
553
+ } catch { /* a log that cannot rotate keeps growing */ }
554
+ }
555
+
371
556
  /** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
372
557
  function resolveLocalWorkspace(board, task) {
373
558
  const root = board.root;
@@ -398,7 +583,7 @@ function resolveLocalWorkspace(board, task) {
398
583
  * never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
399
584
  * its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
400
585
  */
401
- export function attachSession(board, id, who, { workspace = null, ttl } = {}) {
586
+ export function adoptSession(board, id, who, { workspace = null, ttl } = {}) {
402
587
  const fleet = observeFleet();
403
588
  if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
404
589
  const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
@@ -483,6 +668,9 @@ function send(from, label, to, body, key) {
483
668
  /** The board's own address: where its notices come from and where card updates are mailed. */
484
669
  export const boardAddress = (label) => `sc:${localMachine()}:board:${label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
485
670
 
671
+ /** Consecutive failed sends after which a subscription is dropped (Hermes's notifier drops a dead chat after 12). */
672
+ const MAX_SEND_FAILURES = 12;
673
+
486
674
  function notify(board, fleet, report) {
487
675
  const from = boardAddress(board.label);
488
676
  for (const sub of subscriptions(board.db)) {
@@ -499,6 +687,7 @@ function notify(board, fleet, report) {
499
687
  if (!events.length) continue;
500
688
  const task = tx(board, (db) => getTask(db, sub.task_id));
501
689
  let cursor = Number(sub.last_event_id);
690
+ let failed = null;
502
691
  try {
503
692
  for (const ev of events) {
504
693
  const p = json(ev.payload, {}) ?? {};
@@ -515,20 +704,139 @@ function notify(board, fleet, report) {
515
704
  cursor = ev.id;
516
705
  }
517
706
  } catch (error) {
518
- report.undelivered.push([sub.task_id, sub.chat_id, String(error.message).slice(-200)]);
707
+ failed = String(error.message).slice(-200);
708
+ report.undelivered.push([sub.task_id, sub.chat_id, failed]);
519
709
  }
520
710
  // the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
521
711
  tx(board, (db) => {
522
- db.prepare("UPDATE kanban_notify_subs SET last_event_id = ? WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND last_event_id < ?").run(cursor, sub.task_id, sub.chat_id, cursor);
523
- if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id);
712
+ const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?";
713
+ db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, sub.task_id, sub.chat_id, cursor);
714
+ const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
715
+ if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id); return; }
716
+ if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), sub.task_id, sub.chat_id);
717
+ if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id);
524
718
  });
525
719
  }
526
720
  }
527
721
 
722
+ // Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
723
+ const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', 'status', 'archived', 'unblocked', 'block_loop_detected', 'review_requested', 'changes_requested'];
724
+
725
+ /**
726
+ * A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
727
+ * `serve` hosts none, as a Hermes CLI without its gateway delivers none). Answers what was sent and what failed.
728
+ */
729
+ export async function notifyChats(root, adapters, log = () => {}) {
730
+ const sent = [];
731
+ for (const slug of listBoards(root)) {
732
+ const board = openBoard(root, slug);
733
+ board.label = boardLabel(root, slug);
734
+ try {
735
+ for (const sub of subscriptions(board.db)) {
736
+ const adapter = adapters?.[sub.platform];
737
+ if (sub.platform === 'supercode' || !adapter) continue;
738
+ const events = board.db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${CHAT_KINDS.map(() => '?').join(', ')}) ORDER BY id`).all(sub.task_id, sub.last_event_id, ...CHAT_KINDS);
739
+ if (!events.length) continue;
740
+ const task = tx(board, (db) => getTask(db, sub.task_id));
741
+ let cursor = Number(sub.last_event_id);
742
+ let failed = false;
743
+ for (const ev of events) {
744
+ const payload = json(ev.payload, {}) ?? {};
745
+ const text = ['archived', 'unblocked'].includes(ev.kind) ? null : notice(ev.kind, task, payload, 'creator');
746
+ if (text) {
747
+ // a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
748
+ const files = ev.kind === 'completed' ? artifactsOf(payload, task).map((path) => ({ path })) : [];
749
+ try {
750
+ await adapter.send({ kind: 'explicit', platform: sub.platform, chat_id: sub.chat_id, ...(sub.thread_id ? { thread_id: sub.thread_id } : {}) }, { text: `${slug !== 'default' ? `[${slug}] ` : ''}${text}`, ...(files.length ? { files } : {}) });
751
+ sent.push([sub.task_id, ev.kind, `${sub.platform}:${sub.chat_id}`]);
752
+ } catch (error) {
753
+ failed = true;
754
+ log('warn', `board ${slug}: a notice for ${sub.task_id} to ${sub.platform}:${sub.chat_id} failed: ${error.message}`);
755
+ break;
756
+ }
757
+ }
758
+ cursor = ev.id;
759
+ }
760
+ tx(board, (db) => {
761
+ const where = 'WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?';
762
+ const key = [sub.task_id, sub.platform, sub.chat_id, sub.thread_id ?? ''];
763
+ db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...key, cursor);
764
+ const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
765
+ if (failures >= MAX_SEND_FAILURES) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...key);
766
+ else if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...key);
767
+ if (task?.status === 'archived' && !failed) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...key);
768
+ });
769
+ }
770
+ } finally {
771
+ board.close();
772
+ }
773
+ }
774
+ return sent;
775
+ }
776
+
777
+ /** A completed card's artifact files: its completion's `artifacts`, then absolute paths its summary or result names that exist. */
778
+ function artifactsOf(payload, task) {
779
+ const seen = new Set();
780
+ const out = [];
781
+ const add = (p) => {
782
+ const path = String(p ?? '').replace(/^~(?=\/)/, process.env.HOME ?? '~');
783
+ if (!path.startsWith('/') || seen.has(path)) return;
784
+ try { if (!statSync(path).isFile()) return; } catch { return; }
785
+ seen.add(path);
786
+ out.push(path);
787
+ };
788
+ for (const p of Array.isArray(payload.artifacts) ? payload.artifacts : []) add(p);
789
+ for (const text of [payload.summary, task?.result]) for (const m of String(text ?? '').matchAll(/(?:^|\s)(~?\/[^\s'"`]+\.[A-Za-z0-9]{1,8})/g)) add(m[1]);
790
+ return out;
791
+ }
792
+
793
+ // ------------------------------------------------------------------ hooks
794
+
795
+ // Hermes's lifecycle hooks, from the board's own events (so a change any writer made fires them, Hermes's CLI's too)
796
+ const HOOK_OF = {
797
+ claimed: 'kanban_task_claimed', completed: 'kanban_task_completed', blocked: 'kanban_task_blocked',
798
+ spawned: 'on_kanban_worker_spawned', session_started: 'on_kanban_worker_spawned', session_resumed: 'on_kanban_worker_spawned', session_replaced: 'on_kanban_worker_spawned', session_adopted: 'on_kanban_worker_spawned',
799
+ crashed: 'on_kanban_worker_exited', rate_limited: 'on_kanban_worker_exited', session_lost: 'on_kanban_worker_exited', timed_out: 'on_kanban_worker_exited',
800
+ stale: 'on_kanban_worker_stale_claim', reclaimed: 'on_kanban_worker_stale_claim',
801
+ assigned: 'on_kanban_task_updated', edited: 'on_kanban_task_updated', model_override_set: 'on_kanban_task_updated', machine_set: 'on_kanban_task_updated',
802
+ linked: 'on_kanban_task_updated', unlinked: 'on_kanban_task_updated', unblocked: 'on_kanban_task_updated', promoted: 'on_kanban_task_updated',
803
+ archived: 'on_kanban_task_updated', review_requested: 'on_kanban_task_updated', changes_requested: 'on_kanban_task_updated',
804
+ };
805
+
806
+ /**
807
+ * `kanban.hooks.<hook>: <command>` (or a list): each command runs with the event as one JSON object on its stdin, and
808
+ * nothing waits for it. Hermes runs Python plugins at these points; supercode runs any program.
809
+ */
810
+ function fireHooks(board, settings, report) {
811
+ const hooks = settings.hooks ?? {};
812
+ if (!Object.keys(hooks).length) return;
813
+ const run = (hook, payload) => {
814
+ for (const command of [hooks[hook]].flat().filter((c) => typeof c === 'string' && c.trim())) {
815
+ const child = spawnChild('/bin/sh', ['-c', command], { detached: true, stdio: ['pipe', 'ignore', 'ignore'], cwd: board.root });
816
+ child.on('error', () => {});
817
+ child.stdin.on('error', () => {});
818
+ child.stdin.end(JSON.stringify({ hook, board: board.slug, ...payload }));
819
+ child.unref();
820
+ }
821
+ };
822
+ tx(board, (db) => {
823
+ db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
824
+ const max = Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS m FROM task_events').get().m);
825
+ const row = db.prepare("SELECT value FROM supercode_board_state WHERE key = 'hooks_cursor'").get();
826
+ const cursor = row ? Number(row.value) : max; // the first tick starts at the present, never replays history
827
+ for (const ev of db.prepare('SELECT e.id, e.task_id, e.run_id, e.kind, e.payload, e.created_at, t.assignee FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id WHERE e.id > ? ORDER BY e.id').all(cursor)) {
828
+ const hook = HOOK_OF[ev.kind];
829
+ if (hook && hooks[hook]) run(hook, { task_id: ev.task_id, run_id: ev.run_id, event: ev.kind, assignee: ev.assignee, payload: json(ev.payload, null), at: ev.created_at });
830
+ }
831
+ db.prepare("INSERT INTO supercode_board_state (key, value) VALUES ('hooks_cursor', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(max));
832
+ });
833
+ if (hooks.on_kanban_dispatch_tick) run('on_kanban_dispatch_tick', { started: report.started.length, reclaimed: report.reclaimed.length, crashed: report.crashed.length, timed_out: report.timed_out.length, promoted: report.promoted });
834
+ }
835
+
528
836
  // ------------------------------------------------------------------ the board's mailbox
529
837
 
530
838
  const ENTRY = fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url));
531
- const NOT_BY_MAIL = new Set(['serve', 'dispatch', 'init', 'attach']);
839
+ const NOT_BY_MAIL = new Set(['serve', 'daemon', 'dispatch', 'init', 'adopt', 'boards', 'gc', 'repair', 'watch', 'tail', 'attach']);
532
840
 
533
841
  /** The board's mailbox on this machine: the directory supercode files mail for `address` in, or null. */
534
842
  function mailboxOf(address) {
@@ -572,6 +880,62 @@ function readMailbox(board, address, report) {
572
880
  }
573
881
  }
574
882
 
883
+ // ------------------------------------------------------------------ goals and triage
884
+
885
+ /**
886
+ * Hermes's goal loop for a pane session: a goal card whose session sits idle while the card is still running is told
887
+ * to keep going, up to the card's turn budget; when the budget is spent the card is blocked for review. Whether the
888
+ * work is done is the session's call through its card (`request-review`), not an auxiliary judge's.
889
+ */
890
+ function pursueGoals(board, fleet, settings, report) {
891
+ if (!fleet.ok) return;
892
+ const cards = board.db.prepare("SELECT id FROM tasks WHERE status = 'running' AND goal_mode = 1").all();
893
+ for (const { id } of cards) {
894
+ const nudge = tx(board, (db) => {
895
+ const task = getTask(db, id);
896
+ const runRow = currentRun(db, id);
897
+ const session = runRow?.metadata?.supercode;
898
+ if (!session?.session_id || session.surface !== 'pane') return null;
899
+ const live = fleet.sessions.find((x) => x.address?.endsWith(`:${session.session_id}`));
900
+ if (live?.status !== 'idle') return null;
901
+ if (session.goal_nudged_at && now() - session.goal_nudged_at < settings.interval * 2) return null;
902
+ const turns = Number(session.goal_turns ?? 0);
903
+ const budget = task.goal_max_turns ?? settings.goalMaxTurns;
904
+ if (turns >= budget) {
905
+ db.prepare("UPDATE tasks SET status = 'blocked', claim_lock = NULL, claim_expires = NULL WHERE id = ?").run(id);
906
+ const ended = endRun(db, id, { outcome: 'blocked', summary: `goal turn budget (${budget}) spent` });
907
+ event(db, id, 'blocked', { reason: `goal turn budget (${budget}) spent; review it`, kind: 'needs_input', source_status: 'ready' }, ended);
908
+ return null;
909
+ }
910
+ setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now() });
911
+ event(db, id, 'goal_continued', { turn: turns + 1, budget }, runRow.id);
912
+ return { address: live.address, text: `Card ${id} (${task.title}) is not done yet: keep working toward it (goal turn ${turns + 1} of ${budget}). When it is done, \`request-review\` it; if you are stuck, \`block\` it with its kind.` };
913
+ });
914
+ if (nudge) {
915
+ try { send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${id}-${now()}`); report.notified.push([id, 'goal_continued', nudge.address]); }
916
+ catch (error) { report.undelivered.push([id, nudge.address, String(error.message).slice(-200)]); }
917
+ }
918
+ }
919
+ }
920
+
921
+ /** Hermes's auto-decompose: triage cards with a pane lane get a session that splits them, a few per tick. */
922
+ function autoDecompose(board, lanes, settings, report) {
923
+ if (!settings.autoDecompose) return;
924
+ let started = 0;
925
+ for (const { id, assignee } of board.db.prepare("SELECT id, assignee FROM tasks WHERE status = 'triage' ORDER BY priority DESC, created_at ASC").all()) {
926
+ if (started >= settings.autoDecomposePerTick) break;
927
+ const lane = lanes[assignee || settings.defaultAssignee];
928
+ if (lane?.surface !== 'pane') continue;
929
+ try {
930
+ const session = startTriageSession(board, id, lanes, settings, 'decompose');
931
+ report.started.push([id, session.address, 'decompose']);
932
+ started += 1;
933
+ } catch (error) {
934
+ report.queued.push([id, String(error.message).slice(-300)]);
935
+ }
936
+ }
937
+ }
938
+
575
939
  // ------------------------------------------------------------------ the tick
576
940
 
577
941
  /** The board's name in notices: the home's owner folder (`…/volter-manager/home` → `volter-manager`), and the slug. */
@@ -584,12 +948,32 @@ export function boardLabel(root, slug) {
584
948
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
585
949
  * `only` limits starting to one card (a create that answers whether its card started). Answers one report per board.
586
950
  */
587
- export function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
951
+ /** A board file's fingerprint: a corrupt board is skipped until the file changes (Hermes retries it then). */
952
+ const fingerprint = (path) => { try { const st = statSync(path); return `${st.size}:${st.mtimeMs}`; } catch { return 'missing'; } };
953
+ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
954
+
955
+ /**
956
+ * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
957
+ * `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
958
+ * per-tick lock (Hermes's `.dispatch.lock`), skipped when another dispatcher holds it. Answers one report per board.
959
+ */
960
+ export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
588
961
  const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
589
- const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), error: 'no pane lanes' };
962
+ const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
963
+ const boards = slugs ?? listBoards(root);
964
+ // the running cards of every board of the home, for kanban.max_in_progress (host-wide, as Hermes counts it)
965
+ let hostRunning = 0;
966
+ for (const slug of boards) {
967
+ try { const b = openBoard(root, slug); try { hostRunning += Number(b.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE status = 'running'").get().n); } finally { b.close(); } } catch { /* its own tick reports it */ }
968
+ }
590
969
  const out = [];
591
- for (const slug of slugs ?? listBoards(root)) {
970
+ for (const slug of boards) {
592
971
  const report = { board: slug, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
972
+ const path = boardPath(root, slug);
973
+ const marker = `${path}.corrupt-seen`;
974
+ if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
975
+ let lock = null;
976
+ try { lock = await holdBoardTickLock(path); } catch (error) { report.skipped_locked = error.message; out.push(report); continue; }
593
977
  let board;
594
978
  try {
595
979
  board = openBoard(root, slug);
@@ -597,15 +981,23 @@ export function tick({ root, lanes, settings, only = null, slugs = null, dispatc
597
981
  readMailbox(board, boardAddress(board.label), report);
598
982
  supervise(board, fleet, settings, report);
599
983
  report.promoted = recomputeReady(board, settings.failureLimit);
600
- if (dispatch) start(board, fleet, lanes, settings, report, { only });
984
+ const before = report.started.length;
985
+ if (dispatch) start(board, fleet, lanes, settings, report, { only, hostRunning });
986
+ hostRunning += report.started.length - before;
987
+ if (dispatch && !only && !report.paused) { pursueGoals(board, fleet, settings, report); autoDecompose(board, lanes, settings, report); }
601
988
  notify(board, fleet, report);
989
+ fireHooks(board, settings, report);
990
+ if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
991
+ report.ready_waiting = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'ready' AND t.assignee IS NOT NULL AND c.subtask_of IS NULL").get().n);
602
992
  // what a Hermes gateway serves around its dispatcher and this one does not: said by the caller
603
993
  report.chat_subscriptions = Number(board.db.prepare("SELECT COUNT(*) AS n FROM kanban_notify_subs WHERE platform != 'supercode'").get().n);
604
- report.triage = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE status = 'triage'").get().n);
994
+ if (existsSync(marker)) unlinkSync(marker);
605
995
  } catch (error) {
606
996
  report.error = error.message;
997
+ if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
607
998
  } finally {
608
999
  board?.close();
1000
+ lock.release();
609
1001
  }
610
1002
  out.push(report);
611
1003
  }