@volter/supercode-orchestrator 0.4.15 → 0.4.16
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 +318 -0
- package/board/dispatch.mjs +556 -0
- package/board/engine.mjs +547 -0
- package/board/store.mjs +150 -0
- package/package.json +2 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// The board's dispatcher (docs/architecture/orchestrator.md §2.9): one tick reads the fleet through supercode,
|
|
2
|
+
// supervises every running card, promotes cards whose parents are done, starts what capacity allows, and tells
|
|
3
|
+
// subscribers what happened. Hermes's dispatcher is the behaviour it keeps parity with (claims, dependencies,
|
|
4
|
+
// review lane, failure breaker, caps, notify); what it adds is how a session runs, which the IR decides per
|
|
5
|
+
// profile (`worker.surface`):
|
|
6
|
+
//
|
|
7
|
+
// - `pane`: an interactive session on the card's machine (`supercode open --new … --on <machine>`), started with a
|
|
8
|
+
// session id the dispatcher chose (`--session-id`) and recorded on the run. It stays open. It is live while
|
|
9
|
+
// supercode's fleet lists it; one missing for the whole grace on an online machine is lost, and the next start
|
|
10
|
+
// resumes it (`--resume`) before it ever replaces it from the card. Nothing asks the session about itself.
|
|
11
|
+
// - `headless`: the profile's worker for one query on this machine (`bin/hermes-worker.mjs`, Hermes's worker
|
|
12
|
+
// argv and task environment), watched by its pid as Hermes watches its own.
|
|
13
|
+
//
|
|
14
|
+
// Every side effect follows a committed transition: a card is claimed before its session is launched, and a
|
|
15
|
+
// subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
|
|
16
|
+
// launch and a crash between the two steps is answered by the next tick.
|
|
17
|
+
import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, openSync } from 'node:fs';
|
|
19
|
+
import { hostname, tmpdir } from 'node:os';
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { event, json, listBoards, now, openBoard, tx } from './store.mjs';
|
|
24
|
+
import {
|
|
25
|
+
cardContext, claimIn, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
|
|
26
|
+
recomputeReady, recordFailure, release, setRunSession, subscribeIn, subscriptions,
|
|
27
|
+
} from './engine.mjs';
|
|
28
|
+
|
|
29
|
+
const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
|
|
30
|
+
const MAIL_KINDS = {
|
|
31
|
+
creator: ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', 'status', 'archived', 'block_loop_detected', 'review_requested',
|
|
32
|
+
'changes_requested', 'session_started', 'session_resumed', 'session_replaced', 'session_adopted', 'session_lost', 'spawn_failed'],
|
|
33
|
+
worker: ['commented', 'assigned', 'machine_set', 'blocked', 'unblocked', 'completed', 'archived', 'changes_requested', 'linked', 'unlinked', 'promoted_manual', 'subtask_added'],
|
|
34
|
+
// a card the session's own card depends on: it sees it change
|
|
35
|
+
dependency: ['completed', 'blocked', 'unblocked', 'archived', 'review_requested', 'changes_requested', 'commented'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------------ names and the fleet
|
|
39
|
+
|
|
40
|
+
/** supercode's `normal_machine_name`: the form a machine takes in session addresses. */
|
|
41
|
+
export function normalMachine(name) {
|
|
42
|
+
const short = String(name ?? '').split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
43
|
+
return short || 'localhost';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** This machine's name as supercode's addresses spell it: its Teams enrollment, else its host name. */
|
|
47
|
+
export function localMachine() {
|
|
48
|
+
const home = process.env.SUPERCODE_TEAMS_HOME || join(process.env.SUPERCODE_HOME || (process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'supercode') : join(process.env.HOME ?? '', '.config', 'supercode')), 'teams');
|
|
49
|
+
try {
|
|
50
|
+
const contexts = JSON.parse(readFileSync(join(home, 'workspaces', 'contexts.json'), 'utf8'));
|
|
51
|
+
const ctx = contexts.contexts?.[contexts.current];
|
|
52
|
+
const enrollment = JSON.parse(readFileSync(join(home, 'workspaces', 'connectors', ctx.server_id, ctx.team_id, 'enrollment.json'), 'utf8'));
|
|
53
|
+
if (enrollment?.machine?.name) return normalMachine(enrollment.machine.name);
|
|
54
|
+
} catch { /* not enrolled: the host name */ }
|
|
55
|
+
return normalMachine(hostname());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The supercode command the dispatcher drives. */
|
|
59
|
+
export const supercodeBin = () => process.env.SUPERCODE_BIN || 'supercode';
|
|
60
|
+
|
|
61
|
+
function sc(args, { input = null, timeout = 120_000 } = {}) {
|
|
62
|
+
// a neutral directory: `supercode teams …` resolves its entry from a checkout at the cwd
|
|
63
|
+
const r = spawnSync(supercodeBin(), args, { cwd: tmpdir(), input: input ?? undefined, encoding: 'utf8', timeout, env: process.env });
|
|
64
|
+
return { code: r.status, stdout: r.stdout ?? '', stderr: (r.stderr ?? '') + (r.error ? r.error.message : '') };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lastJson = (text) => {
|
|
68
|
+
const line = text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('{') || l.startsWith('[')).pop();
|
|
69
|
+
return line ? json(line, null) : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What supercode reports about the fleet now: `{ ok, alive: Set<address>, online: Set<machine>, error }`. `alive` is
|
|
74
|
+
* every session that can be messaged on every machine; `online` every enrolled machine connected to the team.
|
|
75
|
+
*/
|
|
76
|
+
export function observeFleet() {
|
|
77
|
+
const list = sc(['message', 'list', '--json']);
|
|
78
|
+
const sessions = list.code === 0 ? json(list.stdout.trim(), null) : null;
|
|
79
|
+
if (!Array.isArray(sessions)) return { ok: false, alive: new Set(), online: new Set(), error: `message list: ${(list.stderr || list.stdout).trim().slice(-300)}` };
|
|
80
|
+
const machines = sc(['teams', 'machines', 'list', '--json']);
|
|
81
|
+
const rows = machines.code === 0 ? lastJson(machines.stdout)?.items : null;
|
|
82
|
+
const online = new Set([localMachine()]);
|
|
83
|
+
for (const m of rows ?? []) if (m.enrollment === 'active' && m.connection === 'online') online.add(normalMachine(m.name));
|
|
84
|
+
return { ok: true, sessions, alive: new Set(sessions.map((s) => s.address).filter(Boolean)), online, error: rows ? null : 'teams machines list gave no answer; only this machine counts as online' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sessionAlive = (fleet, session) => Boolean(session?.session_id) && [...fleet.alive].some((a) => a.endsWith(`:${session.session_id}`));
|
|
88
|
+
|
|
89
|
+
function pidAlive(pid) {
|
|
90
|
+
if (!pid) return false;
|
|
91
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ------------------------------------------------------------------ settings
|
|
95
|
+
|
|
96
|
+
/** The board's settings: the home's `kanban:` block, Hermes's keys and supercode's own (`machines`, `session_grace_seconds`). */
|
|
97
|
+
export function boardSettings(kanban = {}) {
|
|
98
|
+
const pos = (v) => (Number.isInteger(Number(v)) && Number(v) >= 1 ? Number(v) : null);
|
|
99
|
+
return {
|
|
100
|
+
failureLimit: pos(kanban.failure_limit) ?? DEFAULT_FAILURE_LIMIT,
|
|
101
|
+
maxInProgress: pos(kanban.max_in_progress),
|
|
102
|
+
perProfile: pos(kanban.max_in_progress_per_profile),
|
|
103
|
+
defaultAssignee: String(kanban.default_assignee ?? '').trim() || null,
|
|
104
|
+
reviewDispatch: kanban.review_dispatch !== false,
|
|
105
|
+
ttl: claimTtl(kanban.claim_ttl_seconds),
|
|
106
|
+
grace: pos(kanban.session_grace_seconds) ?? 120,
|
|
107
|
+
machines: Object.fromEntries(Object.entries(kanban.machines ?? {}).map(([name, m]) => [normalMachine(name), { capacity: pos(m?.capacity) }])),
|
|
108
|
+
interval: Math.max(Number(kanban.dispatch_interval_seconds) || 60, 1),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Each profile's lane, from the IR: how its cards' sessions run and how many at once. */
|
|
113
|
+
export function lanesOf(profiles = {}) {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const [name, p] of Object.entries(profiles)) {
|
|
116
|
+
const w = p?.worker;
|
|
117
|
+
if (!w?.harness) continue;
|
|
118
|
+
out[name] = { name, dir: p.dir, harness: w.harness, model: w.model ?? null, surface: w.surface ?? 'headless', capacity: w.capacity ?? null };
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ------------------------------------------------------------------ supervise
|
|
124
|
+
|
|
125
|
+
function supervise(board, fleet, settings, report) {
|
|
126
|
+
const rows = board.db.prepare("SELECT id FROM tasks WHERE status = 'running'").all();
|
|
127
|
+
const stop = [];
|
|
128
|
+
for (const { id } of rows) {
|
|
129
|
+
tx(board, (db) => {
|
|
130
|
+
const task = getTask(db, id);
|
|
131
|
+
if (task?.status !== 'running') return;
|
|
132
|
+
const runRow = currentRun(db, id);
|
|
133
|
+
const session = runRow?.metadata?.supercode ?? null;
|
|
134
|
+
const t = now();
|
|
135
|
+
// a card past its max runtime is stopped and counted, as Hermes times its workers out
|
|
136
|
+
if (task.max_runtime_seconds && runRow && t - Number(runRow.started_at) > Number(task.max_runtime_seconds)) {
|
|
137
|
+
stop.push(session);
|
|
138
|
+
release(db, id, { outcome: 'timed_out', error: `ran past max_runtime ${task.max_runtime_seconds}s`, kind: 'timed_out', payload: { limit_seconds: task.max_runtime_seconds } });
|
|
139
|
+
if (recordFailure(db, id, `timed out after ${task.max_runtime_seconds}s`, { failureLimit: settings.failureLimit, outcome: 'timed_out' })) report.auto_blocked.push(id);
|
|
140
|
+
report.timed_out.push(id);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (session?.surface === 'headless') {
|
|
144
|
+
if (pidAlive(session.pid)) {
|
|
145
|
+
if (Number(task.claim_expires) <= t + 60) extendClaim(db, id, { ttl: settings.ttl, reason: 'pid_alive', extra: { worker_pid: session.pid } });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (t - Number(session.launched_at ?? 0) < 30) return;
|
|
149
|
+
release(db, id, { outcome: 'crashed', error: `worker ${session.pid} exited without completing the card`, kind: 'crashed', payload: { worker_pid: session.pid } });
|
|
150
|
+
if (recordFailure(db, id, `worker ${session.pid} exited without completing the card`, { failureLimit: settings.failureLimit })) report.auto_blocked.push(id);
|
|
151
|
+
report.crashed.push(id);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (session?.session_id) {
|
|
155
|
+
if (!fleet.ok) return; // no reading, no verdict
|
|
156
|
+
const alive = sessionAlive(fleet, session);
|
|
157
|
+
const machineUp = fleet.online.has(session.machine);
|
|
158
|
+
const young = t - Number(session.launched_at ?? 0) < settings.grace;
|
|
159
|
+
const missing = !alive && machineUp && !young;
|
|
160
|
+
const next = { ...session };
|
|
161
|
+
if (alive) { next.seen_at ??= t; delete next.missing_since; }
|
|
162
|
+
if (missing) next.missing_since ??= t;
|
|
163
|
+
if (missing && t - next.missing_since >= settings.grace) {
|
|
164
|
+
// lost: gone from an online machine for the whole grace. A resume that never came up is not resumable;
|
|
165
|
+
// a fresh start that never came up is a failure. The card goes back to ready and the spawn resumes it.
|
|
166
|
+
if (next.mode === 'resume' && !next.seen_at) next.resumable = false;
|
|
167
|
+
const whole = { ...(runRow.metadata ?? {}), supercode: next };
|
|
168
|
+
release(db, id, { outcome: 'reclaimed', error: `session ${next.session_id} is gone from ${next.machine}`, metadata: whole, kind: 'session_lost',
|
|
169
|
+
payload: { session_id: next.session_id, machine: next.machine, pane: next.pane, mode: next.mode, seen: Boolean(next.seen_at) } });
|
|
170
|
+
report.lost.push(id);
|
|
171
|
+
if (next.mode === 'fresh' && !next.seen_at && recordFailure(db, id, `session ${next.session_id} never came up on ${next.machine}`, { failureLimit: settings.failureLimit, outcome: 'crashed' })) report.auto_blocked.push(id);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (JSON.stringify(next) !== JSON.stringify(session)) setRunSession(db, runRow.id, next);
|
|
175
|
+
if (Number(task.claim_expires) <= t + 60) {
|
|
176
|
+
extendClaim(db, id, { ttl: settings.ttl, reason: alive ? 'session_alive' : !machineUp ? 'machine_offline' : young ? 'session_starting' : 'session_missing', extra: { session_id: next.session_id, machine: next.machine } });
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// claimed with nothing launched (a dispatcher that died between the two, or another implementation's claim):
|
|
181
|
+
// the claim's own expiry answers it, as Hermes's does
|
|
182
|
+
if (task.claim_expires && Number(task.claim_expires) < t) {
|
|
183
|
+
release(db, id, { outcome: 'reclaimed', error: `stale_lock=${task.claim_lock}`, kind: 'reclaimed', payload: { stale_lock: task.claim_lock, claim_expires: task.claim_expires } });
|
|
184
|
+
report.reclaimed.push(id);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const session of stop) stopSession(session); // after the transaction: a close is a network call
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stopSession(session) {
|
|
192
|
+
if (!session) return;
|
|
193
|
+
if (session.surface === 'headless' && pidAlive(session.pid)) { try { process.kill(session.pid, 'SIGTERM'); } catch { /* gone */ } return; }
|
|
194
|
+
if (session.pane) sc(['teams', 'panes', 'close', session.pane, ...(session.machine ? ['--machine', session.machine] : [])], { timeout: 60_000 });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ------------------------------------------------------------------ start
|
|
198
|
+
|
|
199
|
+
const running = (db, sql, ...args) => Number(db.prepare(`SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'running' ${sql}`).get(...args).n);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* How many sessions the board has on `machine`: every running card's, and every other card's latest session that is
|
|
203
|
+
* still live (an idle session on a done or blocked card holds its place until it is closed).
|
|
204
|
+
*/
|
|
205
|
+
function machineLoad(db, machine, fleet) {
|
|
206
|
+
let n = 0;
|
|
207
|
+
for (const card of db.prepare("SELECT t.id, t.status, c.machine FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.machine IS NOT NULL AND t.status != 'archived'").all()) {
|
|
208
|
+
if (normalMachine(card.machine) !== machine) continue;
|
|
209
|
+
if (card.status === 'running' || (fleet.ok && sessionAlive(fleet, lastSession(db, card.id)))) n += 1;
|
|
210
|
+
}
|
|
211
|
+
return n;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Why a card cannot start now, or null. */
|
|
215
|
+
function guard(db, task, lane, fleet, settings) {
|
|
216
|
+
if (settings.maxInProgress && running(db, '') >= settings.maxInProgress) return `the board runs ${settings.maxInProgress} cards at most (kanban.max_in_progress)`;
|
|
217
|
+
const cap = lane.capacity ?? settings.perProfile;
|
|
218
|
+
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
|
+
if (lane.surface !== 'pane') return null;
|
|
220
|
+
if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
|
|
221
|
+
const machine = normalMachine(task.machine);
|
|
222
|
+
const mcap = settings.machines[machine]?.capacity;
|
|
223
|
+
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
|
+
if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
|
|
225
|
+
if (!fleet.online.has(machine)) return `machine ${machine} is offline; the card starts when it is back`;
|
|
226
|
+
if (task.workspace_kind !== 'dir' || !task.workspace_path) return `a pane session starts in a directory on ${machine}: the card's workspace is dir:<absolute path>, not ${task.workspace_kind}`;
|
|
227
|
+
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
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Record a guard reason once per change, not once per tick. */
|
|
232
|
+
function noteGuard(db, id, reason) {
|
|
233
|
+
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);
|
|
234
|
+
if (json(last?.payload, {})?.reason === reason) return;
|
|
235
|
+
event(db, id, 'respawn_guarded', { reason });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function start(board, fleet, lanes, settings, report, { only = null } = {}) {
|
|
239
|
+
const lanesWanted = settings.reviewDispatch ? "('ready', 'review')" : "('ready')";
|
|
240
|
+
const rows = board.db.prepare(`SELECT id, status, assignee FROM tasks WHERE status IN ${lanesWanted} AND claim_lock IS NULL ORDER BY priority DESC, created_at ASC`).all();
|
|
241
|
+
for (const row of rows) {
|
|
242
|
+
if (only && row.id !== only) continue;
|
|
243
|
+
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
|
+
const assignee = row.assignee || settings.defaultAssignee;
|
|
245
|
+
if (!assignee) { report.unassigned.push(row.id); continue; }
|
|
246
|
+
const lane = lanes[assignee];
|
|
247
|
+
if (!lane) { report.nonspawnable.push(row.id); continue; }
|
|
248
|
+
const claimed = tx(board, (db) => {
|
|
249
|
+
const task = getTask(db, row.id);
|
|
250
|
+
if (!task || task.status !== row.status || task.claim_lock) return null;
|
|
251
|
+
const why = guard(db, task, lane, fleet, settings);
|
|
252
|
+
if (why) { noteGuard(db, row.id, why); report.queued.push([row.id, why]); return null; }
|
|
253
|
+
if (!row.assignee) {
|
|
254
|
+
db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, row.id);
|
|
255
|
+
event(db, row.id, 'assigned', { assignee, source: 'kanban.default_assignee' });
|
|
256
|
+
}
|
|
257
|
+
const runId = claimIn(db, row.id, { ttl: settings.ttl, expectStatus: row.status });
|
|
258
|
+
return runId ? { task: getTask(db, row.id), runId } : null;
|
|
259
|
+
});
|
|
260
|
+
if (!claimed) continue;
|
|
261
|
+
try {
|
|
262
|
+
const session = lane.surface === 'pane' ? launchPane(board, claimed.task, claimed.runId, lane, fleet) : launchHeadless(board, claimed.task, claimed.runId, lane, settings);
|
|
263
|
+
report.started.push([row.id, session.address ?? `pid ${session.pid}`, session.mode]);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
tx(board, (db) => {
|
|
266
|
+
release(db, row.id, { outcome: 'spawn_failed', error: String(error.message).slice(-500), kind: 'spawn_failed', payload: { error: String(error.message).slice(-300) } });
|
|
267
|
+
if (recordFailure(db, row.id, error.message, { failureLimit: settings.failureLimit, outcome: 'spawn_failed' })) report.auto_blocked.push(row.id);
|
|
268
|
+
});
|
|
269
|
+
report.failed.push([row.id, String(error.message).slice(-300)]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function opening(board, task, label) {
|
|
275
|
+
const context = tx(board, (db) => cardContext(db, task.id));
|
|
276
|
+
const skills = task.skills?.length ? `\nSkills pinned to this card: ${task.skills.join(', ')}.` : '';
|
|
277
|
+
const at = `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}`;
|
|
278
|
+
// 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
|
+
: '';
|
|
283
|
+
return `You are the session working card ${task.id} on the ${label} board. The board started you; updates to the card `
|
|
284
|
+
+ `(comments, reassignment, blocks, completion) and to the cards it waits on reach you as board notices.${door}${skills}\n\n${context}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function launchPane(board, task, runId, lane, fleet) {
|
|
288
|
+
const machine = normalMachine(task.machine);
|
|
289
|
+
const model = task.model_override ?? lane.model;
|
|
290
|
+
const tail = model ? ['--model', model] : [];
|
|
291
|
+
const base = ['open', '--new', 'claude', '--detach', '--on', task.machine, '--cwd', task.workspace_path];
|
|
292
|
+
const prev = tx(board, (db) => lastSession(db, task.id));
|
|
293
|
+
let session = null;
|
|
294
|
+
let replaced = null;
|
|
295
|
+
if (prev && prev.machine === machine) {
|
|
296
|
+
if (sessionAlive(fleet, prev)) {
|
|
297
|
+
session = { ...prev, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
298
|
+
delete session.missing_since;
|
|
299
|
+
} else if (prev.resumable !== false) {
|
|
300
|
+
const r = sc([...base, '--key', `board-${task.id}-r${runId}-resume`, '--input',
|
|
301
|
+
`The board resumed this session after it was lost; you are still on card ${task.id}: ${task.title}. Carry on.`, '--', '--resume', prev.session_id, ...tail], { timeout: 180_000 });
|
|
302
|
+
const pane = r.code === 0 ? lastJson(r.stdout)?.pane ?? null : null;
|
|
303
|
+
// a resume the harness refuses (its transcript is gone) ends the pane at once; another machine's launch
|
|
304
|
+
// answers before that shows, so the pane is looked for again a moment later
|
|
305
|
+
if (pane && paneSurvives(task.machine, pane)) session = { surface: 'pane', session_id: prev.session_id, machine, pane, mode: 'resume', launched_at: now() };
|
|
306
|
+
else replaced = { session_id: prev.session_id, why: r.code === 0 ? `its resume ended as soon as it started (pane ${pane})` : (r.stderr || r.stdout).trim().slice(-300) };
|
|
307
|
+
} else replaced = { session_id: prev.session_id, why: 'its resume never came up' };
|
|
308
|
+
} else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
|
|
309
|
+
if (!session) {
|
|
310
|
+
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 });
|
|
312
|
+
if (r.code !== 0) throw new Error(`supercode open --on ${task.machine}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
313
|
+
session = { surface: 'pane', session_id: sid, machine, pane: lastJson(r.stdout)?.pane ?? null, mode: 'fresh', launched_at: now(), ...(replaced ? { replaces: replaced.session_id } : {}) };
|
|
314
|
+
}
|
|
315
|
+
session.harness = 'claude-code';
|
|
316
|
+
session.address = `sc:${machine}:claude-code:${session.session_id}`;
|
|
317
|
+
const kind = { adopt: 'session_adopted', resume: 'session_resumed', fresh: replaced ? 'session_replaced' : 'session_started' }[session.mode];
|
|
318
|
+
tx(board, (db) => {
|
|
319
|
+
setRunSession(db, runId, session);
|
|
320
|
+
event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
|
|
321
|
+
// the working session hears updates to its card; a session no longer working it does not
|
|
322
|
+
for (const s of subscriptions(db, task.id)) {
|
|
323
|
+
if (s.delivery_metadata?.role === 'worker' && s.chat_id !== session.address) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(task.id, s.chat_id);
|
|
324
|
+
}
|
|
325
|
+
subscribeIn(db, task.id, session.address, 'worker');
|
|
326
|
+
// it sees the cards its own depends on change, and works its subtasks
|
|
327
|
+
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(task.id)) subscribeIn(db, p.parent_id, session.address, 'dependency');
|
|
328
|
+
for (const t of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of = ?').all(task.id)) subscribeIn(db, t.task_id, session.address, 'worker');
|
|
329
|
+
});
|
|
330
|
+
return session;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Whether a pane just launched on `machine` is still there after the harness had a moment to start. */
|
|
334
|
+
function paneSurvives(machine, pane) {
|
|
335
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6000);
|
|
336
|
+
const r = sc(['teams', 'panes', 'ls', '--machine', machine], { timeout: 60_000 });
|
|
337
|
+
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
338
|
+
return Array.isArray(panes) ? panes.some((p) => p.pane === pane) : true; // no reading: the grace decides
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
|
|
342
|
+
function launchHeadless(board, task, runId, lane, settings) {
|
|
343
|
+
const workspace = resolveLocalWorkspace(board, task);
|
|
344
|
+
const args = ['-p', lane.name, '--cli', '--accept-hooks', ...task.skills.flatMap((s) => ['--skills', s]),
|
|
345
|
+
...(task.model_override ? ['-m', task.model_override, ...(task.provider_override ? ['--provider', task.provider_override] : [])] : []),
|
|
346
|
+
...(task.reasoning_effort ? ['--reasoning', task.reasoning_effort] : []), 'chat', '-q', `work kanban task ${task.id}`];
|
|
347
|
+
const logDir = join(dirname(board.path), board.slug === 'default' ? 'kanban' : '', 'logs');
|
|
348
|
+
mkdirSync(logDir, { recursive: true });
|
|
349
|
+
const out = openSync(join(logDir, `${task.id}.log`), 'a');
|
|
350
|
+
const child = spawnChild(process.execPath, [WORKER, ...args], {
|
|
351
|
+
cwd: workspace, detached: true, stdio: ['ignore', out, out],
|
|
352
|
+
env: {
|
|
353
|
+
...process.env, HERMES_HOME: lane.dir, HERMES_PROFILE: lane.name, HERMES_KANBAN_TASK: task.id, HERMES_KANBAN_WORKSPACE: workspace,
|
|
354
|
+
HERMES_KANBAN_RUN_ID: String(runId), HERMES_KANBAN_DB: board.path, HERMES_KANBAN_BOARD: board.slug, HERMES_SESSION_SOURCE: 'kanban',
|
|
355
|
+
HERMES_KANBAN_CLAIM_LOCK: `${hostname()}:${process.pid}`, ...(task.tenant ? { HERMES_TENANT: task.tenant } : {}),
|
|
356
|
+
...(task.branch_name ? { HERMES_KANBAN_BRANCH: task.branch_name } : {}),
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
child.unref();
|
|
360
|
+
const session = { surface: 'headless', pid: child.pid, machine: normalMachine(hostname()), mode: 'fresh', launched_at: now() };
|
|
361
|
+
tx(board, (db) => {
|
|
362
|
+
db.prepare('UPDATE tasks SET worker_pid = ? WHERE id = ?').run(child.pid, task.id);
|
|
363
|
+
db.prepare('UPDATE task_runs SET worker_pid = ? WHERE id = ?').run(child.pid, runId);
|
|
364
|
+
setRunSession(db, runId, session);
|
|
365
|
+
event(db, task.id, 'spawned', { pid: child.pid }, runId);
|
|
366
|
+
});
|
|
367
|
+
return session;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
|
|
371
|
+
function resolveLocalWorkspace(board, task) {
|
|
372
|
+
const root = board.root;
|
|
373
|
+
if (task.workspace_kind === 'dir') { mkdirSync(task.workspace_path, { recursive: true }); return task.workspace_path; }
|
|
374
|
+
if (task.workspace_kind === 'worktree') {
|
|
375
|
+
const anchor = task.workspace_path;
|
|
376
|
+
if (!anchor) throw new Error(`card ${task.id} is a worktree card with no repository (worktree:<path>)`);
|
|
377
|
+
const top = spawnSync('git', ['-C', anchor, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
378
|
+
const repo = top.status === 0 ? top.stdout.trim() : null;
|
|
379
|
+
const target = repo && repo === anchor ? join(repo, '.worktrees', task.id) : anchor;
|
|
380
|
+
if (!existsSync(target)) {
|
|
381
|
+
const branch = task.branch_name || `wt/${task.id}`;
|
|
382
|
+
const made = spawnSync('git', ['-C', repo ?? dirname(anchor), 'worktree', 'add', '-B', branch, target], { encoding: 'utf8' });
|
|
383
|
+
if (made.status !== 0) throw new Error(`git worktree add ${target}: ${made.stderr.trim().slice(-300)}`);
|
|
384
|
+
}
|
|
385
|
+
return target;
|
|
386
|
+
}
|
|
387
|
+
const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
|
|
388
|
+
mkdirSync(dir, { recursive: true });
|
|
389
|
+
return dir;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ------------------------------------------------------------------ attach
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Put a live session the board did not start on a card (a session started by hand before its card existed): the
|
|
396
|
+
* card is claimed and its run records the session as the fleet shows it (address, pane, the pane's directory),
|
|
397
|
+
* never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
|
|
398
|
+
* its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
|
|
399
|
+
*/
|
|
400
|
+
export function attachSession(board, id, who, { workspace = null, ttl } = {}) {
|
|
401
|
+
const fleet = observeFleet();
|
|
402
|
+
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
403
|
+
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
|
|
404
|
+
if (!found) throw new Error(`the fleet lists no live session ${who}`);
|
|
405
|
+
const [, host, harness, sessionId] = found.address.split(':');
|
|
406
|
+
if (harness !== 'claude-code') throw new Error(`${found.address} is ${harness}; the board resumes claude-code sessions by id`);
|
|
407
|
+
let dir = workspace;
|
|
408
|
+
if (!dir && found.pane) {
|
|
409
|
+
const r = sc(['teams', 'panes', 'ls', '--machine', host], { timeout: 60_000 });
|
|
410
|
+
dir = (json(r.stdout.trim(), []) ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
|
|
411
|
+
}
|
|
412
|
+
return tx(board, (db) => {
|
|
413
|
+
const task = getTask(db, id);
|
|
414
|
+
if (!task) throw new Error(`no card ${id}`);
|
|
415
|
+
if (task.status === 'running') throw new Error(`${id} is running already`);
|
|
416
|
+
if (task.machine && normalMachine(task.machine) !== host) throw new Error(`${id} runs on ${task.machine}, and ${found.address} is on ${host}; move the card first`);
|
|
417
|
+
const was = lastSession(db, id);
|
|
418
|
+
if (was && was.session_id !== sessionId && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
|
|
419
|
+
if (!task.machine) db.prepare('INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)').run(id, host, task.subtask_of);
|
|
420
|
+
if (dir && !(task.workspace_kind === 'dir' && task.workspace_path)) {
|
|
421
|
+
db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
|
|
422
|
+
}
|
|
423
|
+
if (task.status !== 'ready') {
|
|
424
|
+
if (!['todo', 'blocked', 'scheduled', 'triage', 'review'].includes(task.status)) throw new Error(`${id} is ${task.status}; only an open card takes a session`);
|
|
425
|
+
db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(id);
|
|
426
|
+
}
|
|
427
|
+
const runId = claimIn(db, id, { ttl, expectStatus: 'ready' });
|
|
428
|
+
if (!runId) throw new Error(`${id} waits on parents that are not done`);
|
|
429
|
+
const session = { surface: 'pane', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
430
|
+
setRunSession(db, runId, session);
|
|
431
|
+
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt' }, runId);
|
|
432
|
+
subscribeIn(db, id, found.address, 'worker');
|
|
433
|
+
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
|
|
434
|
+
return { ...session, workspace: dir };
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ------------------------------------------------------------------ notify
|
|
439
|
+
|
|
440
|
+
function notice(kind, task, p, role) {
|
|
441
|
+
const tid = task?.id ?? '?';
|
|
442
|
+
const who = task?.assignee ? `@${task.assignee} ` : '';
|
|
443
|
+
const title = task?.title ?? '';
|
|
444
|
+
switch (kind) {
|
|
445
|
+
case 'session_started': case 'session_resumed': case 'session_replaced': {
|
|
446
|
+
const verb = { session_started: 'started', session_resumed: 'resumed', session_replaced: 'could not resume the card\'s last session and started' }[kind];
|
|
447
|
+
return `▶ ${who}card ${tid} — ${title}: the board ${verb} session ${p.session_id} on ${p.machine} (pane ${p.pane}; ${p.address}).`;
|
|
448
|
+
}
|
|
449
|
+
case 'session_adopted': return `▶ ${who}card ${tid} — ${title}: the board found session ${p.session_id} still live on ${p.machine} and kept it (${p.address}).`;
|
|
450
|
+
case 'session_lost': return `✖ ${who}card ${tid}: session ${p.session_id} is gone from ${p.machine}; the board resumes it, and replaces it from the card only when it cannot.`;
|
|
451
|
+
case 'spawn_failed': return `✖ ${who}card ${tid}: its session did not start: ${String(p.error ?? '').slice(0, 300)}`;
|
|
452
|
+
case 'completed': return `✔ ${who}card ${tid} done — ${title}${p.summary ? `\n${String(p.summary).slice(0, 300)}` : ''}`;
|
|
453
|
+
case 'blocked': return `⏸ ${who}card ${tid} blocked${p.reason ? `: ${String(p.reason).slice(0, 300)}` : ''}`;
|
|
454
|
+
case 'unblocked': return `▶ card ${tid} unblocked`;
|
|
455
|
+
case 'gave_up': return `✖ ${who}card ${tid} gave up after ${p.failures} failures${p.error ? `\n${String(p.error).slice(0, 300)}` : ''}`;
|
|
456
|
+
case 'crashed': return `✖ ${who}card ${tid}: its worker exited without completing it; the board will retry`;
|
|
457
|
+
case 'timed_out': return `⏱ ${who}card ${tid} timed out (max_runtime ${p.limit_seconds}s); the board will retry`;
|
|
458
|
+
case 'status': return `🔄 ${who}card ${tid} → ${p.status}`;
|
|
459
|
+
case 'archived': return role === 'worker' ? `card ${tid} archived` : null;
|
|
460
|
+
case 'block_loop_detected': return `🛑 ${who}card ${tid} went to triage: blocked ${p.recurrences}x for the same cause${p.reason ? `: ${String(p.reason).slice(0, 200)}` : ''}`;
|
|
461
|
+
case 'review_requested': return `👀 ${who}card ${tid} ready for review — ${title}${p.summary ? `\n${String(p.summary).slice(0, 300)}` : ''}`;
|
|
462
|
+
case 'changes_requested': return `🛑 card ${tid}: review asked for changes: ${String(p.reason ?? 'reviewer feedback').slice(0, 300)}`;
|
|
463
|
+
case 'assigned': return `card ${tid} is now assigned to ${p.assignee ?? 'nobody'}`;
|
|
464
|
+
case 'machine_set': return `card ${tid} now runs on ${p.machine ?? 'no machine'}`;
|
|
465
|
+
case 'linked': case 'unlinked': return `card ${tid}: dependency ${kind} (${p.parent} → ${p.child})`;
|
|
466
|
+
case 'promoted_manual': return `card ${tid} promoted to ready by ${p.actor}`;
|
|
467
|
+
case 'subtask_added': return `card ${tid} has a new subtask ${p.subtask}: ${p.title}`;
|
|
468
|
+
default: return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function send(from, label, to, body, key) {
|
|
473
|
+
const request = {
|
|
474
|
+
op: 'send', from, from_name: `${label} board`, to, queue: false, id: key,
|
|
475
|
+
body: `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
|
|
476
|
+
};
|
|
477
|
+
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 60_000 });
|
|
478
|
+
const answer = lastJson(r.stdout) ?? {};
|
|
479
|
+
if (r.code !== 0 || answer.code !== 0) throw new Error((answer.text || r.stderr || 'no answer').trim().slice(-300));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function notify(board, report) {
|
|
483
|
+
const from = `sc:${localMachine()}:board:${board.label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
|
|
484
|
+
for (const sub of subscriptions(board.db)) {
|
|
485
|
+
if (sub.platform !== 'supercode') continue;
|
|
486
|
+
const role = ['worker', 'dependency'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
|
|
487
|
+
const kinds = MAIL_KINDS[role];
|
|
488
|
+
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
|
+
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
490
|
+
if (!events.length) continue;
|
|
491
|
+
const task = tx(board, (db) => getTask(db, sub.task_id));
|
|
492
|
+
let cursor = Number(sub.last_event_id);
|
|
493
|
+
try {
|
|
494
|
+
for (const ev of events) {
|
|
495
|
+
const p = json(ev.payload, {}) ?? {};
|
|
496
|
+
let text;
|
|
497
|
+
if (ev.kind === 'commented') {
|
|
498
|
+
if (p.author === sub.chat_id) { cursor = ev.id; continue; }
|
|
499
|
+
const c = board.db.prepare('SELECT body FROM task_comments WHERE task_id = ? AND author = ? AND created_at >= ? ORDER BY id LIMIT 1').get(sub.task_id, p.author, ev.created_at);
|
|
500
|
+
text = c ? `💬 card ${sub.task_id}, ${p.author}:\n${c.body}` : null;
|
|
501
|
+
} else text = notice(ev.kind, task, p, role);
|
|
502
|
+
if (text) {
|
|
503
|
+
send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`);
|
|
504
|
+
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
505
|
+
}
|
|
506
|
+
cursor = ev.id;
|
|
507
|
+
}
|
|
508
|
+
} catch (error) {
|
|
509
|
+
report.undelivered.push([sub.task_id, sub.chat_id, String(error.message).slice(-200)]);
|
|
510
|
+
}
|
|
511
|
+
// the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
|
|
512
|
+
tx(board, (db) => {
|
|
513
|
+
db.prepare("UPDATE kanban_notify_subs SET last_event_id = ? WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND last_event_id < ?").run(cursor, sub.task_id, sub.chat_id, cursor);
|
|
514
|
+
if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ------------------------------------------------------------------ the tick
|
|
520
|
+
|
|
521
|
+
/** The board's name in notices: the home's owner folder (`…/volter-manager/home` → `volter-manager`), and the slug. */
|
|
522
|
+
export function boardLabel(root, slug) {
|
|
523
|
+
const home = basename(root) === 'home' ? basename(dirname(root)) : basename(root);
|
|
524
|
+
return slug && slug !== 'default' ? `${home}/${slug}` : home;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
|
|
529
|
+
* `only` limits starting to one card (a create that answers whether its card started). Answers one report per board.
|
|
530
|
+
*/
|
|
531
|
+
export function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
|
|
532
|
+
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
533
|
+
const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), error: 'no pane lanes' };
|
|
534
|
+
const out = [];
|
|
535
|
+
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 };
|
|
537
|
+
let board;
|
|
538
|
+
try {
|
|
539
|
+
board = openBoard(root, slug);
|
|
540
|
+
board.label = boardLabel(root, slug);
|
|
541
|
+
supervise(board, fleet, settings, report);
|
|
542
|
+
report.promoted = recomputeReady(board, settings.failureLimit);
|
|
543
|
+
if (dispatch) start(board, fleet, lanes, settings, report, { only });
|
|
544
|
+
notify(board, report);
|
|
545
|
+
// what a Hermes gateway serves around its dispatcher and this one does not: said by the caller
|
|
546
|
+
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
|
+
} catch (error) {
|
|
549
|
+
report.error = error.message;
|
|
550
|
+
} finally {
|
|
551
|
+
board?.close();
|
|
552
|
+
}
|
|
553
|
+
out.push(report);
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|