@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/engine.mjs
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
|
|
9
9
|
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
10
14
|
import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
11
15
|
|
|
12
16
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
@@ -105,7 +109,8 @@ function retryStatus(db, taskId) {
|
|
|
105
109
|
const row = one(db, 'SELECT current_run_id FROM tasks WHERE id = ?', taskId);
|
|
106
110
|
if (!row?.current_run_id) return 'ready';
|
|
107
111
|
const claimed = one(db, "SELECT payload FROM task_events WHERE task_id = ? AND run_id = ? AND kind = 'claimed' ORDER BY id DESC LIMIT 1", taskId, row.current_run_id);
|
|
108
|
-
|
|
112
|
+
const source = json(claimed?.payload, {})?.source_status;
|
|
113
|
+
return source === 'review' || source === 'triage' ? source : 'ready';
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
/** The phase a card blocked out of comes back to (Hermes reads it off the latest interruption). */
|
|
@@ -124,6 +129,17 @@ function resumeStatus(db, taskId) {
|
|
|
124
129
|
* machine its session runs on. Answers its id; with an `idempotencyKey` a live card already filed under it is
|
|
125
130
|
* answered instead.
|
|
126
131
|
*/
|
|
132
|
+
/** A project in the home's `projects.db` (Hermes's registry), by id or slug; null when there is none. */
|
|
133
|
+
function projectOf(root, idOrSlug) {
|
|
134
|
+
const file = join(root, 'projects.db');
|
|
135
|
+
if (!existsSync(file)) return null;
|
|
136
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
137
|
+
try {
|
|
138
|
+
return db.prepare('SELECT id, slug, name, primary_path FROM projects WHERE id = ?').get(idOrSlug)
|
|
139
|
+
?? db.prepare('SELECT id, slug, name, primary_path FROM projects WHERE slug = ?').get(String(idOrSlug).toLowerCase()) ?? null;
|
|
140
|
+
} catch { return null; } finally { db.close(); }
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
export function createCard(board, spec) {
|
|
128
144
|
const title = String(spec.title ?? '').trim();
|
|
129
145
|
if (!title) throw new Error('a card has a title');
|
|
@@ -149,14 +165,26 @@ export function createCard(board, spec) {
|
|
|
149
165
|
else if (parents.some((p) => !['done', 'archived'].includes(one(db, 'SELECT status FROM tasks WHERE id = ?', p).status))) status = 'todo';
|
|
150
166
|
let id = newTaskId();
|
|
151
167
|
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
168
|
+
// a project (Hermes's projects registry, `projects.db` in the home) anchors the card's worktree in its repository
|
|
169
|
+
const project = spec.project ? projectOf(board.root, spec.project) : null;
|
|
170
|
+
if (project) {
|
|
171
|
+
if (ws.kind === 'scratch') ws.kind = 'worktree';
|
|
172
|
+
if (ws.kind === 'worktree' && !ws.path && project.primary_path) ws.path = join(project.primary_path, '.worktrees', id);
|
|
173
|
+
if (ws.kind === 'worktree' && !spec.branch) {
|
|
174
|
+
const t = title.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
175
|
+
spec = { ...spec, branch: `${project.slug}/${id}${t ? `-${t}` : ''}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
spec = { ...spec, project: project?.id ?? null };
|
|
152
179
|
const t = now();
|
|
153
180
|
run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
|
|
154
|
-
tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id
|
|
155
|
-
|
|
181
|
+
tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
|
|
182
|
+
project_id, goal_mode, goal_max_turns)
|
|
183
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
156
184
|
id, title, spec.body ?? null, spec.assignee || null, status, Number(spec.priority) || 0, spec.createdBy ?? 'user', t, ws.kind, ws.path,
|
|
157
185
|
spec.branch ?? null, spec.tenant ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
|
|
158
186
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
159
|
-
spec.reasoning ?? null, spec.sessionId ?? null);
|
|
187
|
+
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
160
188
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
161
189
|
if (machine || arc) run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)', id, machine, arc?.id ?? null);
|
|
162
190
|
for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
|
|
@@ -250,7 +278,8 @@ export function claim(board, id, { ttl, lock = claimer(), expectStatus = 'ready'
|
|
|
250
278
|
|
|
251
279
|
/** `claim` inside the caller's own transaction (a dispatcher's guard and claim see one state). */
|
|
252
280
|
export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null } = {}) {
|
|
253
|
-
|
|
281
|
+
// a triage card is claimed to be specified, which does not wait on its parents
|
|
282
|
+
if (expectStatus !== 'triage' && !parentsDone(db, id)) {
|
|
254
283
|
run(db, "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", id);
|
|
255
284
|
event(db, id, 'claim_rejected', { reason: 'parents_not_done' });
|
|
256
285
|
return null;
|
|
@@ -268,7 +297,7 @@ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStat
|
|
|
268
297
|
id, task.assignee, task.current_step_key, lock, expires, task.max_runtime_seconds, t);
|
|
269
298
|
const runId = Number(r.lastInsertRowid);
|
|
270
299
|
run(db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', runId, id);
|
|
271
|
-
event(db, id, 'claimed', { lock, expires, run_id: runId, source_status:
|
|
300
|
+
event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: ['review', 'triage'].includes(task.status) ? task.status : 'ready' }, runId);
|
|
272
301
|
return runId;
|
|
273
302
|
}
|
|
274
303
|
|
|
@@ -311,7 +340,26 @@ export function recordFailure(db, id, error, { failureLimit = DEFAULT_FAILURE_LI
|
|
|
311
340
|
return true;
|
|
312
341
|
}
|
|
313
342
|
|
|
314
|
-
export function complete(board, id,
|
|
343
|
+
export function complete(board, id, opts = {}) {
|
|
344
|
+
completeIn(board, id, opts);
|
|
345
|
+
cleanupWorkspace(board, id);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
|
|
350
|
+
* workspaces, or a worktree (its branch stays); never while a child still waits on it, never a `dir`.
|
|
351
|
+
*/
|
|
352
|
+
function cleanupWorkspace(board, id) {
|
|
353
|
+
const task = tx(board, (db) => getTask(db, id));
|
|
354
|
+
if (!task?.workspace_path || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
|
|
355
|
+
const waiting = tx(board, (db) => one(db, "SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived') LIMIT 1", id));
|
|
356
|
+
if (waiting || !existsSync(task.workspace_path)) return;
|
|
357
|
+
if (task.workspace_kind === 'worktree') { spawnSync('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }); return; }
|
|
358
|
+
const managed = resolve(board.slug === 'default' ? join(board.root, 'kanban', 'workspaces') : join(board.root, 'kanban', 'boards', board.slug, 'workspaces'));
|
|
359
|
+
if (resolve(task.workspace_path).startsWith(`${managed}/`)) rmSync(task.workspace_path, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function completeIn(board, id, { result = null, summary = null, metadata = null } = {}) {
|
|
315
363
|
return tx(board, (db) => {
|
|
316
364
|
const task = must(db, id);
|
|
317
365
|
if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
|
|
@@ -322,7 +370,12 @@ export function complete(board, id, { result = null, summary = null, metadata =
|
|
|
322
370
|
let runId = endRun(db, id, { outcome: 'completed', status: 'done', summary: said, metadata: metadata ?? undefined });
|
|
323
371
|
if (runId === null && (said || metadata || task.status === 'review')) runId = synthesizeRun(db, id, { outcome: 'completed', summary: said ?? (task.status === 'review' ? 'Review approved without additional evidence.' : null), metadata });
|
|
324
372
|
const first = String(said ?? '').trim().split('\n')[0].slice(0, 400);
|
|
325
|
-
|
|
373
|
+
const artifacts = Array.isArray(metadata?.artifacts) ? metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
|
|
374
|
+
event(db, id, 'completed', { result_len: result ? result.length : 0, summary: first || null, ...(artifacts.length ? { artifacts } : {}) }, runId);
|
|
375
|
+
// card ids the handoff names that no card has (Hermes flags them the same way)
|
|
376
|
+
const named = [...new Set(`${summary ?? ''} ${result ?? ''}`.match(/\bt_[a-f0-9]{8,}\b/g) ?? [])];
|
|
377
|
+
const phantom = named.filter((t) => !one(db, 'SELECT 1 FROM tasks WHERE id = ?', t));
|
|
378
|
+
if (phantom.length) event(db, id, 'suspected_hallucinated_references', { phantom_refs: phantom, source: 'completion_summary' }, runId);
|
|
326
379
|
recomputeIn(db);
|
|
327
380
|
});
|
|
328
381
|
}
|
|
@@ -380,7 +433,9 @@ export function requestChanges(board, id, { reason = null, reviewer = null, impl
|
|
|
380
433
|
return tx(board, (db) => {
|
|
381
434
|
const task = must(db, id);
|
|
382
435
|
if (task.status !== 'review' && !(task.status === 'running' && retryStatus(db, id) === 'review')) throw new Error(`${id} is not in review`);
|
|
383
|
-
|
|
436
|
+
// back to the implementer the review request recorded, not to the reviewer now holding it
|
|
437
|
+
const asked = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", id)?.payload, {}) ?? {};
|
|
438
|
+
const back = implementer ?? asked.implementer ?? task.assignee;
|
|
384
439
|
run(db, "UPDATE tasks SET status = ?, assignee = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", parentsDone(db, id) ? 'ready' : 'todo', back, id);
|
|
385
440
|
const runId = endRun(db, id, { outcome: 'changes_requested', summary: reason });
|
|
386
441
|
event(db, id, 'changes_requested', { reason, reviewer, implementer: back }, runId);
|
|
@@ -545,3 +600,212 @@ export function cardContext(db, id) {
|
|
|
545
600
|
}
|
|
546
601
|
return out.join('\n');
|
|
547
602
|
}
|
|
603
|
+
|
|
604
|
+
// ------------------------------------------------------------------ the rest of Hermes's verbs
|
|
605
|
+
|
|
606
|
+
/** A triage card gets its spec (a tightened title and a body) and waits in todo for its parents, as Hermes's specifier leaves it. */
|
|
607
|
+
export function specify(board, id, { title = null, body = null, author = 'user' } = {}) {
|
|
608
|
+
return tx(board, (db) => {
|
|
609
|
+
const task = must(db, id);
|
|
610
|
+
if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is specified`);
|
|
611
|
+
if (title && String(title).trim()) run(db, 'UPDATE tasks SET title = ? WHERE id = ?', String(title).trim(), id);
|
|
612
|
+
if (body !== null) run(db, 'UPDATE tasks SET body = ? WHERE id = ?', body, id);
|
|
613
|
+
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
614
|
+
const runId = endRun(db, id, { outcome: 'specified', summary: 'specified' });
|
|
615
|
+
event(db, id, 'specified', { author, title_changed: Boolean(title), body_len: body ? body.length : 0 }, runId);
|
|
616
|
+
recomputeIn(db);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* A triage card fans out into children it waits on (Hermes's decomposer: the root stays and is the child of every
|
|
622
|
+
* leaf, so it wakes when the graph is done). `children` are `{ title, body?, assignee? }`; answers their ids.
|
|
623
|
+
*/
|
|
624
|
+
export function decompose(board, id, children, { author = 'user' } = {}) {
|
|
625
|
+
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
626
|
+
const task = tx(board, (db) => must(db, id));
|
|
627
|
+
if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is decomposed`);
|
|
628
|
+
const ids = children.map((c) => createCard(board, {
|
|
629
|
+
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
630
|
+
workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
|
|
631
|
+
}).id);
|
|
632
|
+
tx(board, (db) => {
|
|
633
|
+
for (const child of ids) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, id);
|
|
634
|
+
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
635
|
+
const runId = endRun(db, id, { outcome: 'decomposed', summary: `decomposed into ${ids.join(', ')}` });
|
|
636
|
+
event(db, id, 'decomposed', { children: ids, author }, runId);
|
|
637
|
+
});
|
|
638
|
+
return ids;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last. */
|
|
642
|
+
export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = 'swarm-orchestrator', priority = 0, idempotencyKey = null }) {
|
|
643
|
+
if (!String(goal ?? '').trim()) throw new Error('a swarm has a goal');
|
|
644
|
+
if (!workers.length) throw new Error('a swarm has at least one --worker PROFILE:TITLE[:SKILL,SKILL]');
|
|
645
|
+
const protocol = (root) => `\n\n## Swarm protocol\n- Swarm root / shared blackboard: \`${root}\`.\n- Read sibling/parent handoffs from the board's context before working.\n`
|
|
646
|
+
+ `- Put machine-readable facts in completion metadata.\n- Put cross-worker notes on the root card as comments.\n- Goal: ${goal.trim()}\n`;
|
|
647
|
+
const root = createCard(board, { title: `Swarm: ${goal.trim()}`.slice(0, 200), body: goal, tenant, createdBy, priority, idempotencyKey, initialStatus: 'blocked' });
|
|
648
|
+
if (root.existing) return { root_id: root.id, existing: true };
|
|
649
|
+
tx(board, (db) => {
|
|
650
|
+
run(db, "UPDATE tasks SET status = 'done', completed_at = ? WHERE id = ?", now(), root.id);
|
|
651
|
+
const runId = synthesizeRun(db, root.id, { outcome: 'completed', summary: 'swarm blackboard', metadata: { swarm: true } });
|
|
652
|
+
event(db, root.id, 'completed', { result_len: 0, summary: 'swarm blackboard' }, runId);
|
|
653
|
+
});
|
|
654
|
+
const workerIds = workers.map((w) => createCard(board, { title: w.title, body: `${w.title}${protocol(root.id)}`, assignee: w.profile, skills: w.skills, parents: [root.id], tenant, createdBy, priority }).id);
|
|
655
|
+
const verifierId = createCard(board, { title: 'Verify swarm outputs', body: `Verify the workers' outputs.${protocol(root.id)}`, assignee: verifier, parents: workerIds, tenant, createdBy, priority }).id;
|
|
656
|
+
const synthesizerId = createCard(board, { title: 'Synthesize swarm outputs', body: `Synthesize the verified outputs.${protocol(root.id)}`, assignee: synthesizer, parents: [verifierId], tenant, createdBy, priority }).id;
|
|
657
|
+
return { root_id: root.id, worker_ids: workerIds, verifier_id: verifierId, synthesizer_id: synthesizerId };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Hermes's `edit`: correct a done card's result (and its completed run's summary and metadata). */
|
|
661
|
+
export function editResult(board, id, { result, summary = null, metadata = undefined }) {
|
|
662
|
+
return tx(board, (db) => {
|
|
663
|
+
const task = must(db, id);
|
|
664
|
+
if (task.status !== 'done') throw new Error(`${id} is ${task.status}; edit corrects a done card's result`);
|
|
665
|
+
run(db, 'UPDATE tasks SET result = ? WHERE id = ?', result, id);
|
|
666
|
+
const said = summary ?? result;
|
|
667
|
+
const last = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC LIMIT 1", id);
|
|
668
|
+
let runId = last ? Number(last.id) : null;
|
|
669
|
+
if (runId === null) runId = synthesizeRun(db, id, { outcome: 'completed', summary: said, metadata: metadata ?? null });
|
|
670
|
+
else {
|
|
671
|
+
run(db, 'UPDATE task_runs SET summary = ? WHERE id = ?', said, runId);
|
|
672
|
+
if (metadata !== undefined) run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(metadata), runId);
|
|
673
|
+
}
|
|
674
|
+
event(db, id, 'edited', { fields: ['result', 'summary', ...(metadata !== undefined ? ['metadata'] : [])], result_len: result ? result.length : 0, summary: String(said ?? '').split('\n')[0].slice(0, 400) || null }, runId);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Send a card in review back to its implementer (ready, or todo while parents are open). */
|
|
679
|
+
export function reopenReview(board, id) {
|
|
680
|
+
return tx(board, (db) => {
|
|
681
|
+
const task = must(db, id);
|
|
682
|
+
if (task.status !== 'review') throw new Error(`${id} is ${task.status}, not in review`);
|
|
683
|
+
const handoff = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", id)?.payload, {}) ?? {};
|
|
684
|
+
const status = parentsDone(db, id) ? 'ready' : 'todo';
|
|
685
|
+
const implementer = typeof handoff.implementer === 'string' && handoff.implementer.trim() ? handoff.implementer : null;
|
|
686
|
+
run(db, `UPDATE tasks SET status = ?, current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL${implementer ? ', assignee = ?' : ''} WHERE id = ?`, ...(implementer ? [status, implementer, id] : [status, id]));
|
|
687
|
+
event(db, id, 'review_reopened', { status, ...(implementer ? { implementer } : {}) });
|
|
688
|
+
return status;
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** A running card's worker says it is alive (Hermes's `heartbeat`); the dispatcher does not rely on it. */
|
|
693
|
+
export function heartbeat(board, id, note = null) {
|
|
694
|
+
return tx(board, (db) => {
|
|
695
|
+
const task = must(db, id);
|
|
696
|
+
if (task.status !== 'running') throw new Error(`${id} is ${task.status}, not running`);
|
|
697
|
+
const t = now();
|
|
698
|
+
run(db, 'UPDATE tasks SET last_heartbeat_at = ? WHERE id = ?', t, id);
|
|
699
|
+
if (task.current_run_id) run(db, 'UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?', t, task.current_run_id);
|
|
700
|
+
event(db, id, 'heartbeat', note ? { note } : null, task.current_run_id);
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** A card's model (and provider) for its next start, or cleared. */
|
|
705
|
+
export function setModel(board, id, model = null, provider = null) {
|
|
706
|
+
if (provider && !model) throw new Error('a provider needs a model');
|
|
707
|
+
return tx(board, (db) => {
|
|
708
|
+
must(db, id);
|
|
709
|
+
run(db, 'UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?', model || null, provider || null, id);
|
|
710
|
+
event(db, id, 'model_override_set', { model: model || null, provider: provider || null });
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function addAttachment(board, id, { file, name, contentType = null, author = 'user', dir }) {
|
|
715
|
+
return tx(board, (db) => {
|
|
716
|
+
must(db, id);
|
|
717
|
+
const r = run(db, 'INSERT INTO task_attachments (task_id, filename, stored_path, content_type, size, uploaded_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
718
|
+
id, name, file.path, contentType, file.size, author, now());
|
|
719
|
+
event(db, id, 'attachment_added', { id: Number(r.lastInsertRowid), filename: name, size: file.size });
|
|
720
|
+
return Number(r.lastInsertRowid);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export const attachments = (db, id) => all(db, 'SELECT * FROM task_attachments WHERE task_id = ? ORDER BY id', id);
|
|
725
|
+
|
|
726
|
+
export function removeAttachment(board, attachmentId) {
|
|
727
|
+
return tx(board, (db) => {
|
|
728
|
+
const row = one(db, 'SELECT * FROM task_attachments WHERE id = ?', attachmentId);
|
|
729
|
+
if (!row) throw new Error(`no attachment ${attachmentId}`);
|
|
730
|
+
run(db, 'DELETE FROM task_attachments WHERE id = ?', attachmentId);
|
|
731
|
+
event(db, row.task_id, 'attachment_removed', { id: Number(attachmentId), filename: row.filename });
|
|
732
|
+
return row;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Per status, per assignee, and the oldest ready card's age (Hermes's `stats`). */
|
|
737
|
+
export function stats(db) {
|
|
738
|
+
const byStatus = {};
|
|
739
|
+
for (const r of all(db, "SELECT status, COUNT(*) AS n FROM tasks WHERE status != 'archived' GROUP BY status")) byStatus[r.status] = Number(r.n);
|
|
740
|
+
const byAssignee = {};
|
|
741
|
+
for (const r of all(db, "SELECT assignee, status, COUNT(*) AS n FROM tasks WHERE status != 'archived' AND assignee IS NOT NULL GROUP BY assignee, status")) (byAssignee[r.assignee] ??= {})[r.status] = Number(r.n);
|
|
742
|
+
const byMachine = {};
|
|
743
|
+
for (const r of all(db, "SELECT c.machine, t.status, COUNT(*) AS n FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived' AND c.machine IS NOT NULL GROUP BY c.machine, t.status")) (byMachine[r.machine] ??= {})[r.status] = Number(r.n);
|
|
744
|
+
const oldest = one(db, "SELECT MIN(created_at) AS ts FROM tasks WHERE status = 'ready'")?.ts;
|
|
745
|
+
const t = now();
|
|
746
|
+
return { by_status: byStatus, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, now: t };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function runs(db, id, { stateType = null, stateName = null } = {}) {
|
|
750
|
+
must(db, id);
|
|
751
|
+
const where = stateType === 'status' ? ' AND status = ?' : stateType === 'outcome' ? ' AND outcome = ?' : '';
|
|
752
|
+
return all(db, `SELECT * FROM task_runs WHERE task_id = ?${where} ORDER BY id`, ...(where ? [id, stateName] : [id])).map((r) => ({ ...r, metadata: json(r.metadata, null) }));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Delete the thread events of done and archived cards older than `seconds` (Hermes's `gc` of events). */
|
|
756
|
+
export function gcEvents(board, seconds) {
|
|
757
|
+
return tx(board, (db) => Number(run(db, "DELETE FROM task_events WHERE created_at < ? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done', 'archived'))", now() - seconds).changes));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ------------------------------------------------------------------ diagnostics
|
|
761
|
+
|
|
762
|
+
const hours = (s) => s / 3600;
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Distress signals on a card, computed on demand and never stored (Hermes's diagnostics, the same kinds where the
|
|
766
|
+
* same thing can go wrong, and supercode's own where only its sessions can): each `{ kind, severity, title, data,
|
|
767
|
+
* actions }`, worst first.
|
|
768
|
+
*/
|
|
769
|
+
export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null } = {}) {
|
|
770
|
+
const out = [];
|
|
771
|
+
const t = now();
|
|
772
|
+
const events = all(db, 'SELECT kind, payload, created_at FROM task_events WHERE task_id = ? ORDER BY id', task.id);
|
|
773
|
+
const failures = Number(task.consecutive_failures || 0);
|
|
774
|
+
if (failures >= Math.max(1, (task.max_retries ?? failureLimit) - 1) && task.status !== 'done') {
|
|
775
|
+
out.push({ kind: 'repeated_failures', severity: task.status === 'blocked' ? 'error' : 'warning', title: `${failures} consecutive failures`,
|
|
776
|
+
data: { failures, last_error: task.last_failure_error }, actions: [`supercode workflow reclaim ${task.id}`, `supercode workflow reassign ${task.id} <profile>`] });
|
|
777
|
+
}
|
|
778
|
+
const phantom = [...events].reverse().find((e) => e.kind === 'suspected_hallucinated_references');
|
|
779
|
+
if (phantom && !events.some((e) => e.kind === 'edited' && e.created_at > phantom.created_at)) {
|
|
780
|
+
out.push({ kind: 'prose_phantom_refs', severity: 'warning', title: 'its completion summary names card ids no card has', data: json(phantom.payload, {}) ?? {}, actions: [`supercode workflow edit ${task.id} --result <corrected>`] });
|
|
781
|
+
}
|
|
782
|
+
const lost = events.filter((e) => e.kind === 'session_lost' && t - e.created_at < 24 * 3600).length;
|
|
783
|
+
if (lost >= 2) out.push({ kind: 'repeated_session_loss', severity: 'warning', title: `its session was lost ${lost}x in 24h`, data: { lost }, actions: [`supercode workflow move ${task.id} <machine>`] });
|
|
784
|
+
const crashes = events.filter((e) => e.kind === 'crashed' && t - e.created_at < 24 * 3600).length;
|
|
785
|
+
if (crashes >= 2) out.push({ kind: 'repeated_crashes', severity: 'error', title: `its worker crashed ${crashes}x in 24h`, data: { crashes }, actions: [`supercode workflow log ${task.id}`] });
|
|
786
|
+
if (task.status === 'blocked') {
|
|
787
|
+
const since = [...events].reverse().find((e) => e.kind === 'blocked' || e.kind === 'gave_up')?.created_at ?? task.created_at;
|
|
788
|
+
if (hours(t - since) >= blockedHours) out.push({ kind: 'stuck_in_blocked', severity: 'warning', title: `blocked for ${Math.floor(hours(t - since))}h`, data: { hours: Math.floor(hours(t - since)) }, actions: [`supercode workflow unblock ${task.id}`] });
|
|
789
|
+
const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
|
|
790
|
+
if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
|
|
791
|
+
}
|
|
792
|
+
if (task.status === 'ready' && task.assignee) {
|
|
793
|
+
const since = [...events].reverse().find((e) => ['created', 'promoted', 'unblocked', 'reclaimed', 'session_lost', 'promoted_manual'].includes(e.kind))?.created_at ?? task.created_at;
|
|
794
|
+
const age = t - since;
|
|
795
|
+
if (age >= strandedSeconds) {
|
|
796
|
+
const guard = json([...events].reverse().find((e) => e.kind === 'respawn_guarded')?.payload, {})?.reason;
|
|
797
|
+
out.push({ kind: 'stranded_in_ready', severity: age >= strandedSeconds * 6 ? 'critical' : age >= strandedSeconds * 2 ? 'error' : 'warning',
|
|
798
|
+
title: `ready for ${Math.floor(age / 60)}m with no session`, data: { age_seconds: age, ...(guard ? { waiting_because: guard } : {}) }, actions: [`supercode workflow reassign ${task.id} <profile>`] });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (task.status === 'review') {
|
|
802
|
+
const children = all(db, 'SELECT child_id FROM task_links WHERE parent_id = ?', task.id).length;
|
|
803
|
+
if (children) out.push({ kind: 'review_dependency_deadlock', severity: 'warning', title: `review handoff holds ${children} dependent card(s)`, data: { children }, actions: [`supercode workflow complete ${task.id}`] });
|
|
804
|
+
}
|
|
805
|
+
if (task.status === 'running' && fleet?.ok) {
|
|
806
|
+
const session = lastSession(db, task.id);
|
|
807
|
+
if (session?.machine && !fleet.online.has(session.machine)) out.push({ kind: 'machine_offline', severity: 'warning', title: `its machine ${session.machine} is offline`, data: { machine: session.machine }, actions: [] });
|
|
808
|
+
}
|
|
809
|
+
const order = ['critical', 'error', 'warning'];
|
|
810
|
+
return out.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
|
|
811
|
+
}
|
package/board/store.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// `engine.mjs`. Hermes's own tools may write the same file at the same time: every write takes SQLite's writer
|
|
10
10
|
// lock (`BEGIN IMMEDIATE`) and retries while the other holds it.
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
|
-
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { hostname } from 'node:os';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
15
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -78,7 +78,12 @@ export function listBoards(root) {
|
|
|
78
78
|
const out = existsSync(boardPath(root)) ? [DEFAULT_BOARD] : [];
|
|
79
79
|
const named = join(root, 'kanban', 'boards');
|
|
80
80
|
if (existsSync(named)) {
|
|
81
|
-
for (const slug of readdirSync(named))
|
|
81
|
+
for (const slug of readdirSync(named)) {
|
|
82
|
+
if (slug.startsWith('_') || !existsSync(boardPath(root, slug))) continue;
|
|
83
|
+
// a board archived in its metadata is not dispatched (Hermes lists boards without the archived)
|
|
84
|
+
try { if (JSON.parse(readFileSync(join(named, slug, 'board.json'), 'utf8'))?.archived) continue; } catch { /* no metadata: live */ }
|
|
85
|
+
out.push(slug);
|
|
86
|
+
}
|
|
82
87
|
}
|
|
83
88
|
return out;
|
|
84
89
|
}
|
package/board.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { holdDispatcherLock } from './hermes-locks.mjs';
|
|
13
13
|
import { summary } from './board/cli.mjs';
|
|
14
|
+
import { notifyChats } from './board/dispatch.mjs';
|
|
14
15
|
|
|
15
16
|
const ENTRY = fileURLToPath(new URL('./bin/orchestrator.mjs', import.meta.url));
|
|
16
17
|
|
|
@@ -30,7 +31,7 @@ function dispatchOnce(root) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** Start the board's dispatcher for the home at `root`; answers `{ tick, stop }`, or null when it does not dispatch. */
|
|
33
|
-
export async function startBoard({ root, config = {}, log = () => {} }) {
|
|
34
|
+
export async function startBoard({ root, config = {}, log = () => {}, adapters = {} }) {
|
|
34
35
|
const kanban = config.kanban ?? {};
|
|
35
36
|
const off = String(process.env.HERMES_KANBAN_DISPATCH_IN_GATEWAY ?? '').trim().toLowerCase();
|
|
36
37
|
if (['0', 'false', 'no', 'off'].includes(off)) { log('info', 'board: not dispatched (HERMES_KANBAN_DISPATCH_IN_GATEWAY)'); return null; }
|
|
@@ -42,19 +43,27 @@ export async function startBoard({ root, config = {}, log = () => {} }) {
|
|
|
42
43
|
let running = false;
|
|
43
44
|
let stopped = false;
|
|
44
45
|
const said = new Set();
|
|
46
|
+
const stuck = {};
|
|
47
|
+
const warned = {};
|
|
45
48
|
const tick = async () => {
|
|
46
49
|
if (running || stopped) return;
|
|
47
50
|
running = true;
|
|
48
51
|
try {
|
|
49
52
|
const { reports, error } = await dispatchOnce(root);
|
|
50
53
|
if (error) { log('warn', `board: tick failed: ${error}`); return; }
|
|
54
|
+
// chat-platform subscriptions are delivered here, through the daemon's own channel adapters
|
|
55
|
+
try { await notifyChats(root, adapters, log); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); }
|
|
51
56
|
for (const r of reports) {
|
|
52
57
|
const line = summary(r);
|
|
58
|
+
stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
|
|
59
|
+
if (stuck[r.board] >= 6 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log('warn', `board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${stuck[r.board]} ticks`); warned[r.board] = Date.now(); }
|
|
60
|
+
if (r.paused && !said.has(`paused:${r.board}`)) { said.add(`paused:${r.board}`); log('info', `board ${r.board}: new starts paused by ${r.paused}`); }
|
|
61
|
+
if (!r.paused) said.delete(`paused:${r.board}`);
|
|
53
62
|
if (!line.endsWith('nothing to do')) log(r.error ? 'warn' : 'info', line);
|
|
54
63
|
for (const [task, why] of r.failed ?? []) log('warn', `board ${r.board}: ${task} did not start: ${why}`);
|
|
55
64
|
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a notice for ${task} to ${to} was not delivered: ${why}`);
|
|
56
|
-
|
|
57
|
-
if (
|
|
65
|
+
const unserved = r.chat_subscriptions && !Object.keys(adapters).length;
|
|
66
|
+
if (unserved && !said.has(`subs:${r.board}`)) { said.add(`subs:${r.board}`); log('warn', `board ${r.board}: ${r.chat_subscriptions} subscription(s) name a chat platform and this daemon hosts no channel adapter to tell them`); }
|
|
58
67
|
if (r.nonspawnable?.length && !said.has(`lane:${r.board}`)) { said.add(`lane:${r.board}`); log('warn', `board ${r.board}: ${r.nonspawnable.length} card(s) name an assignee that is no profile of this home, and wait`); }
|
|
59
68
|
}
|
|
60
69
|
} finally {
|
package/hermes-locks.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'node:fs';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
|
-
import { join, resolve } from 'node:path';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
17
|
|
|
18
18
|
/** Hermes's gateway kind, the `kind` of every record it writes. */
|
|
19
19
|
export const GATEWAY_KIND = 'hermes-gateway';
|
|
@@ -154,6 +154,14 @@ export function holdDispatcherLock(root, { python = 'python3' } = {}) {
|
|
|
154
154
|
return holdFileLock(path, root, { python, what: `the board at ${resolve(root)} is dispatched by`, tail: 'one dispatcher serves a board' });
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Hold one board's per-tick lock, `<kanban.db>.dispatch.lock` (Hermes's `_dispatch_tick_lock`), for one tick: a
|
|
159
|
+
* one-shot `dispatch` never ticks a board beside another dispatcher. Rejects, naming the holder, when it is held.
|
|
160
|
+
*/
|
|
161
|
+
export function holdBoardTickLock(dbPath, { python = 'python3' } = {}) {
|
|
162
|
+
return holdFileLock(`${dbPath}.dispatch.lock`, dirname(dbPath), { python, what: `the board ${dbPath} is being ticked by`, tail: 'one tick at a time' });
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
function holdFileLock(path, home, { python, what, tail }) {
|
|
158
166
|
return new Promise((done, fail) => {
|
|
159
167
|
let child;
|
package/loop.mjs
CHANGED
|
@@ -85,7 +85,7 @@ export class Orchestrator {
|
|
|
85
85
|
// reject unhandled, which Node treats as fatal and which stopped the daemon
|
|
86
86
|
if (this.autoTick) { this.timer = setInterval(() => { this.tick().catch(() => undefined); }, TICK_MS); this.timer.unref?.(); }
|
|
87
87
|
// the home's board, dispatched as a Hermes gateway dispatches it (docs/plans/hermes-compat.md row 14)
|
|
88
|
-
if (this.autoTick) this.board = await startBoard({ root: this.state.root, config: this.state.profiles.default?.residue?.config ?? {}, log: this.log });
|
|
88
|
+
if (this.autoTick) this.board = await startBoard({ root: this.state.root, config: this.state.profiles.default?.residue?.config ?? {}, log: this.log, adapters: this.adapters });
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|