@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.
- package/board/cli.mjs +792 -121
- package/board/dispatch.mjs +229 -16
- package/board/engine.mjs +225 -7
- package/board.mjs +0 -1
- package/package.json +1 -1
package/board/dispatch.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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'
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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 {
|