@volter/supercode-orchestrator 0.4.16 → 0.4.18

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,7 +15,7 @@
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, readFileSync, openSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync } 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';
@@ -106,6 +106,11 @@ export function boardSettings(kanban = {}) {
106
106
  grace: pos(kanban.session_grace_seconds) ?? 120,
107
107
  machines: Object.fromEntries(Object.entries(kanban.machines ?? {}).map(([name, m]) => [normalMachine(name), { capacity: pos(m?.capacity) }])),
108
108
  interval: Math.max(Number(kanban.dispatch_interval_seconds) || 60, 1),
109
+ staleTimeout: pos(kanban.dispatch_stale_timeout_seconds),
110
+ autoDecompose: kanban.auto_decompose !== false,
111
+ autoDecomposePerTick: pos(kanban.auto_decompose_per_tick) ?? 3,
112
+ goalMaxTurns: pos(kanban.goal_max_turns) ?? 20,
113
+ 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
114
  };
110
115
  }
111
116
 
@@ -141,6 +146,13 @@ function supervise(board, fleet, settings, report) {
141
146
  return;
142
147
  }
143
148
  if (session?.surface === 'headless') {
149
+ // Hermes's stale check: a worker running past the timeout with no heartbeat in the last hour is stopped
150
+ if (settings.staleTimeout && runRow && t - Number(runRow.started_at) >= settings.staleTimeout && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
151
+ stop.push(session);
152
+ 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 } });
153
+ report.reclaimed.push(id);
154
+ return;
155
+ }
144
156
  if (pidAlive(session.pid)) {
145
157
  if (Number(task.claim_expires) <= t + 60) extendClaim(db, id, { ttl: settings.ttl, reason: 'pid_alive', extra: { worker_pid: session.pid } });
146
158
  return;
@@ -228,6 +240,38 @@ function guard(db, task, lane, fleet, settings) {
228
240
  return null;
229
241
  }
230
242
 
243
+ // Hermes's respawn guard, for a one-shot (headless) worker: no respawn while the last failure is an auth or quota
244
+ // wall, after a completion nobody requeued, or while a pull request is open on the card. A pane session is resumed
245
+ // rather than respawned, so it never runs twice and these do not apply.
246
+ 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;
247
+ const PR_URL = /https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i;
248
+
249
+ function respawnGuard(db, task, settings, lane) {
250
+ const t = now();
251
+ 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);
252
+ if (last?.outcome === 'rate_limited') return settings.rateLimitCooldown > 0 && t - Number(last.ended_at) < settings.rateLimitCooldown ? 'rate_limit_cooldown' : null;
253
+ if (task.last_failure_error && BLOCKER.test(task.last_failure_error)) return 'blocker_auth';
254
+ if (lane === 'review') return null;
255
+ 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);
256
+ 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';
257
+ 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';
258
+ return null;
259
+ }
260
+
261
+ /** This machine's memory pressure as Hermes reads it (Linux's MemAvailable; `unknown` elsewhere, which restricts nothing). */
262
+ function memoryPressure() {
263
+ try {
264
+ 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])]));
265
+ const avail = info.MemAvailable;
266
+ const total = info.MemTotal;
267
+ if (!Number.isFinite(avail)) return 'unknown';
268
+ const fraction = total > 0 ? avail / total : null;
269
+ if (avail < 64 * 1024 || (fraction !== null && fraction < 0.05)) return 'critical';
270
+ if (avail < 128 * 1024 || (fraction !== null && fraction < 0.15)) return 'elevated';
271
+ return 'ok';
272
+ } catch { return 'unknown'; }
273
+ }
274
+
231
275
  /** Record a guard reason once per change, not once per tick. */
232
276
  function noteGuard(db, id, reason) {
233
277
  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);
