@volter/supercode-orchestrator 0.4.16 → 0.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/cli.mjs +792 -121
- package/board/dispatch.mjs +229 -16
- package/board/engine.mjs +225 -7
- package/board.mjs +0 -1
- package/package.json +1 -1
package/board/engine.mjs
CHANGED
|
@@ -105,7 +105,8 @@ function retryStatus(db, taskId) {
|
|
|
105
105
|
const row = one(db, 'SELECT current_run_id FROM tasks WHERE id = ?', taskId);
|
|
106
106
|
if (!row?.current_run_id) return 'ready';
|
|
107
107
|
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
|
-
|
|
108
|
+
const source = json(claimed?.payload, {})?.source_status;
|
|
109
|
+
return source === 'review' || source === 'triage' ? source : 'ready';
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/** The phase a card blocked out of comes back to (Hermes reads it off the latest interruption). */
|
|
@@ -151,12 +152,13 @@ export function createCard(board, spec) {
|
|
|
151
152
|
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
152
153
|
const t = now();
|
|
153
154
|
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
|
-
|
|
155
|
+
tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
|
|
156
|
+
project_id, goal_mode, goal_max_turns)
|
|
157
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
156
158
|
id, title, spec.body ?? null, spec.assignee || null, status, Number(spec.priority) || 0, spec.createdBy ?? 'user', t, ws.kind, ws.path,
|
|
157
159
|
spec.branch ?? null, spec.tenant ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
|
|
158
160
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
159
|
-
spec.reasoning ?? null, spec.sessionId ?? null);
|
|
161
|
+
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
160
162
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
161
163
|
if (machine || arc) run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)', id, machine, arc?.id ?? null);
|
|
162
164
|
for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
|
|
@@ -250,7 +252,8 @@ export function claim(board, id, { ttl, lock = claimer(), expectStatus = 'ready'
|
|
|
250
252
|
|
|
251
253
|
/** `claim` inside the caller's own transaction (a dispatcher's guard and claim see one state). */
|
|
252
254
|
export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null } = {}) {
|
|
253
|
-
|
|
255
|
+
// a triage card is claimed to be specified, which does not wait on its parents
|
|
256
|
+
if (expectStatus !== 'triage' && !parentsDone(db, id)) {
|
|
254
257
|
run(db, "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", id);
|
|
255
258
|
event(db, id, 'claim_rejected', { reason: 'parents_not_done' });
|
|
256
259
|
return null;
|
|
@@ -268,7 +271,7 @@ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStat
|
|
|
268
271
|
id, task.assignee, task.current_step_key, lock, expires, task.max_runtime_seconds, t);
|
|
269
272
|
const runId = Number(r.lastInsertRowid);
|
|
270
273
|
run(db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', runId, id);
|
|
271
|
-
event(db, id, 'claimed', { lock, expires, run_id: runId, source_status:
|
|
274
|
+
event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: ['review', 'triage'].includes(task.status) ? task.status : 'ready' }, runId);
|
|
272
275
|
return runId;
|
|
273
276
|
}
|
|
274
277
|
|
|
@@ -323,6 +326,10 @@ export function complete(board, id, { result = null, summary = null, metadata =
|
|
|
323
326
|
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
327
|
const first = String(said ?? '').trim().split('\n')[0].slice(0, 400);
|
|
325
328
|
event(db, id, 'completed', { result_len: result ? result.length : 0, summary: first || null }, runId);
|
|
329
|
+
// card ids the handoff names that no card has (Hermes flags them the same way)
|
|
330
|
+
const named = [...new Set(`${summary ?? ''} ${result ?? ''}`.match(/\bt_[a-f0-9]{8,}\b/g) ?? [])];
|
|
331
|
+
const phantom = named.filter((t) => !one(db, 'SELECT 1 FROM tasks WHERE id = ?', t));
|
|
332
|
+
if (phantom.length) event(db, id, 'suspected_hallucinated_references', { phantom_refs: phantom, source: 'completion_summary' }, runId);
|
|
326
333
|
recomputeIn(db);
|
|
327
334
|
});
|
|
328
335
|
}
|
|
@@ -380,7 +387,9 @@ export function requestChanges(board, id, { reason = null, reviewer = null, impl
|
|
|
380
387
|
return tx(board, (db) => {
|
|
381
388
|
const task = must(db, id);
|
|
382
389
|
if (task.status !== 'review' && !(task.status === 'running' && retryStatus(db, id) === 'review')) throw new Error(`${id} is not in review`);
|
|
383
|
-
|
|
390
|
+
// back to the implementer the review request recorded, not to the reviewer now holding it
|
|
391
|
+
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, {}) ?? {};
|
|
392
|
+
const back = implementer ?? asked.implementer ?? task.assignee;
|
|
384
393
|
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
394
|
const runId = endRun(db, id, { outcome: 'changes_requested', summary: reason });
|
|
386
395
|
event(db, id, 'changes_requested', { reason, reviewer, implementer: back }, runId);
|
|
@@ -545,3 +554,212 @@ export function cardContext(db, id) {
|
|
|
545
554
|
}
|
|
546
555
|
return out.join('\n');
|
|
547
556
|
}
|
|
557
|
+
|
|
558
|
+
// ------------------------------------------------------------------ the rest of Hermes's verbs
|
|
559
|
+
|
|
560
|
+
/** 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. */
|
|
561
|
+
export function specify(board, id, { title = null, body = null, author = 'user' } = {}) {
|
|
562
|
+
return tx(board, (db) => {
|
|
563
|
+
const task = must(db, id);
|
|
564
|
+
if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is specified`);
|
|
565
|
+
if (title && String(title).trim()) run(db, 'UPDATE tasks SET title = ? WHERE id = ?', String(title).trim(), id);
|
|
566
|
+
if (body !== null) run(db, 'UPDATE tasks SET body = ? WHERE id = ?', body, id);
|
|
567
|
+
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
568
|
+
const runId = endRun(db, id, { outcome: 'specified', summary: 'specified' });
|
|
569
|
+
event(db, id, 'specified', { author, title_changed: Boolean(title), body_len: body ? body.length : 0 }, runId);
|
|
570
|
+
recomputeIn(db);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A triage card fans out into children it waits on (Hermes's decomposer: the root stays and is the child of every
|
|
576
|
+
* leaf, so it wakes when the graph is done). `children` are `{ title, body?, assignee? }`; answers their ids.
|
|
577
|
+
*/
|
|
578
|
+
export function decompose(board, id, children, { author = 'user' } = {}) {
|
|
579
|
+
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
580
|
+
const task = tx(board, (db) => must(db, id));
|
|
581
|
+
if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is decomposed`);
|
|
582
|
+
const ids = children.map((c) => createCard(board, {
|
|
583
|
+
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
584
|
+
workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
|
|
585
|
+
}).id);
|
|
586
|
+
tx(board, (db) => {
|
|
587
|
+
for (const child of ids) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, id);
|
|
588
|
+
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
589
|
+
const runId = endRun(db, id, { outcome: 'decomposed', summary: `decomposed into ${ids.join(', ')}` });
|
|
590
|
+
event(db, id, 'decomposed', { children: ids, author }, runId);
|
|
591
|
+
});
|
|
592
|
+
return ids;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last. */
|
|
596
|
+
export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = 'swarm-orchestrator', priority = 0, idempotencyKey = null }) {
|
|
597
|
+
if (!String(goal ?? '').trim()) throw new Error('a swarm has a goal');
|
|
598
|
+
if (!workers.length) throw new Error('a swarm has at least one --worker PROFILE:TITLE[:SKILL,SKILL]');
|
|
599
|
+
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`
|
|
600
|
+
+ `- Put machine-readable facts in completion metadata.\n- Put cross-worker notes on the root card as comments.\n- Goal: ${goal.trim()}\n`;
|
|
601
|
+
const root = createCard(board, { title: `Swarm: ${goal.trim()}`.slice(0, 200), body: goal, tenant, createdBy, priority, idempotencyKey, initialStatus: 'blocked' });
|
|
602
|
+
if (root.existing) return { root_id: root.id, existing: true };
|
|
603
|
+
tx(board, (db) => {
|
|
604
|
+
run(db, "UPDATE tasks SET status = 'done', completed_at = ? WHERE id = ?", now(), root.id);
|
|
605
|
+
const runId = synthesizeRun(db, root.id, { outcome: 'completed', summary: 'swarm blackboard', metadata: { swarm: true } });
|
|
606
|
+
event(db, root.id, 'completed', { result_len: 0, summary: 'swarm blackboard' }, runId);
|
|
607
|
+
});
|
|
608
|
+
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);
|
|
609
|
+
const verifierId = createCard(board, { title: 'Verify swarm outputs', body: `Verify the workers' outputs.${protocol(root.id)}`, assignee: verifier, parents: workerIds, tenant, createdBy, priority }).id;
|
|
610
|
+
const synthesizerId = createCard(board, { title: 'Synthesize swarm outputs', body: `Synthesize the verified outputs.${protocol(root.id)}`, assignee: synthesizer, parents: [verifierId], tenant, createdBy, priority }).id;
|
|
611
|
+
return { root_id: root.id, worker_ids: workerIds, verifier_id: verifierId, synthesizer_id: synthesizerId };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Hermes's `edit`: correct a done card's result (and its completed run's summary and metadata). */
|
|
615
|
+
export function editResult(board, id, { result, summary = null, metadata = undefined }) {
|
|
616
|
+
return tx(board, (db) => {
|
|
617
|
+
const task = must(db, id);
|
|
618
|
+
if (task.status !== 'done') throw new Error(`${id} is ${task.status}; edit corrects a done card's result`);
|
|
619
|
+
run(db, 'UPDATE tasks SET result = ? WHERE id = ?', result, id);
|
|
620
|
+
const said = summary ?? result;
|
|
621
|
+
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);
|
|
622
|
+
let runId = last ? Number(last.id) : null;
|
|
623
|
+
if (runId === null) runId = synthesizeRun(db, id, { outcome: 'completed', summary: said, metadata: metadata ?? null });
|
|
624
|
+
else {
|
|
625
|
+
run(db, 'UPDATE task_runs SET summary = ? WHERE id = ?', said, runId);
|
|
626
|
+
if (metadata !== undefined) run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(metadata), runId);
|
|
627
|
+
}
|
|
628
|
+
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);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Send a card in review back to its implementer (ready, or todo while parents are open). */
|
|
633
|
+
export function reopenReview(board, id) {
|
|
634
|
+
return tx(board, (db) => {
|
|
635
|
+
const task = must(db, id);
|
|
636
|
+
if (task.status !== 'review') throw new Error(`${id} is ${task.status}, not in review`);
|
|
637
|
+
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, {}) ?? {};
|
|
638
|
+
const status = parentsDone(db, id) ? 'ready' : 'todo';
|
|
639
|
+
const implementer = typeof handoff.implementer === 'string' && handoff.implementer.trim() ? handoff.implementer : null;
|
|
640
|
+
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]));
|
|
641
|
+
event(db, id, 'review_reopened', { status, ...(implementer ? { implementer } : {}) });
|
|
642
|
+
return status;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** A running card's worker says it is alive (Hermes's `heartbeat`); the dispatcher does not rely on it. */
|
|
647
|
+
export function heartbeat(board, id, note = null) {
|
|
648
|
+
return tx(board, (db) => {
|
|
649
|
+
const task = must(db, id);
|
|
650
|
+
if (task.status !== 'running') throw new Error(`${id} is ${task.status}, not running`);
|
|
651
|
+
const t = now();
|
|
652
|
+
run(db, 'UPDATE tasks SET last_heartbeat_at = ? WHERE id = ?', t, id);
|
|
653
|
+
if (task.current_run_id) run(db, 'UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?', t, task.current_run_id);
|
|
654
|
+
event(db, id, 'heartbeat', note ? { note } : null, task.current_run_id);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** A card's model (and provider) for its next start, or cleared. */
|
|
659
|
+
export function setModel(board, id, model = null, provider = null) {
|
|
660
|
+
if (provider && !model) throw new Error('a provider needs a model');
|
|
661
|
+
return tx(board, (db) => {
|
|
662
|
+
must(db, id);
|
|
663
|
+
run(db, 'UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?', model || null, provider || null, id);
|
|
664
|
+
event(db, id, 'model_override_set', { model: model || null, provider: provider || null });
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function addAttachment(board, id, { file, name, contentType = null, author = 'user', dir }) {
|
|
669
|
+
return tx(board, (db) => {
|
|
670
|
+
must(db, id);
|
|
671
|
+
const r = run(db, 'INSERT INTO task_attachments (task_id, filename, stored_path, content_type, size, uploaded_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
672
|
+
id, name, file.path, contentType, file.size, author, now());
|
|
673
|
+
event(db, id, 'attachment_added', { id: Number(r.lastInsertRowid), filename: name, size: file.size });
|
|
674
|
+
return Number(r.lastInsertRowid);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export const attachments = (db, id) => all(db, 'SELECT * FROM task_attachments WHERE task_id = ? ORDER BY id', id);
|
|
679
|
+
|
|
680
|
+
export function removeAttachment(board, attachmentId) {
|
|
681
|
+
return tx(board, (db) => {
|
|
682
|
+
const row = one(db, 'SELECT * FROM task_attachments WHERE id = ?', attachmentId);
|
|
683
|
+
if (!row) throw new Error(`no attachment ${attachmentId}`);
|
|
684
|
+
run(db, 'DELETE FROM task_attachments WHERE id = ?', attachmentId);
|
|
685
|
+
event(db, row.task_id, 'attachment_removed', { id: Number(attachmentId), filename: row.filename });
|
|
686
|
+
return row;
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Per status, per assignee, and the oldest ready card's age (Hermes's `stats`). */
|
|
691
|
+
export function stats(db) {
|
|
692
|
+
const byStatus = {};
|
|
693
|
+
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);
|
|
694
|
+
const byAssignee = {};
|
|
695
|
+
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);
|
|
696
|
+
const byMachine = {};
|
|
697
|
+
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);
|
|
698
|
+
const oldest = one(db, "SELECT MIN(created_at) AS ts FROM tasks WHERE status = 'ready'")?.ts;
|
|
699
|
+
const t = now();
|
|
700
|
+
return { by_status: byStatus, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, now: t };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export function runs(db, id, { stateType = null, stateName = null } = {}) {
|
|
704
|
+
must(db, id);
|
|
705
|
+
const where = stateType === 'status' ? ' AND status = ?' : stateType === 'outcome' ? ' AND outcome = ?' : '';
|
|
706
|
+
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) }));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Delete the thread events of done and archived cards older than `seconds` (Hermes's `gc` of events). */
|
|
710
|
+
export function gcEvents(board, seconds) {
|
|
711
|
+
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));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ------------------------------------------------------------------ diagnostics
|
|
715
|
+
|
|
716
|
+
const hours = (s) => s / 3600;
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Distress signals on a card, computed on demand and never stored (Hermes's diagnostics, the same kinds where the
|
|
720
|
+
* same thing can go wrong, and supercode's own where only its sessions can): each `{ kind, severity, title, data,
|
|
721
|
+
* actions }`, worst first.
|
|
722
|
+
*/
|
|
723
|
+
export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null } = {}) {
|
|
724
|
+
const out = [];
|
|
725
|
+
const t = now();
|
|
726
|
+
const events = all(db, 'SELECT kind, payload, created_at FROM task_events WHERE task_id = ? ORDER BY id', task.id);
|
|
727
|
+
const failures = Number(task.consecutive_failures || 0);
|
|
728
|
+
if (failures >= Math.max(1, (task.max_retries ?? failureLimit) - 1) && task.status !== 'done') {
|
|
729
|
+
out.push({ kind: 'repeated_failures', severity: task.status === 'blocked' ? 'error' : 'warning', title: `${failures} consecutive failures`,
|
|
730
|
+
data: { failures, last_error: task.last_failure_error }, actions: [`supercode workflow reclaim ${task.id}`, `supercode workflow reassign ${task.id} <profile>`] });
|
|
731
|
+
}
|
|
732
|
+
const phantom = [...events].reverse().find((e) => e.kind === 'suspected_hallucinated_references');
|
|
733
|
+
if (phantom && !events.some((e) => e.kind === 'edited' && e.created_at > phantom.created_at)) {
|
|
734
|
+
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>`] });
|
|
735
|
+
}
|
|
736
|
+
const lost = events.filter((e) => e.kind === 'session_lost' && t - e.created_at < 24 * 3600).length;
|
|
737
|
+
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>`] });
|
|
738
|
+
const crashes = events.filter((e) => e.kind === 'crashed' && t - e.created_at < 24 * 3600).length;
|
|
739
|
+
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}`] });
|
|
740
|
+
if (task.status === 'blocked') {
|
|
741
|
+
const since = [...events].reverse().find((e) => e.kind === 'blocked' || e.kind === 'gave_up')?.created_at ?? task.created_at;
|
|
742
|
+
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}`] });
|
|
743
|
+
const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
|
|
744
|
+
if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
|
|
745
|
+
}
|
|
746
|
+
if (task.status === 'ready' && task.assignee) {
|
|
747
|
+
const since = [...events].reverse().find((e) => ['created', 'promoted', 'unblocked', 'reclaimed', 'session_lost', 'promoted_manual'].includes(e.kind))?.created_at ?? task.created_at;
|
|
748
|
+
const age = t - since;
|
|
749
|
+
if (age >= strandedSeconds) {
|
|
750
|
+
const guard = json([...events].reverse().find((e) => e.kind === 'respawn_guarded')?.payload, {})?.reason;
|
|
751
|
+
out.push({ kind: 'stranded_in_ready', severity: age >= strandedSeconds * 6 ? 'critical' : age >= strandedSeconds * 2 ? 'error' : 'warning',
|
|
752
|
+
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>`] });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (task.status === 'review') {
|
|
756
|
+
const children = all(db, 'SELECT child_id FROM task_links WHERE parent_id = ?', task.id).length;
|
|
757
|
+
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}`] });
|
|
758
|
+
}
|
|
759
|
+
if (task.status === 'running' && fleet?.ok) {
|
|
760
|
+
const session = lastSession(db, task.id);
|
|
761
|
+
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: [] });
|
|
762
|
+
}
|
|
763
|
+
const order = ['critical', 'error', 'warning'];
|
|
764
|
+
return out.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
|
|
765
|
+
}
|
package/board.mjs
CHANGED
|
@@ -54,7 +54,6 @@ export async function startBoard({ root, config = {}, log = () => {} }) {
|
|
|
54
54
|
for (const [task, why] of r.failed ?? []) log('warn', `board ${r.board}: ${task} did not start: ${why}`);
|
|
55
55
|
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a notice for ${task} to ${to} was not delivered: ${why}`);
|
|
56
56
|
if (r.chat_subscriptions && !said.has(`subs:${r.board}`)) { said.add(`subs:${r.board}`); log('warn', `board ${r.board}: ${r.chat_subscriptions} subscription(s) name a chat platform, which Hermes's gateway notifier serves and this dispatcher does not (it tells supercode sessions)`); }
|
|
57
|
-
if (r.triage && !said.has(`triage:${r.board}`)) { said.add(`triage:${r.board}`); log('warn', `board ${r.board}: ${r.triage} triage card(s) wait for decomposition, which Hermes's gateway runs on its auxiliary model and this dispatcher does not`); }
|
|
58
57
|
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
58
|
}
|
|
60
59
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.18",
|
|
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": {
|