@volter/supercode-orchestrator 0.4.32 → 0.4.33
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 +32 -21
- package/board/dispatch.mjs +52 -74
- package/board/engine.mjs +6 -5
- package/board/workflow.mjs +20 -7
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { holdDispatcherLock } from '../hermes-locks.mjs';
|
|
|
13
13
|
import { boardPath, DEFAULT_BOARD, event, json, listBoards, now, openBoard, tx } from './store.mjs';
|
|
14
14
|
import * as engine from './engine.mjs';
|
|
15
15
|
import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused, startCard, tick } from './dispatch.mjs';
|
|
16
|
-
import { loadWorkflow, useWorkflow, workflowEntry } from './workflow.mjs';
|
|
16
|
+
import { loadWorkflow, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
17
17
|
import { evaluate } from './expr.mjs';
|
|
18
18
|
|
|
19
19
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
@@ -26,7 +26,7 @@ Hermes Kanban's verbs, with their flags:
|
|
|
26
26
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
27
27
|
list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
|
|
28
28
|
assign <id> <profile|none> reassign <id> <profile> [--reclaim] [--reason] set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
29
|
-
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] link|unlink <parent> <child>
|
|
29
|
+
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
30
30
|
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
31
31
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
32
32
|
block <id> [reason] [--kind dependency|needs_input|capability|transient] [--ids ...] schedule <id> [reason] [--ids ...]
|
|
@@ -178,7 +178,8 @@ const ts = (t) => {
|
|
|
178
178
|
const p = (n) => String(n).padStart(2, '0');
|
|
179
179
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
180
180
|
};
|
|
181
|
-
|
|
181
|
+
// a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
|
|
182
|
+
const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
|
|
182
183
|
const cardDict = (t) => ({
|
|
183
184
|
id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
|
|
184
185
|
workspace_kind: t.workspace_kind, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
|
|
@@ -189,6 +190,15 @@ const cardDict = (t) => ({
|
|
|
189
190
|
});
|
|
190
191
|
|
|
191
192
|
/** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
|
|
193
|
+
// a moment the caller names: an ISO time, or a duration from now (30m, 2h); epoch seconds, or null
|
|
194
|
+
function untilOf(v) {
|
|
195
|
+
if (v === undefined || v === null || v === '' || v === true) return null;
|
|
196
|
+
if (/^\d+(?:\.\d+)?[smhd]?$/i.test(String(v).trim())) return now() + duration(v);
|
|
197
|
+
const t = Date.parse(String(v));
|
|
198
|
+
if (Number.isNaN(t)) throw new Error(`malformed --until ${v} (expected an ISO time such as 2026-09-29T04:35Z, or a duration such as 2h)`);
|
|
199
|
+
return Math.floor(t / 1000);
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
function duration(v) {
|
|
193
203
|
if (v === undefined || v === null || v === '') return null;
|
|
194
204
|
const s = String(v).trim().toLowerCase();
|
|
@@ -224,8 +234,9 @@ export async function runWorkflow(argv) {
|
|
|
224
234
|
withBoard((b) => { const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
|
|
225
235
|
}
|
|
226
236
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
227
|
-
|
|
228
|
-
|
|
237
|
+
// every verb reads the board through its workflow (a list settles cards too), so every verb loads it
|
|
238
|
+
if (verb !== 'help') await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
239
|
+
engine.setCaller((db, cardId) => rolesFor(db, cardId, sid, workflowFor(root).params));
|
|
229
240
|
|
|
230
241
|
switch (verb) {
|
|
231
242
|
case 'init': {
|
|
@@ -245,15 +256,14 @@ export async function runWorkflow(argv) {
|
|
|
245
256
|
const meta = boardMeta(root, slug);
|
|
246
257
|
let workspace = args.workspace;
|
|
247
258
|
if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
|
|
248
|
-
//
|
|
249
|
-
const goalDefault = !args.goal && !args['no-goal'] && !args['subtask-of'] && (await laneState(root)).kanban.goal_mode_default === true;
|
|
259
|
+
// --goal / --no-goal are the create's words; without either, the workflow's initial decides
|
|
250
260
|
const created = withBoard((b) => engine.createCard(b, {
|
|
251
261
|
title: rest.join(' '), body: args.body, assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
252
262
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
253
263
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
254
264
|
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
|
|
255
265
|
sessionId: process.env.CLAUDE_CODE_SESSION_ID ?? null, subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
256
|
-
project: args.project ?? meta.project_id ?? null, goal: args.goal
|
|
266
|
+
project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
|
|
257
267
|
}), true);
|
|
258
268
|
let answer = { id: created.id, existing: created.existing };
|
|
259
269
|
if (!args['no-start'] && !created.existing && !args['subtask-of']) {
|
|
@@ -318,6 +328,7 @@ export async function runWorkflow(argv) {
|
|
|
318
328
|
const got = engine.showCard(b.db, id);
|
|
319
329
|
got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
|
|
320
330
|
got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit });
|
|
331
|
+
got.stage = statusOf(b.db, got.task);
|
|
321
332
|
return got;
|
|
322
333
|
});
|
|
323
334
|
const t = card.task;
|
|
@@ -326,7 +337,7 @@ export async function runWorkflow(argv) {
|
|
|
326
337
|
say(args, '', { task: cardDict(t), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
|
|
327
338
|
return 0;
|
|
328
339
|
}
|
|
329
|
-
const out = [`Task ${t.id}: ${t.title}`, ` status: ${t.status}`, ` assignee: ${t.assignee || '-'}`];
|
|
340
|
+
const out = [`Task ${t.id}: ${t.title}`, ` status: ${card.stage}${card.stage !== t.status ? ` (${t.status} in kanban.db)` : ''}`, ` assignee: ${t.assignee || '-'}`];
|
|
330
341
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
331
342
|
if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
|
|
332
343
|
if (t.tenant) out.push(` tenant: ${t.tenant}`);
|
|
@@ -416,7 +427,7 @@ export async function runWorkflow(argv) {
|
|
|
416
427
|
return 0;
|
|
417
428
|
}
|
|
418
429
|
case 'reclaim': need(1, 'a card id'); await withBoardAsync((b) => engine.reclaim(b, id, args.reason ?? (rest.slice(1).join(' ') || null))); say(args, `Reclaimed ${id}`, { ok: true }); return 0;
|
|
419
|
-
case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null)); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
|
|
430
|
+
case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null, { until: untilOf(args.until) })); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
|
|
420
431
|
|
|
421
432
|
case 'link': need(2, 'a parent and a child'); await withBoardAsync((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
|
|
422
433
|
case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `no link ${rest[0]} -> ${rest[1]}`, { ok: gone }); return gone ? 0 : 1; }
|
|
@@ -489,7 +500,7 @@ export async function runWorkflow(argv) {
|
|
|
489
500
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
490
501
|
}
|
|
491
502
|
case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
492
|
-
case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ??
|
|
503
|
+
case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
|
|
493
504
|
case 'reopen': {
|
|
494
505
|
// a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
|
|
495
506
|
need(1, 'a card id');
|
|
@@ -652,14 +663,13 @@ export async function runWorkflow(argv) {
|
|
|
652
663
|
|
|
653
664
|
case 'dispatch': {
|
|
654
665
|
const { lanes, settings } = await laneState(root);
|
|
655
|
-
|
|
656
|
-
if (args.max) settings.maxInProgress = Number(args.max);
|
|
666
|
+
const params = { ...(args['failure-limit'] ? { failure_limit: Number(args['failure-limit']) } : {}), ...(args.max ? { max_spawn: Number(args.max) } : {}) };
|
|
657
667
|
if (args['dry-run']) {
|
|
658
668
|
const would = withBoard((b) => engine.listCards(b.db, { status: 'ready' }).filter((t) => t.assignee && lanes[t.assignee] && !t.subtask_of).map((t) => ({ id: t.id, assignee: t.assignee, machine: t.machine, surface: lanes[t.assignee].surface })));
|
|
659
669
|
say(args, would.length ? would.map((w) => `would start ${w.id} as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`).join('\n') : 'nothing would start', would);
|
|
660
670
|
return 0;
|
|
661
671
|
}
|
|
662
|
-
const reports = await tick({ root, lanes, settings });
|
|
672
|
+
const reports = await tick({ root, lanes, settings, params });
|
|
663
673
|
if (args.json) say(args, '', reports);
|
|
664
674
|
else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
|
|
665
675
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
@@ -963,8 +973,11 @@ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', '
|
|
|
963
973
|
* is `manager`; the session of the card's current run has that run's role; the card's working session between runs
|
|
964
974
|
* is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
|
|
965
975
|
*/
|
|
966
|
-
function rolesFor(db, cardId, sessionId) {
|
|
976
|
+
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
967
977
|
if (!sessionId) return ['manager'];
|
|
978
|
+
// a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
|
|
979
|
+
const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
|
|
980
|
+
if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
|
|
968
981
|
const task = engine.getTask(db, cardId);
|
|
969
982
|
if (!task) return [];
|
|
970
983
|
const roles = [];
|
|
@@ -1024,11 +1037,9 @@ export function summary(r) {
|
|
|
1024
1037
|
async function serve(root, args = {}) {
|
|
1025
1038
|
const lock = await holdDispatcherLock(root);
|
|
1026
1039
|
// Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
|
|
1027
|
-
const override = {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
...(Number(args['failure-limit']) > 0 ? { failureLimit: Number(args['failure-limit']) } : {}),
|
|
1031
|
-
};
|
|
1040
|
+
const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
|
|
1041
|
+
// --max and --failure-limit are params of every tick, read by the workflow as its own
|
|
1042
|
+
const params = { ...(Number(args.max) > 0 ? { max_spawn: Number(args.max) } : {}), ...(Number(args['failure-limit']) > 0 ? { failure_limit: Number(args['failure-limit']) } : {}) };
|
|
1032
1043
|
if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
|
|
1033
1044
|
let stopped = false;
|
|
1034
1045
|
const stop = () => { stopped = true; };
|
|
@@ -1048,7 +1059,7 @@ async function serve(root, args = {}) {
|
|
|
1048
1059
|
const state = await laneState(root);
|
|
1049
1060
|
const settings = { ...state.settings, ...override };
|
|
1050
1061
|
interval = settings.interval;
|
|
1051
|
-
for (const r of await tick({ root, lanes: state.lanes, settings })) {
|
|
1062
|
+
for (const r of await tick({ root, lanes: state.lanes, settings, params })) {
|
|
1052
1063
|
const said = summary(r);
|
|
1053
1064
|
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
1054
1065
|
if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -30,13 +30,6 @@ import {
|
|
|
30
30
|
} from './engine.mjs';
|
|
31
31
|
|
|
32
32
|
const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
|
|
33
|
-
const MAIL_KINDS = {
|
|
34
|
-
creator: ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', 'status', 'archived', 'block_loop_detected', 'review_requested',
|
|
35
|
-
'changes_requested', 'session_started', 'session_resumed', 'session_replaced', 'session_adopted', 'session_lost', 'spawn_failed'],
|
|
36
|
-
worker: ['commented', 'assigned', 'machine_set', 'blocked', 'unblocked', 'completed', 'archived', 'changes_requested', 'linked', 'unlinked', 'promoted_manual', 'subtask_added', 'session_adopted'],
|
|
37
|
-
// a card the session's own card depends on: it sees it change
|
|
38
|
-
dependency: ['completed', 'blocked', 'unblocked', 'archived', 'review_requested', 'changes_requested', 'commented'],
|
|
39
|
-
};
|
|
40
33
|
|
|
41
34
|
// ------------------------------------------------------------------ names and the fleet
|
|
42
35
|
|
|
@@ -107,30 +100,26 @@ function derivedMaxInProgress() {
|
|
|
107
100
|
} catch { return null; }
|
|
108
101
|
}
|
|
109
102
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
103
|
+
/**
|
|
104
|
+
* What the platform itself reads from the workflow's params (everything else the dispatcher does is the workflow's
|
|
105
|
+
* transitions and `dispatch`): the claim ttl, the session grace, the tick interval, log rotation, hooks, the failure
|
|
106
|
+
* limit the diagnostics report against, and Hermes's environment override for the crash grace.
|
|
107
|
+
*/
|
|
108
|
+
export function boardSettings(params = {}) {
|
|
112
109
|
const pos = (v) => (Number.isInteger(Number(v)) && Number(v) >= 1 ? Number(v) : null);
|
|
110
|
+
const env = (name, fallback) => {
|
|
111
|
+
const raw = process.env[name];
|
|
112
|
+
return raw !== undefined && raw !== '' && Number.isInteger(Number(raw)) && Number(raw) >= 0 ? Number(raw) : fallback;
|
|
113
|
+
};
|
|
113
114
|
return {
|
|
114
|
-
failureLimit: pos(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
machines: Object.fromEntries(Object.entries(kanban.machines ?? {}).map(([name, m]) => [normalMachine(name), { capacity: pos(m?.capacity) }])),
|
|
123
|
-
interval: Math.max(Number(kanban.dispatch_interval_seconds) || 60, 1),
|
|
124
|
-
staleTimeout: pos(kanban.dispatch_stale_timeout_seconds),
|
|
125
|
-
autoDecompose: kanban.auto_decompose !== false,
|
|
126
|
-
autoDecomposePerTick: pos(kanban.auto_decompose_per_tick) ?? 3,
|
|
127
|
-
goalMaxTurns: pos(kanban.goal_max_turns) ?? 20,
|
|
128
|
-
maxSpawn: pos(kanban.max_spawn),
|
|
129
|
-
reconcileOrphans: kanban.reconcile_orphans !== false,
|
|
130
|
-
logRotateBytes: pos(kanban.worker_log_rotate_bytes) ?? 2 * 1024 * 1024,
|
|
131
|
-
logBackups: Number.isInteger(Number(kanban.worker_log_backup_count)) && Number(kanban.worker_log_backup_count) >= 0 ? Number(kanban.worker_log_backup_count) : 1,
|
|
132
|
-
hooks: kanban.hooks && typeof kanban.hooks === 'object' ? kanban.hooks : {},
|
|
133
|
-
rateLimitCooldown: Number.isInteger(Number(process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS)) && process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS !== '' ? Number(process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS) : 300,
|
|
115
|
+
failureLimit: pos(params.failure_limit) ?? DEFAULT_FAILURE_LIMIT,
|
|
116
|
+
ttl: claimTtl(params.claim_ttl_seconds),
|
|
117
|
+
grace: pos(params.session_grace_seconds) ?? 120,
|
|
118
|
+
interval: Math.max(Number(params.dispatch_interval_seconds) || 60, 1),
|
|
119
|
+
logRotateBytes: pos(params.worker_log_rotate_bytes) ?? 2 * 1024 * 1024,
|
|
120
|
+
logBackups: Number.isInteger(Number(params.worker_log_backup_count)) && Number(params.worker_log_backup_count) >= 0 ? Number(params.worker_log_backup_count) : 1,
|
|
121
|
+
hooks: params.hooks && typeof params.hooks === 'object' ? params.hooks : {},
|
|
122
|
+
crashGrace: env('HERMES_KANBAN_CRASH_GRACE_SECONDS', 30),
|
|
134
123
|
};
|
|
135
124
|
}
|
|
136
125
|
|
|
@@ -171,7 +160,8 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
171
160
|
const limits = limitsOf(db, board, entry, task, session);
|
|
172
161
|
// Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
|
|
173
162
|
const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
|
|
174
|
-
|
|
163
|
+
// (whether it is reconciled is the workflow's: its `orphaned` transitions and their guard)
|
|
164
|
+
if ((!task.claim_lock || !task.claim_expires) && !live) {
|
|
175
165
|
observe('orphaned', { reason: 'orphaned_running', claim_lock: task.claim_lock, claim_expires: task.claim_expires, error: 'orphaned running card (broken claim bookkeeping)' }, 'reclaimed');
|
|
176
166
|
return;
|
|
177
167
|
}
|
|
@@ -737,39 +727,6 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
737
727
|
|
|
738
728
|
// ------------------------------------------------------------------ notify
|
|
739
729
|
|
|
740
|
-
function notice(kind, task, p, role) {
|
|
741
|
-
const tid = task?.id ?? '?';
|
|
742
|
-
const who = task?.assignee ? `@${task.assignee} ` : '';
|
|
743
|
-
const title = task?.title ?? '';
|
|
744
|
-
switch (kind) {
|
|
745
|
-
case 'session_started': case 'session_resumed': case 'session_replaced': {
|
|
746
|
-
const verb = { session_started: 'started', session_resumed: 'resumed', session_replaced: 'could not resume the card\'s last session and started' }[kind];
|
|
747
|
-
return `▶ ${who}card ${tid} — ${title}: the board ${verb} session ${p.session_id} on ${p.machine} (pane ${p.pane}; ${p.address}).`;
|
|
748
|
-
}
|
|
749
|
-
case 'session_adopted': return role === 'worker'
|
|
750
|
-
? `▶ card ${tid} — ${title} is running again, with you: carry it on from where it stands (\`supercode workflow show ${tid}\` reads it).`
|
|
751
|
-
: `▶ ${who}card ${tid} — ${title}: the board found session ${p.session_id} still live on ${p.machine} and kept it (${p.address}).`;
|
|
752
|
-
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.`;
|
|
753
|
-
case 'spawn_failed': return `✖ ${who}card ${tid}: its session did not start: ${String(p.error ?? '').slice(0, 300)}`;
|
|
754
|
-
case 'completed': return `✔ ${who}card ${tid} done — ${title}${p.summary ? `\n${String(p.summary).slice(0, 300)}` : ''}`;
|
|
755
|
-
case 'blocked': return `⏸ ${who}card ${tid} blocked${p.reason ? `: ${String(p.reason).slice(0, 300)}` : ''}`;
|
|
756
|
-
case 'unblocked': return `▶ card ${tid} unblocked`;
|
|
757
|
-
case 'gave_up': return `✖ ${who}card ${tid} gave up after ${p.failures} failures${p.error ? `\n${String(p.error).slice(0, 300)}` : ''}`;
|
|
758
|
-
case 'crashed': return `✖ ${who}card ${tid}: its worker exited without completing it; the board will retry`;
|
|
759
|
-
case 'timed_out': return `⏱ ${who}card ${tid} timed out (max_runtime ${p.limit_seconds}s); the board will retry`;
|
|
760
|
-
case 'status': return `🔄 ${who}card ${tid} → ${p.status}`;
|
|
761
|
-
case 'archived': return role === 'worker' ? `card ${tid} archived` : null;
|
|
762
|
-
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)}` : ''}`;
|
|
763
|
-
case 'review_requested': return `👀 ${who}card ${tid} ready for review — ${title}${p.summary ? `\n${String(p.summary).slice(0, 300)}` : ''}`;
|
|
764
|
-
case 'changes_requested': return `🛑 card ${tid}: review asked for changes: ${String(p.reason ?? 'reviewer feedback').slice(0, 300)}`;
|
|
765
|
-
case 'assigned': return `card ${tid} is now assigned to ${p.assignee ?? 'nobody'}`;
|
|
766
|
-
case 'machine_set': return `card ${tid} now runs on ${p.machine ?? 'no machine'}`;
|
|
767
|
-
case 'linked': case 'unlinked': return `card ${tid}: dependency ${kind} (${p.parent} → ${p.child})`;
|
|
768
|
-
case 'promoted_manual': return `card ${tid} promoted to ready by ${p.actor}`;
|
|
769
|
-
case 'subtask_added': return `card ${tid} has a new subtask ${p.subtask}: ${p.title}`;
|
|
770
|
-
default: return null;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
730
|
|
|
774
731
|
// a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
|
|
775
732
|
function deliverNow(board, to, body, key) {
|
|
@@ -796,7 +753,22 @@ export const boardAddress = (label) => `sc:${localMachine()}:board:${label.repla
|
|
|
796
753
|
/** Consecutive failed sends after which a subscription is dropped (Hermes's notifier drops a dead chat after 12). */
|
|
797
754
|
const MAX_SEND_FAILURES = 12;
|
|
798
755
|
|
|
799
|
-
|
|
756
|
+
/**
|
|
757
|
+
* What an audience is told of an event: the workflow's `notify[audience][kind]` template over the card, the event's
|
|
758
|
+
* payload and (for a comment) the comment; null when the workflow names no such notice.
|
|
759
|
+
*/
|
|
760
|
+
export function noticeOf(entry, audience, kind, task, payload, comment = null) {
|
|
761
|
+
const template = entry.workflow.notify?.[audience]?.[kind];
|
|
762
|
+
if (template === undefined || template === null) return null;
|
|
763
|
+
const cut = (text, n) => String(text ?? '').slice(0, Number(n));
|
|
764
|
+
const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
|
|
765
|
+
return text.trim() ? text : null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** The event kinds an audience hears (the workflow's notify). */
|
|
769
|
+
const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
|
|
770
|
+
|
|
771
|
+
async function notify(board, fleet, report, entry) {
|
|
800
772
|
const from = boardAddress(board.label);
|
|
801
773
|
// each subscription's notices go in order; different subscribers are told side by side
|
|
802
774
|
await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
|
|
@@ -806,7 +778,8 @@ async function notify(board, fleet, report) {
|
|
|
806
778
|
tx(board, (db) => db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id));
|
|
807
779
|
return;
|
|
808
780
|
}
|
|
809
|
-
const kinds =
|
|
781
|
+
const kinds = heardBy(entry, role);
|
|
782
|
+
if (!kinds.length) return;
|
|
810
783
|
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`)
|
|
811
784
|
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
812
785
|
if (!events.length) return;
|
|
@@ -819,9 +792,9 @@ async function notify(board, fleet, report) {
|
|
|
819
792
|
let text;
|
|
820
793
|
if (ev.kind === 'commented') {
|
|
821
794
|
if (p.author === sub.chat_id) { cursor = ev.id; continue; }
|
|
822
|
-
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);
|
|
823
|
-
text = c ?
|
|
824
|
-
} else text =
|
|
795
|
+
const c = board.db.prepare('SELECT author, 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);
|
|
796
|
+
text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
|
|
797
|
+
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
825
798
|
if (text) {
|
|
826
799
|
// Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
|
|
827
800
|
await send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
|
|
@@ -846,7 +819,6 @@ async function notify(board, fleet, report) {
|
|
|
846
819
|
}
|
|
847
820
|
|
|
848
821
|
// Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
|
|
849
|
-
const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', 'status', 'archived', 'unblocked', 'block_loop_detected', 'review_requested', 'changes_requested'];
|
|
850
822
|
|
|
851
823
|
/**
|
|
852
824
|
* A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
|
|
@@ -854,6 +826,10 @@ const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', '
|
|
|
854
826
|
*/
|
|
855
827
|
export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
856
828
|
const sent = [];
|
|
829
|
+
// what a chat is told is the workflow's (notify.chat)
|
|
830
|
+
const entry = await loadWorkflow(root, { fresh: true });
|
|
831
|
+
const kinds = heardBy(entry, 'chat');
|
|
832
|
+
if (!kinds.length) return sent;
|
|
857
833
|
for (const slug of listBoards(root)) {
|
|
858
834
|
const board = openBoard(root, slug);
|
|
859
835
|
board.label = boardLabel(root, slug);
|
|
@@ -861,14 +837,14 @@ export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
|
861
837
|
for (const sub of subscriptions(board.db)) {
|
|
862
838
|
const adapter = adapters?.[sub.platform];
|
|
863
839
|
if (sub.platform === 'supercode' || !adapter) continue;
|
|
864
|
-
const events = board.db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${
|
|
840
|
+
const events = board.db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`).all(sub.task_id, sub.last_event_id, ...kinds);
|
|
865
841
|
if (!events.length) continue;
|
|
866
842
|
const task = tx(board, (db) => getTask(db, sub.task_id));
|
|
867
843
|
let cursor = Number(sub.last_event_id);
|
|
868
844
|
let failed = false;
|
|
869
845
|
for (const ev of events) {
|
|
870
846
|
const payload = json(ev.payload, {}) ?? {};
|
|
871
|
-
const text =
|
|
847
|
+
const text = noticeOf(entry, 'chat', ev.kind, task, payload);
|
|
872
848
|
if (text) {
|
|
873
849
|
// a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
|
|
874
850
|
const files = ev.kind === 'completed' ? artifactsOf(payload, task).map((path) => ({ path })) : [];
|
|
@@ -1063,8 +1039,10 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
|
|
|
1063
1039
|
* `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
|
|
1064
1040
|
* per-tick lock (Hermes's `.dispatch.lock`), skipped when another dispatcher holds it. Answers one report per board.
|
|
1065
1041
|
*/
|
|
1066
|
-
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
|
|
1067
|
-
|
|
1042
|
+
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {} }) {
|
|
1043
|
+
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
1044
|
+
const loaded = await loadWorkflow(root, { fresh: true });
|
|
1045
|
+
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
1068
1046
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
1069
1047
|
const fleetAt = Date.now();
|
|
1070
1048
|
const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
@@ -1103,7 +1081,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1103
1081
|
// what the moves asked for, once they are committed (messages to actors, closed panes, cleaned workspaces)
|
|
1104
1082
|
await runEffects(board, effects);
|
|
1105
1083
|
phase('goals');
|
|
1106
|
-
await notify(board, fleet, report); phase('notify');
|
|
1084
|
+
await notify(board, fleet, report, entry); phase('notify');
|
|
1107
1085
|
fireHooks(board, settings, report);
|
|
1108
1086
|
if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
|
1109
1087
|
report.ready_waiting = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'ready' AND t.assignee IS NOT NULL AND c.subtask_of IS NULL").get().n);
|
package/board/engine.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
|
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
14
14
|
import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
15
|
-
import { fire,
|
|
15
|
+
import { fire, loadWorkflow, setStatus, settle, startNew, statusOf, workflowFor } from './workflow.mjs';
|
|
16
16
|
|
|
17
17
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
18
18
|
export const BLOCK_KINDS = ['dependency', 'needs_input', 'capability', 'transient'];
|
|
@@ -203,8 +203,9 @@ export function createCard(board, spec) {
|
|
|
203
203
|
for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
|
|
204
204
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
205
205
|
const entry = workflowFor(board.root);
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
207
|
+
const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
|
|
208
|
+
goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
208
209
|
event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
209
210
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
210
211
|
if (arc) {
|
|
@@ -449,7 +450,7 @@ export const reclaim = (board, id, reason = null, { roles } = {}) => act(board,
|
|
|
449
450
|
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
450
451
|
export const specify = (board, id, { title = null, body = null, author = 'user', roles } = {}) => act(board, id, 'specify', { roles, payload: { title, body, author } });
|
|
451
452
|
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
452
|
-
export const heartbeat = (board, id, note = null, { roles } = {}) => act(board, id, 'heartbeat', { roles, payload: { note } });
|
|
453
|
+
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
453
454
|
export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
|
|
454
455
|
if (provider && !model) throw new Error('a provider needs a model');
|
|
455
456
|
return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
|
|
@@ -539,7 +540,7 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
|
|
|
539
540
|
// ------------------------------------------------------------------ reading
|
|
540
541
|
|
|
541
542
|
export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
|
|
542
|
-
const rows = all(db, `SELECT t.*, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
543
|
+
const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
543
544
|
WHERE (? IS NULL OR t.status = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
544
545
|
ORDER BY t.priority DESC, t.created_at ASC`, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
|
|
545
546
|
return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [] }));
|
package/board/workflow.mjs
CHANGED
|
@@ -98,10 +98,14 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
98
98
|
queue_reason: json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'respawn_guarded' AND created_at >= ? ORDER BY id DESC LIMIT 1",
|
|
99
99
|
task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null,
|
|
100
100
|
last_block_event: one(db, "SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1", task.id)?.kind ?? null,
|
|
101
|
-
|
|
101
|
+
// where the latest interruption says the card resumes (any status the workflow declares); none recorded: null
|
|
102
|
+
resume_status: interruption.resume_status ?? interruption.retry_status ?? interruption.source_status ?? null,
|
|
102
103
|
last_run: lastRun ?? {},
|
|
103
104
|
last_completed: lastCompleted ?? null,
|
|
104
105
|
events_since: (t, kinds = []) => all(db, `SELECT kind FROM task_events WHERE task_id = ? AND created_at >= ?${kinds.length ? ` AND kind IN (${kinds.map(() => '?').join(', ')})` : ''}`, task.id, t, ...kinds),
|
|
106
|
+
// the latest event of each kind on the card: kind → { created_at, payload }
|
|
107
|
+
last_events: Object.fromEntries(all(db, 'SELECT e.kind, e.created_at, e.payload FROM task_events e JOIN (SELECT kind, MAX(id) AS id FROM task_events WHERE task_id = ? GROUP BY kind) l ON l.id = e.id', task.id)
|
|
108
|
+
.map((r) => [r.kind, { created_at: r.created_at, payload: json(r.payload, {}) ?? {} }])),
|
|
105
109
|
comments_since: (t) => all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? AND created_at >= ?', task.id, t),
|
|
106
110
|
};
|
|
107
111
|
return {
|
|
@@ -235,12 +239,20 @@ function dueAfter(db, workflow, task, status, scope) {
|
|
|
235
239
|
return null;
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
|
|
244
|
+
* set and its actions run (a goal card by the workflow's default, for one). Answers the status.
|
|
245
|
+
*/
|
|
246
|
+
export function startNew(db, board, entry, task, event) {
|
|
247
|
+
const scope = scopeOf(db, board, task, { params: entry.params, event, extra: { lanes: lanesIn(entry) } });
|
|
241
248
|
const t = entry.workflow.initial.find((x) => holds(x.guard ?? null, scope));
|
|
242
249
|
if (!t?.target) throw new Error('the workflow says nowhere for a new card to start');
|
|
243
|
-
|
|
250
|
+
const status = String(templateValue(t.target, scope));
|
|
251
|
+
if (!entry.workflow.statuses[status]) throw new Error(`the workflow starts a new card in ${status}, which it does not declare`);
|
|
252
|
+
setStatus(db, entry.workflow, task.id, status);
|
|
253
|
+
const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from: status, to: status, effects: [], runId: null };
|
|
254
|
+
for (const a of t.actions ?? []) act(ctx, a);
|
|
255
|
+
return status;
|
|
244
256
|
}
|
|
245
257
|
|
|
246
258
|
// ------------------------------------------------------------------ actions
|
|
@@ -270,7 +282,8 @@ const FORMAT = {
|
|
|
270
282
|
block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
|
|
271
283
|
dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, source_status: source }),
|
|
272
284
|
review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
|
|
273
|
-
|
|
285
|
+
// the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
|
|
286
|
+
changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
|
|
274
287
|
review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
|
|
275
288
|
unblocked: ({ to }) => (to !== 'ready' ? { status: to, resume_status: to } : null),
|
|
276
289
|
gave_up: ({ e, after, params, outcome }) => ({ error: String(e.error ?? '').slice(0, 500) || null, failures: Number(after.consecutive_failures || 0), limit: after.max_retries ?? params.failure_limit, outcome }),
|
|
@@ -283,7 +296,7 @@ const FORMAT = {
|
|
|
283
296
|
assigned: ({ after }) => ({ assignee: after.assignee ?? null }),
|
|
284
297
|
model_override_set: ({ e }) => ({ model: e.model || null, provider: e.provider || null }),
|
|
285
298
|
goal_set: ({ after }) => ({ goal_mode: Boolean(after.goal_mode), goal_max_turns: after.goal_mode ? after.goal_max_turns ?? null : null }),
|
|
286
|
-
heartbeat: ({ e }) => (e.note ? { note: e.note } : null),
|
|
299
|
+
heartbeat: ({ e }) => (e.note || e.until ? { ...(e.note ? { note: e.note } : {}), ...(e.until ? { until: Number(e.until) } : {}) } : null),
|
|
287
300
|
commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
|
|
288
301
|
archived: () => null,
|
|
289
302
|
status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.33",
|
|
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": {
|