@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.
- package/adapters/chat-sdk.mjs +9 -5
- package/board/cli.mjs +818 -123
- package/board/dispatch.mjs +423 -31
- package/board/engine.mjs +273 -9
- package/board/store.mjs +7 -2
- package/board.mjs +12 -3
- package/hermes-locks.mjs +9 -1
- package/loop.mjs +1 -1
- package/package.json +1 -1
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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)) {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
|
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
|
|
351
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|