@volter/supercode-orchestrator 0.4.32 → 0.4.34
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 +56 -75
- package/board/engine.mjs +8 -45
- 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
|
}
|
|
@@ -533,7 +523,10 @@ function memoryPressure() {
|
|
|
533
523
|
|
|
534
524
|
/** Record a guard reason once per change, not once per tick. */
|
|
535
525
|
function noteGuard(db, id, reason) {
|
|
536
|
-
|
|
526
|
+
// once per reason per entry into the status: a card back in ready (a new `since`) is noted again, so the workflow's
|
|
527
|
+
// after-timers that read the reason (card.queue_reason) see it
|
|
528
|
+
const since = Number(db.prepare('SELECT since FROM supercode_cards WHERE task_id = ?').get(id)?.since ?? 0);
|
|
529
|
+
const last = db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('respawn_guarded', 'claimed') AND created_at >= ? ORDER BY id DESC LIMIT 1").get(id, since);
|
|
537
530
|
if (json(last?.payload, {})?.reason === reason) return;
|
|
538
531
|
event(db, id, 'respawn_guarded', { reason });
|
|
539
532
|
}
|
|
@@ -737,39 +730,6 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
737
730
|
|
|
738
731
|
// ------------------------------------------------------------------ notify
|
|
739
732
|
|
|
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
733
|
|
|
774
734
|
// a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
|
|
775
735
|
function deliverNow(board, to, body, key) {
|
|
@@ -796,7 +756,22 @@ export const boardAddress = (label) => `sc:${localMachine()}:board:${label.repla
|
|
|
796
756
|
/** Consecutive failed sends after which a subscription is dropped (Hermes's notifier drops a dead chat after 12). */
|
|
797
757
|
const MAX_SEND_FAILURES = 12;
|
|
798
758
|
|
|
799
|
-
|
|
759
|
+
/**
|
|
760
|
+
* What an audience is told of an event: the workflow's `notify[audience][kind]` template over the card, the event's
|
|
761
|
+
* payload and (for a comment) the comment; null when the workflow names no such notice.
|
|
762
|
+
*/
|
|
763
|
+
export function noticeOf(entry, audience, kind, task, payload, comment = null) {
|
|
764
|
+
const template = entry.workflow.notify?.[audience]?.[kind];
|
|
765
|
+
if (template === undefined || template === null) return null;
|
|
766
|
+
const cut = (text, n) => String(text ?? '').slice(0, Number(n));
|
|
767
|
+
const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
|
|
768
|
+
return text.trim() ? text : null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** The event kinds an audience hears (the workflow's notify). */
|
|
772
|
+
const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
|
|
773
|
+
|
|
774
|
+
async function notify(board, fleet, report, entry) {
|
|
800
775
|
const from = boardAddress(board.label);
|
|
801
776
|
// each subscription's notices go in order; different subscribers are told side by side
|
|
802
777
|
await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
|
|
@@ -806,7 +781,8 @@ async function notify(board, fleet, report) {
|
|
|
806
781
|
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
782
|
return;
|
|
808
783
|
}
|
|
809
|
-
const kinds =
|
|
784
|
+
const kinds = heardBy(entry, role);
|
|
785
|
+
if (!kinds.length) return;
|
|
810
786
|
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
787
|
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
812
788
|
if (!events.length) return;
|
|
@@ -819,9 +795,9 @@ async function notify(board, fleet, report) {
|
|
|
819
795
|
let text;
|
|
820
796
|
if (ev.kind === 'commented') {
|
|
821
797
|
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 =
|
|
798
|
+
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);
|
|
799
|
+
text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
|
|
800
|
+
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
825
801
|
if (text) {
|
|
826
802
|
// Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
|
|
827
803
|
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 +822,6 @@ async function notify(board, fleet, report) {
|
|
|
846
822
|
}
|
|
847
823
|
|
|
848
824
|
// 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
825
|
|
|
851
826
|
/**
|
|
852
827
|
* A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
|
|
@@ -854,6 +829,10 @@ const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', '
|
|
|
854
829
|
*/
|
|
855
830
|
export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
856
831
|
const sent = [];
|
|
832
|
+
// what a chat is told is the workflow's (notify.chat)
|
|
833
|
+
const entry = await loadWorkflow(root, { fresh: true });
|
|
834
|
+
const kinds = heardBy(entry, 'chat');
|
|
835
|
+
if (!kinds.length) return sent;
|
|
857
836
|
for (const slug of listBoards(root)) {
|
|
858
837
|
const board = openBoard(root, slug);
|
|
859
838
|
board.label = boardLabel(root, slug);
|
|
@@ -861,14 +840,14 @@ export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
|
861
840
|
for (const sub of subscriptions(board.db)) {
|
|
862
841
|
const adapter = adapters?.[sub.platform];
|
|
863
842
|
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 (${
|
|
843
|
+
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
844
|
if (!events.length) continue;
|
|
866
845
|
const task = tx(board, (db) => getTask(db, sub.task_id));
|
|
867
846
|
let cursor = Number(sub.last_event_id);
|
|
868
847
|
let failed = false;
|
|
869
848
|
for (const ev of events) {
|
|
870
849
|
const payload = json(ev.payload, {}) ?? {};
|
|
871
|
-
const text =
|
|
850
|
+
const text = noticeOf(entry, 'chat', ev.kind, task, payload);
|
|
872
851
|
if (text) {
|
|
873
852
|
// a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
|
|
874
853
|
const files = ev.kind === 'completed' ? artifactsOf(payload, task).map((path) => ({ path })) : [];
|
|
@@ -1063,8 +1042,10 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
|
|
|
1063
1042
|
* `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
|
|
1064
1043
|
* per-tick lock (Hermes's `.dispatch.lock`), skipped when another dispatcher holds it. Answers one report per board.
|
|
1065
1044
|
*/
|
|
1066
|
-
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
|
|
1067
|
-
|
|
1045
|
+
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {} }) {
|
|
1046
|
+
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
1047
|
+
const loaded = await loadWorkflow(root, { fresh: true });
|
|
1048
|
+
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
1068
1049
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
1069
1050
|
const fleetAt = Date.now();
|
|
1070
1051
|
const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
@@ -1103,7 +1084,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1103
1084
|
// what the moves asked for, once they are committed (messages to actors, closed panes, cleaned workspaces)
|
|
1104
1085
|
await runEffects(board, effects);
|
|
1105
1086
|
phase('goals');
|
|
1106
|
-
await notify(board, fleet, report); phase('notify');
|
|
1087
|
+
await notify(board, fleet, report, entry); phase('notify');
|
|
1107
1088
|
fireHooks(board, settings, report);
|
|
1108
1089
|
if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
|
1109
1090
|
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'];
|
|
@@ -130,15 +130,6 @@ function retryStatus(db, taskId) {
|
|
|
130
130
|
return source === 'review' || source === 'triage' ? source : 'ready';
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/** The phase a card blocked out of comes back to (Hermes reads it off the latest interruption). */
|
|
134
|
-
function resumeStatus(db, taskId) {
|
|
135
|
-
const row = one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
|
|
136
|
-
'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
|
|
137
|
-
ORDER BY id DESC LIMIT 1`, taskId);
|
|
138
|
-
const p = json(row?.payload, {}) ?? {};
|
|
139
|
-
return ['resume_status', 'retry_status', 'source_status'].some((k) => p[k] === 'review') ? 'review' : 'ready';
|
|
140
|
-
}
|
|
141
|
-
|
|
142
133
|
// ------------------------------------------------------------------ creating and linking
|
|
143
134
|
|
|
144
135
|
/**
|
|
@@ -203,8 +194,9 @@ export function createCard(board, spec) {
|
|
|
203
194
|
for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
|
|
204
195
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
205
196
|
const entry = workflowFor(board.root);
|
|
206
|
-
|
|
207
|
-
|
|
197
|
+
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
198
|
+
const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
|
|
199
|
+
goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
208
200
|
event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
209
201
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
210
202
|
if (arc) {
|
|
@@ -289,35 +281,6 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
|
|
|
289
281
|
return true;
|
|
290
282
|
}
|
|
291
283
|
|
|
292
|
-
/** Put a running card back where its run came from, closing the run with `outcome`. Answers the closed run id. */
|
|
293
|
-
export function release(db, id, { outcome = 'reclaimed', error = null, metadata = undefined, kind = 'reclaimed', payload = {} } = {}) {
|
|
294
|
-
const task = one(db, 'SELECT status, claim_lock FROM tasks WHERE id = ?', id);
|
|
295
|
-
if (!task || task.status !== 'running') return null;
|
|
296
|
-
const retry = retryStatus(db, id);
|
|
297
|
-
run(db, 'UPDATE tasks SET status = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', retry, id);
|
|
298
|
-
const runId = endRun(db, id, { outcome, status: outcome, error, metadata });
|
|
299
|
-
event(db, id, kind, { ...payload, retry_status: retry }, runId);
|
|
300
|
-
return runId;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Count a failure against the card (Hermes's circuit breaker): at the card's `max_retries`, else `failureLimit`,
|
|
305
|
-
* consecutive failures it is blocked with a `gave_up` event. Answers true when it tripped.
|
|
306
|
-
*/
|
|
307
|
-
export function recordFailure(db, id, error, { failureLimit = DEFAULT_FAILURE_LIMIT, outcome = 'crashed' } = {}) {
|
|
308
|
-
const task = one(db, 'SELECT consecutive_failures, max_retries, status FROM tasks WHERE id = ?', id);
|
|
309
|
-
if (!task) return false;
|
|
310
|
-
const failures = Number(task.consecutive_failures || 0) + 1;
|
|
311
|
-
run(db, 'UPDATE tasks SET consecutive_failures = ?, last_failure_error = ? WHERE id = ?', failures, String(error).slice(0, 500), id);
|
|
312
|
-
const limit = task.max_retries ?? failureLimit;
|
|
313
|
-
if (failures < limit) return false;
|
|
314
|
-
if (task.status === 'running') endRun(db, id, { outcome, error: String(error) });
|
|
315
|
-
run(db, "UPDATE tasks SET status = 'blocked', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
316
|
-
event(db, id, 'gave_up', { error: String(error).slice(0, 500), failures, limit, outcome });
|
|
317
|
-
return true;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
284
|
/**
|
|
322
285
|
* A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
|
|
323
286
|
* workspaces, or a worktree (its branch stays); never while a child still waits on it, never a `dir`.
|
|
@@ -449,7 +412,7 @@ export const reclaim = (board, id, reason = null, { roles } = {}) => act(board,
|
|
|
449
412
|
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
450
413
|
export const specify = (board, id, { title = null, body = null, author = 'user', roles } = {}) => act(board, id, 'specify', { roles, payload: { title, body, author } });
|
|
451
414
|
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 } });
|
|
415
|
+
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
453
416
|
export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
|
|
454
417
|
if (provider && !model) throw new Error('a provider needs a model');
|
|
455
418
|
return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
|
|
@@ -539,9 +502,9 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
|
|
|
539
502
|
// ------------------------------------------------------------------ reading
|
|
540
503
|
|
|
541
504
|
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
|
-
WHERE (? IS NULL OR t.status = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
544
|
-
ORDER BY t.priority DESC, t.created_at ASC`, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
|
|
505
|
+
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
|
|
506
|
+
WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
507
|
+
ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
|
|
545
508
|
return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [] }));
|
|
546
509
|
}
|
|
547
510
|
|
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.34",
|
|
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": {
|