@@ -237,6 +281,8 @@ function noteGuard(db, id, reason) {
237
281
 
238
282
  function start(board, fleet, lanes, settings, report, { only = null } = {}) {
239
283
  const lanesWanted = settings.reviewDispatch ? "('ready', 'review')" : "('ready')";
284
+ const pressure = memoryPressure();
285
+ let headlessStarted = 0;
240
286
  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
287
  for (const row of rows) {
242
288
  if (only && row.id !== only) continue;
@@ -248,7 +294,12 @@ function start(board, fleet, lanes, settings, report, { only = null } = {}) {
248
294
  const claimed = tx(board, (db) => {
249
295
  const task = getTask(db, row.id);
250
296
  if (!task || task.status !== row.status || task.claim_lock) return null;
251
- const why = guard(db, task, lane, fleet, settings);
297
+ let why = guard(db, task, lane, fleet, settings);
298
+ if (!why && lane.surface !== 'pane') {
299
+ // a one-shot worker runs on this machine: its memory, and Hermes's respawn guard
300
+ if (pressure === 'critical' || (pressure === 'elevated' && headlessStarted >= 1)) why = `this machine's memory pressure is ${pressure}; the card starts when it eases`;
301
+ else why = respawnGuard(db, task, settings, row.status);
302
+ }
252
303
  if (why) { noteGuard(db, row.id, why); report.queued.push([row.id, why]); return null; }
253
304
  if (!row.assignee) {
254
305
  db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, row.id);
@@ -259,7 +310,10 @@ function start(board, fleet, lanes, settings, report, { only = null } = {}) {
259
310
  });
260
311
  if (!claimed) continue;
261
312
  try {
262
- const session = lane.surface === 'pane' ? launchPane(board, claimed.task, claimed.runId, lane, fleet) : launchHeadless(board, claimed.task, claimed.runId, lane, settings);
313
+ const session = lane.surface === 'pane'
314
+ ? launchPane(board, claimed.task, claimed.runId, lane, fleet, { input: row.status === 'review' ? reviewAsk(board, claimed.task) : null })
315
+ : launchHeadless(board, claimed.task, claimed.runId, lane, settings);
316
+ if (lane.surface !== 'pane') headlessStarted += 1;
263
317
  report.started.push([row.id, session.address ?? `pid ${session.pid}`, session.mode]);
264
318
  } catch (error) {
265
319
  tx(board, (db) => {
@@ -271,20 +325,65 @@ function start(board, fleet, lanes, settings, report, { only = null } = {}) {
271
325
  }
272
326
  }
273
327
 
328
+ /** What a session started from the review lane is asked to do: verify the handoff and give the verdict. */
329
+ function reviewAsk(board, task) {
330
+ const local = normalMachine(task.machine) === localMachine();
331
+ const where = local ? `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}` : `--at ${boardAddress(board.label)}`;
332
+ 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, `
333
+ + `then give the verdict: \`supercode workflow complete ${task.id} --summary "<what you verified>" ${where}\` approves it, and `
334
+ + `\`supercode workflow request-changes ${task.id} "<what must change>" ${where}\` sends it back.\n\n${tx(board, (db) => cardContext(db, task.id))}`;
335
+ }
336
+
337
+ /** What a session started on a triage card is asked to do: write its spec, or split it into cards it waits on. */
338
+ function triageAsk(board, task, label, purpose) {
339
+ const local = normalMachine(task.machine) === localMachine();
340
+ const where = local ? `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}` : `--at ${boardAddress(label)}`;
341
+ const context = tx(board, (db) => cardContext(db, task.id));
342
+ return purpose === 'decompose'
343
+ ? `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: `
344
+ + `\`supercode workflow decompose ${task.id} --child "<title>[@<profile>]" --child "..." ${where}\` (each child gets this card's machine and directory). `
345
+ + `The card itself stays and wakes when they are done.\n\n${context}`
346
+ : `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: `
347
+ + `\`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}`;
348
+ }
349
+
350
+ /**
351
+ * Start a session on a triage card to specify or decompose it (where Hermes asks an auxiliary model, supercode asks a
352
+ * session in the card's lane, which then keeps the card's memory when the card starts). Answers the session.
353
+ */
354
+ export function startTriageSession(board, id, lanes, settings, purpose) {
355
+ const fleet = observeFleet();
356
+ const task = tx(board, (db) => getTask(db, id));
357
+ if (!task) throw new Error(`no card ${id}`);
358
+ if (task.status !== 'triage') throw new Error(`${id} is ${task.status}; only a triage card is ${purpose === 'decompose' ? 'decomposed' : 'specified'}`);
359
+ const lane = lanes[task.assignee || settings.defaultAssignee];
360
+ 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'})`);
361
+ const runId = tx(board, (db) => {
362
+ const why = guard(db, task, lane, fleet, settings);
363
+ if (why) throw new Error(`${id} cannot start a session now: ${why}`);
364
+ return claimIn(db, id, { ttl: settings.ttl, expectStatus: 'triage' });
365
+ });
366
+ if (!runId) throw new Error(`${id} could not be claimed`);
367
+ return launchPane(board, getTaskNow(board, id), runId, lane, fleet, { input: triageAsk(board, task, board.label, purpose) });
368
+ }
369
+
370
+ const getTaskNow = (board, id) => tx(board, (db) => getTask(db, id));
371
+
274
372
  function opening(board, task, label) {
275
373
  const context = tx(board, (db) => cardContext(db, task.id));
276
374
  const skills = task.skills?.length ? `\nSkills pinned to this card: ${task.skills.join(', ')}.` : '';
277
375
  const at = `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}`;
278
376
  // the board's own door is on the machine that keeps it; a session elsewhere writes to its card through its manager
279
- const door = normalMachine(task.machine) === localMachine()
280
- ? `\nYour card is yours to keep current: \`supercode workflow show ${task.id} ${at}\` reads it; \`comment\`, \`block --kind <needs_input|capability|transient|dependency>\` `
281
- + `and \`review\` update it; \`create <title> --subtask-of ${task.id}\` files a subtask, and \`complete <subtask>\` marks one done. Its reviewers, not you, mark the card itself done.`
282
- : '';
377
+ const local = normalMachine(task.machine) === localMachine();
378
+ const where = local ? at : `--at ${boardAddress(label)}`;
379
+ 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}\` `
380
+ + `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`
381
+ + `${local ? '' : ' (each is filed with the board and answered by a board notice)'}. Its reviewers, not you, mark the card itself done.`;
283
382
  return `You are the session working card ${task.id} on the ${label} board. The board started you; updates to the card `
284
383
  + `(comments, reassignment, blocks, completion) and to the cards it waits on reach you as board notices.${door}${skills}\n\n${context}`;
285
384
  }
286
385
 
287
- function launchPane(board, task, runId, lane, fleet) {
386
+ function launchPane(board, task, runId, lane, fleet, { input = null } = {}) {
288
387
  const machine = normalMachine(task.machine);
289
388
  const model = task.model_override ?? lane.model;
290
389
  const tail = model ? ['--model', model] : [];
@@ -308,7 +407,7 @@ function launchPane(board, task, runId, lane, fleet) {
308
407
  } else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
309
408
  if (!session) {
310
409
  const sid = randomUUID();
311
- 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 });
410
+ 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 });
312
411
  if (r.code !== 0) throw new Error(`supercode open --on ${task.machine}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
313
412
  session = { surface: 'pane', session_id: sid, machine, pane: lastJson(r.stdout)?.pane ?? null, mode: 'fresh', launched_at: now(), ...(replaced ? { replaces: replaced.session_id } : {}) };
314
413
  }
@@ -341,7 +440,9 @@ function paneSurvives(machine, pane) {
341
440
  /** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
342
441
  function launchHeadless(board, task, runId, lane, settings) {
343
442
  const workspace = resolveLocalWorkspace(board, task);
344
- const args = ['-p', lane.name, '--cli', '--accept-hooks', ...task.skills.flatMap((s) => ['--skills', s]),
443
+ 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');
444
+ const skills = reviewing ? [...new Set([...task.skills, 'sdlc-review'])] : task.skills;
445
+ const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
345
446
  ...(task.model_override ? ['-m', task.model_override, ...(task.provider_override ? ['--provider', task.provider_override] : [])] : []),
346
447
  ...(task.reasoning_effort ? ['--reasoning', task.reasoning_effort] : []), 'chat', '-q', `work kanban task ${task.id}`];
347
448
  const logDir = join(dirname(board.path), board.slug === 'default' ? 'kanban' : '', 'logs');
@@ -397,7 +498,7 @@ function resolveLocalWorkspace(board, task) {
397
498
  * never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
398
499
  * its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
399
500
  */
400
- export function attachSession(board, id, who, { workspace = null, ttl } = {}) {
501
+ export function adoptSession(board, id, who, { workspace = null, ttl } = {}) {
401
502
  const fleet = observeFleet();
402
503
  if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
403
504
  const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
@@ -479,11 +580,19 @@ function send(from, label, to, body, key) {
479
580
  if (r.code !== 0 || answer.code !== 0) throw new Error((answer.text || r.stderr || 'no answer').trim().slice(-300));
480
581
  }
481
582
 
482
- function notify(board, report) {
483
- const from = `sc:${localMachine()}:board:${board.label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
583
+ /** The board's own address: where its notices come from and where card updates are mailed. */
584
+ export const boardAddress = (label) => `sc:${localMachine()}:board:${label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
585
+
586
+ function notify(board, fleet, report) {
587
+ const from = boardAddress(board.label);
484
588
  for (const sub of subscriptions(board.db)) {
485
589
  if (sub.platform !== 'supercode') continue;
486
590
  const role = ['worker', 'dependency'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
591
+ // a session that worked or watched a card and is gone hears nothing more; a creator is retried until it is back
592
+ if (role !== 'creator' && fleet.ok && !fleet.alive.has(sub.chat_id)) {
593
+ tx(board, (db) => db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id));
594
+ continue;
595
+ }
487
596
  const kinds = MAIL_KINDS[role];
488
597
  const events = board.db.prepare(`SELECT id, kind, payload, created_at FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
489
598
  .all(sub.task_id, sub.last_event_id, ...kinds);
@@ -516,6 +625,109 @@ function notify(board, report) {
516
625
  }
517
626
  }
518
627
 
628
+ // ------------------------------------------------------------------ the board's mailbox
629
+
630
+ const ENTRY = fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url));
631
+ const NOT_BY_MAIL = new Set(['serve', 'daemon', 'dispatch', 'init', 'adopt', 'boards', 'gc', 'repair', 'watch', 'tail', 'attach']);
632
+
633
+ /** The board's mailbox on this machine: the directory supercode files mail for `address` in, or null. */
634
+ function mailboxOf(address) {
635
+ const config = process.env.SUPERCODE_HOME || (process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'supercode') : join(process.env.HOME ?? '', '.config', 'supercode'));
636
+ const root = join(config, 'mail');
637
+ if (!existsSync(root)) return null;
638
+ for (const dir of readdirSync(root)) {
639
+ if (!dir.startsWith('board-')) continue;
640
+ try { if (readFileSync(join(root, dir, 'address'), 'utf8').trim() === address) return join(root, dir); } catch { /* not a mailbox */ }
641
+ }
642
+ return null;
643
+ }
644
+
645
+ /**
646
+ * Card updates filed as mail (`supercode workflow <verb> … --at <this board's address>`, from any machine): each is
647
+ * run as its sender, the session supercode found the command running under, so a card's session gets exactly what
648
+ * it may do on this machine; the answer goes back as a notice and the envelope is filed as read.
649
+ */
650
+ function readMailbox(board, address, report) {
651
+ const box = mailboxOf(address);
652
+ if (!box || !existsSync(join(box, 'new'))) return;
653
+ for (const name of readdirSync(join(box, 'new')).filter((n) => n.endsWith('.json')).sort()) {
654
+ const path = join(box, 'new', name);
655
+ let envelope;
656
+ try { envelope = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; }
657
+ try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
658
+ const argv = json(envelope.body, {})?.workflow;
659
+ let answer;
660
+ if (!Array.isArray(argv) || !argv.length || NOT_BY_MAIL.has(argv[0])) answer = `not applied: a mailed card update is one \`supercode workflow\` verb other than ${[...NOT_BY_MAIL].join(', ')}`;
661
+ else {
662
+ const clean = [];
663
+ for (let i = 0; i < argv.length; i += 1) { if (argv[i] === '--root' || argv[i] === '--board') i += 1; else clean.push(String(argv[i])); }
664
+ const sid = String(envelope.from ?? '').split(':')[3] ?? '';
665
+ const r = spawnSync(process.execPath, [ENTRY, 'workflow', ...clean, '--root', board.root, '--board', board.slug], {
666
+ encoding: 'utf8', timeout: 180_000, env: { ...process.env, CLAUDE_CODE_SESSION_ID: sid, CODEX_THREAD_ID: '' },
667
+ });
668
+ answer = `${clean.slice(0, 2).join(' ')}: ${(r.status === 0 ? r.stdout : r.stderr || r.stdout).trim().slice(-600) || `exit ${r.status}`}`;
669
+ }
670
+ report.mailed.push([envelope.from, argv?.[0] ?? '?']);
671
+ try { send(address, board.label, envelope.from, answer, `board-answer-${envelope.id}`); } catch (error) { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }
672
+ }
673
+ }
674
+
675
+ // ------------------------------------------------------------------ goals and triage
676
+
677
+ /**
678
+ * Hermes's goal loop for a pane session: a goal card whose session sits idle while the card is still running is told
679
+ * to keep going, up to the card's turn budget; when the budget is spent the card is blocked for review. Whether the
680
+ * work is done is the session's call through its card (`request-review`), not an auxiliary judge's.
681
+ */
682
+ function pursueGoals(board, fleet, settings, report) {
683
+ if (!fleet.ok) return;
684
+ const cards = board.db.prepare("SELECT id FROM tasks WHERE status = 'running' AND goal_mode = 1").all();
685
+ for (const { id } of cards) {
686
+ const nudge = tx(board, (db) => {
687
+ const task = getTask(db, id);
688
+ const runRow = currentRun(db, id);
689
+ const session = runRow?.metadata?.supercode;
690
+ if (!session?.session_id || session.surface !== 'pane') return null;
691
+ const live = fleet.sessions.find((x) => x.address?.endsWith(`:${session.session_id}`));
692
+ if (live?.status !== 'idle') return null;
693
+ if (session.goal_nudged_at && now() - session.goal_nudged_at < settings.interval * 2) return null;
694
+ const turns = Number(session.goal_turns ?? 0);
695
+ const budget = task.goal_max_turns ?? settings.goalMaxTurns;
696
+ if (turns >= budget) {
697
+ db.prepare("UPDATE tasks SET status = 'blocked', claim_lock = NULL, claim_expires = NULL WHERE id = ?").run(id);
698
+ const ended = endRun(db, id, { outcome: 'blocked', summary: `goal turn budget (${budget}) spent` });
699
+ event(db, id, 'blocked', { reason: `goal turn budget (${budget}) spent; review it`, kind: 'needs_input', source_status: 'ready' }, ended);
700
+ return null;
701
+ }
702
+ setRunSession(db, runRow.id, { ...session, goal_turns: turns + 1, goal_nudged_at: now() });
703
+ event(db, id, 'goal_continued', { turn: turns + 1, budget }, runRow.id);
704
+ 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.` };
705
+ });
706
+ if (nudge) {
707
+ try { send(boardAddress(board.label), board.label, nudge.address, nudge.text, `board-goal-${id}-${now()}`); report.notified.push([id, 'goal_continued', nudge.address]); }
708
+ catch (error) { report.undelivered.push([id, nudge.address, String(error.message).slice(-200)]); }
709
+ }
710
+ }
711
+ }
712
+
713
+ /** Hermes's auto-decompose: triage cards with a pane lane get a session that splits them, a few per tick. */
714
+ function autoDecompose(board, lanes, settings, report) {
715
+ if (!settings.autoDecompose) return;
716
+ let started = 0;
717
+ for (const { id, assignee } of board.db.prepare("SELECT id, assignee FROM tasks WHERE status = 'triage' ORDER BY priority DESC, created_at ASC").all()) {
718
+ if (started >= settings.autoDecomposePerTick) break;
719
+ const lane = lanes[assignee || settings.defaultAssignee];
720
+ if (lane?.surface !== 'pane') continue;
721
+ try {
722
+ const session = startTriageSession(board, id, lanes, settings, 'decompose');
723
+ report.started.push([id, session.address, 'decompose']);
724
+ started += 1;
725
+ } catch (error) {
726
+ report.queued.push([id, String(error.message).slice(-300)]);
727
+ }
728
+ }
729
+ }
730
+
519
731
  // ------------------------------------------------------------------ the tick
520
732
 
521
733
  /** The board's name in notices: the home's owner folder (`…/volter-manager/home` → `volter-manager`), and the slug. */
@@ -533,18 +745,19 @@ export function tick({ root, lanes, settings, only = null, slugs = null, dispatc
533
745
  const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), error: 'no pane lanes' };
534
746
  const out = [];
535
747
  for (const slug of slugs ?? listBoards(root)) {
536
- const report = { board: slug, started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
748
+ 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 };
537
749
  let board;
538
750
  try {
539
751
  board = openBoard(root, slug);
540
752
  board.label = boardLabel(root, slug);
753
+ readMailbox(board, boardAddress(board.label), report);
541
754
  supervise(board, fleet, settings, report);
542
755
  report.promoted = recomputeReady(board, settings.failureLimit);
543
756
  if (dispatch) start(board, fleet, lanes, settings, report, { only });
544
- notify(board, report);
757
+ if (dispatch && !only) { pursueGoals(board, fleet, settings, report); autoDecompose(board, lanes, settings, report); }
758
+ notify(board, fleet, report);
545
759
  // what a Hermes gateway serves around its dispatcher and this one does not: said by the caller
546
760
  report.chat_subscriptions = Number(board.db.prepare("SELECT COUNT(*) AS n FROM kanban_notify_subs WHERE platform != 'supercode'").get().n);
547
- report.triage = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE status = 'triage'").get().n);
548
761
  } catch (error) {
549
762
  report.error = error.message;
550
763
  } finally {