@volter/supercode-orchestrator 0.4.125 → 0.4.127
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 +23 -14
- package/board/dispatch.mjs +75 -13
- package/board/engine.mjs +12 -3
- package/board/tools.mjs +24 -10
- package/board/worker-prompt.mjs +6 -6
- package/board/workflow.mjs +12 -1
- package/board/ztrack.mjs +36 -0
- package/mcp/hermes-kanban-tools.json +40 -3
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { openZtrackBoard } from './ztrack.mjs';
|
|
|
15
15
|
import * as engine from './engine.mjs';
|
|
16
16
|
import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
17
17
|
import { watchBoard } from './wake.mjs';
|
|
18
|
-
import { loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
18
|
+
import { creatorOf, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
19
19
|
import { evaluate } from './expr.mjs';
|
|
20
20
|
|
|
21
21
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
@@ -35,6 +35,7 @@ Hermes Kanban's verbs, with their flags:
|
|
|
35
35
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
36
36
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
37
37
|
pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
|
|
38
|
+
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
38
39
|
unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
|
|
39
40
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
40
41
|
specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
@@ -53,11 +54,13 @@ supercode's own:
|
|
|
53
54
|
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
54
55
|
Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
|
|
55
56
|
<event> <id>... [reason] [--<field> value]: any other event the board's workflow declares, its flags the event's fields.
|
|
57
|
+
start <id> release a --no-start hold and start through the declared workflow
|
|
58
|
+
create ... --adopt <session> hold dispatch until that live session is attached
|
|
56
59
|
goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
|
|
57
60
|
|
|
58
61
|
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
|
|
63
|
+
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
61
64
|
`;
|
|
62
65
|
|
|
63
66
|
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
|
|
@@ -239,6 +242,7 @@ export async function runWorkflow(argv) {
|
|
|
239
242
|
|
|
240
243
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
241
244
|
const listing = ['list','ls'].includes(verb);
|
|
245
|
+
await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
242
246
|
const sid = listing && !args.mine ? null : callerSession();
|
|
243
247
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
244
248
|
if (sid) setActor(`sc:${localMachine()}:${sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${sid}`);
|
|
@@ -247,7 +251,6 @@ export async function runWorkflow(argv) {
|
|
|
247
251
|
}
|
|
248
252
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
249
253
|
// every verb reads the board through its workflow (a list settles cards too), so every verb loads it
|
|
250
|
-
if (verb !== 'help' && !listing) await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
251
254
|
engine.setCaller((db, cardId) => rolesFor(db, cardId, sid, workflowFor(root).params));
|
|
252
255
|
// a verb with no session behind it (a person at the CLI, the board file's sync) is the manager's, as its roles are
|
|
253
256
|
if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
|
|
@@ -293,11 +296,15 @@ export async function runWorkflow(argv) {
|
|
|
293
296
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
294
297
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
295
298
|
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
|
|
299
|
+
noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
|
|
296
300
|
every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
297
301
|
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,
|
|
298
302
|
}), true);
|
|
299
303
|
let answer = { id: created.id, existing: created.existing };
|
|
300
|
-
if (
|
|
304
|
+
if (args.adopt) {
|
|
305
|
+
const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
|
|
306
|
+
answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
|
|
307
|
+
} else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
|
|
301
308
|
const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
|
|
302
309
|
const started = report.started.find(([t]) => t === created.id);
|
|
303
310
|
const queued = report.queued.find(([t]) => t === created.id);
|
|
@@ -812,6 +819,12 @@ export async function runWorkflow(argv) {
|
|
|
812
819
|
say(args, lifted.length ? `Resumed (removed ${lifted.join(', ')})` : 'Not paused', { ok: true, lifted });
|
|
813
820
|
return 0;
|
|
814
821
|
}
|
|
822
|
+
case 'start': {
|
|
823
|
+
need(1, 'a card id');
|
|
824
|
+
const { lanes, settings } = await laneState(root);
|
|
825
|
+
const session = await withBoardAsync(b => startCard(b, id, { lanes, settings, roles: rolesFor(b.db, id, sid, workflowFor(root).params) }));
|
|
826
|
+
say(args, `${id}: ${session?.address ?? 'started'}`, { ok: true, session }); return 0;
|
|
827
|
+
}
|
|
815
828
|
case 'adopt': {
|
|
816
829
|
need(2, 'a card id and a session');
|
|
817
830
|
const { settings } = await laneState(root);
|
|
@@ -841,10 +854,10 @@ export async function runWorkflow(argv) {
|
|
|
841
854
|
if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
|
|
842
855
|
need(1, 'a card id');
|
|
843
856
|
const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
|
|
844
|
-
const payload = { ...fields, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
|
|
857
|
+
const payload = { ...fields, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
|
|
845
858
|
const results = [];
|
|
846
859
|
for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
|
|
847
|
-
say(args, results.map(([t, s]) => `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
860
|
+
say(args, results.map(([t, s]) => name === 'request_state' ? `${t}: ${payload.state} requested; state remains ${s}. Mail delivery is recorded on the card.` : `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
848
861
|
}
|
|
849
862
|
}
|
|
850
863
|
}
|
|
@@ -1061,16 +1074,10 @@ function ownCard(db, sessionId) {
|
|
|
1061
1074
|
|
|
1062
1075
|
const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
|
|
1063
1076
|
|
|
1064
|
-
/**
|
|
1065
|
-
* What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, heartbeat,
|
|
1066
|
-
* request review, attach a file, specify or decompose it in triage; on its subtasks, file, complete, block, unblock,
|
|
1067
|
-
* comment and attach. Never its arc's completion, and never another arc's card: anything across arcs goes through the
|
|
1068
|
-
* fleet's manager.
|
|
1069
|
-
*/
|
|
1070
1077
|
/**
|
|
1071
1078
|
* Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
|
|
1072
1079
|
* is `manager`; the session of the card's current run has that run's role; the card's working session between runs
|
|
1073
|
-
* is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
|
|
1080
|
+
* is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
|
|
1074
1081
|
*/
|
|
1075
1082
|
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
1076
1083
|
if (!sessionId) return ['manager', 'owner'];
|
|
@@ -1080,6 +1087,7 @@ function rolesFor(db, cardId, sessionId, params = {}) {
|
|
|
1080
1087
|
const task = engine.getTask(db, cardId);
|
|
1081
1088
|
if (!task) return [];
|
|
1082
1089
|
const roles = [];
|
|
1090
|
+
if (creatorOf(db, cardId)?.endsWith(`:${sessionId}`)) roles.push('creator');
|
|
1083
1091
|
const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
|
|
1084
1092
|
if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
|
|
1085
1093
|
else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
|
|
@@ -1100,6 +1108,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1100
1108
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1101
1109
|
const card = engine.getTask(db, rest[0]);
|
|
1102
1110
|
if (!card) return; // the verb itself answers "no card"
|
|
1111
|
+
if (creatorOf(db, card.id)?.endsWith(`:${callerSession()}`)) return; // its declared creator doors still govern the act
|
|
1103
1112
|
// a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
|
|
1104
1113
|
if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
|
|
1105
1114
|
for (const other of args.ids ?? []) if (other !== own.id && engine.getTask(db, other)?.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${other}`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -136,7 +136,20 @@ const HARNESSES = {
|
|
|
136
136
|
const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.machine}:${s.harness ?? 'claude-code'}:${s.session_id}` : null);
|
|
137
137
|
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
138
138
|
|
|
139
|
-
const sessionAlive = (fleet, session) =>
|
|
139
|
+
const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ? fleet.native.get(addressOf(session)).alive : Boolean(session?.session_id) && fleet.alive.has(addressOf(session));
|
|
140
|
+
|
|
141
|
+
/** Native activity comes from the machine's harness discovery, not catalogue age. */
|
|
142
|
+
function nativeSessions(machine, harness) {
|
|
143
|
+
const result = sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
|
|
144
|
+
const page = result.code === 0 ? lastJson(result.stdout) : null;
|
|
145
|
+
if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
146
|
+
return page.sessions;
|
|
147
|
+
}
|
|
148
|
+
function nativeReading(rows, session) {
|
|
149
|
+
const row = rows.find(r => r.locator?.session_id === session.session_id && r.locator?.harness === session.harness);
|
|
150
|
+
const presence = row?.activity?.presence;
|
|
151
|
+
return { row, alive: presence === 'running' ? true : presence === 'persisted' ? false : null };
|
|
152
|
+
}
|
|
140
153
|
|
|
141
154
|
/** Match a manual continuation by the same harness identity and an actually live machine pane. */
|
|
142
155
|
function continuationPane(fleet, session, panes) {
|
|
@@ -211,6 +224,17 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
211
224
|
// The catalog can lag a pane. Read the actual machines once, before any board mutation.
|
|
212
225
|
const observed = new Map();
|
|
213
226
|
const exits = new Map();
|
|
227
|
+
const native = new Map(); fleet.native = new Map();
|
|
228
|
+
for (const { id } of rows) {
|
|
229
|
+
const session = currentRun(board.db, id)?.metadata?.supercode;
|
|
230
|
+
if (!session?.session_id || session.pane || session.surface === 'headless') continue;
|
|
231
|
+
const key = `${session.machine}:${session.harness}`;
|
|
232
|
+
if (!native.has(key)) {
|
|
233
|
+
try { native.set(key, nativeSessions(session.machine, session.harness)); }
|
|
234
|
+
catch (error) { native.set(key, null); report.failed.push([id, error.message]); }
|
|
235
|
+
}
|
|
236
|
+
fleet.native.set(addressOf(session), native.get(key) ? nativeReading(native.get(key), session) : { alive: null });
|
|
237
|
+
}
|
|
214
238
|
for (const { id } of rows) {
|
|
215
239
|
const s = currentRun(board.db, id)?.metadata?.supercode;
|
|
216
240
|
if (!s?.pane || observed.has(s.machine)) continue;
|
|
@@ -288,7 +312,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
288
312
|
// Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
|
|
289
313
|
const paneReading = session?.pane ? observed.get(session.machine) : undefined;
|
|
290
314
|
const paneLive = paneReading instanceof Set ? paneReading.has(session.pane) : null;
|
|
291
|
-
const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.pane ? paneLive !== false : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
|
|
315
|
+
const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.pane ? paneLive !== false : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) !== false : true) : false;
|
|
292
316
|
// (whether it is reconciled is the workflow's: its `orphaned` transitions and their guard)
|
|
293
317
|
if ((!task.claim_lock || !task.claim_expires) && !live) {
|
|
294
318
|
observe('orphaned', { reason: 'orphaned_running', claim_lock: task.claim_lock, claim_expires: task.claim_expires, error: 'orphaned running card (broken claim bookkeeping)' }, 'reclaimed');
|
|
@@ -325,6 +349,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
325
349
|
if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
|
|
326
350
|
if (!session.pane && !fleet.ok) return;
|
|
327
351
|
const alive = session.pane ? paneLive : sessionAlive(fleet, session);
|
|
352
|
+
if (alive == null) return; // native discovery was inconclusive
|
|
328
353
|
const machineUp = paneReading instanceof Set || fleet.online.has(session.machine);
|
|
329
354
|
const young = t - Number(session.launched_at ?? 0) < Number(limits.grace ?? settings.grace);
|
|
330
355
|
const missing = !alive && machineUp && !young;
|
|
@@ -374,6 +399,13 @@ function stopSession(board, session) {
|
|
|
374
399
|
if (pidAlive(session.pid)) signal('SIGKILL');
|
|
375
400
|
return;
|
|
376
401
|
}
|
|
402
|
+
if (!session.pane && session.surface === 'native') {
|
|
403
|
+
const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
|
|
404
|
+
if (r.code !== 0) throw new Error(`closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
405
|
+
const reading = nativeReading(nativeSessions(session.machine, session.harness), session);
|
|
406
|
+
if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
377
409
|
if (session.pane) {
|
|
378
410
|
const r = sc(['teams', 'panes', 'close', session.pane, ...(session.machine ? ['--machine', session.machine] : [])], { timeout: 60_000, as: boardAddress(board.label) });
|
|
379
411
|
if (r.code !== 0 || json(r.stdout.trim(), null)?.closed !== session.pane) throw new Error(`closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
|
|
@@ -421,6 +453,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
421
453
|
for (const task of everyCard) {
|
|
422
454
|
if (only && task.id !== only) continue;
|
|
423
455
|
const state = scopeFor(task).card;
|
|
456
|
+
if (state.dispatch_hold) continue;
|
|
424
457
|
if (workflow.run && (task.subtask_of || state.paused || state.archived || state.blocked_by.length || task.current_run_id || (state.every && state.next_run_at > now()))) continue;
|
|
425
458
|
if (!startable.has(statusOf(board.db, task))) continue;
|
|
426
459
|
const plan = planOf(task, scopeFor(task));
|
|
@@ -565,6 +598,8 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
565
598
|
const moved = tx(board, (db) => {
|
|
566
599
|
assertWorkspaceOwner(db, before, checked);
|
|
567
600
|
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
|
|
601
|
+
if (!roles.includes('manager')) throw new Error('Only the manager starts a held card; request its decision.');
|
|
602
|
+
event(db, id, 'dispatch_released', { reason: 'explicit start' });
|
|
568
603
|
return fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } });
|
|
569
604
|
});
|
|
570
605
|
const task = getTaskNow(board, id);
|
|
@@ -611,7 +646,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
611
646
|
report.undelivered.push([row.task_id, addressOf(session), error]);
|
|
612
647
|
}
|
|
613
648
|
}
|
|
614
|
-
if (!session?.pane || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
|
|
649
|
+
if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
|
|
615
650
|
if (caller && session.session_id === caller) continue; // let the command that ended its own run return
|
|
616
651
|
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all());
|
|
617
652
|
// Persist retries even for historical ends that did not have a close_session action.
|
|
@@ -619,6 +654,16 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
619
654
|
let error = null;
|
|
620
655
|
try {
|
|
621
656
|
const machine = session.machine ?? localMachine();
|
|
657
|
+
if (session.surface === 'native' && !session.pane) {
|
|
658
|
+
const reading = nativeReading(nativeSessions(machine, session.harness), session);
|
|
659
|
+
if (reading.alive == null) throw new Error(`close deferred: native liveness unknown for ${addressOf(session)}`);
|
|
660
|
+
if (reading.alive) {
|
|
661
|
+
const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
|
|
662
|
+
if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
|
|
663
|
+
if (session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been delivered`);
|
|
664
|
+
stopSession(board, session);
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
622
667
|
if (!panes.has(machine)) {
|
|
623
668
|
const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000, as: boardAddress(board.label) });
|
|
624
669
|
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
@@ -634,10 +679,11 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
634
679
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
635
680
|
stopSession(board, session); observed.live.delete(session.pane);
|
|
636
681
|
}
|
|
682
|
+
}
|
|
637
683
|
} catch (e) { error = String(e.message).slice(-500); }
|
|
638
684
|
tx(board, (db) => {
|
|
639
685
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
640
|
-
if (!current || current.pane !== session.pane) return;
|
|
686
|
+
if (!current || current.pane !== session.pane || current.session_id !== session.session_id) return;
|
|
641
687
|
const at = now();
|
|
642
688
|
if (error) {
|
|
643
689
|
setRunSession(db, row.id, { ...current, close_attempted_at: at, close_attempts: Number(current.close_attempts ?? 0) + 1, close_error: error });
|
|
@@ -668,8 +714,9 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
668
714
|
if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
|
|
669
715
|
const runRow = currentRun(db, id);
|
|
670
716
|
const session = runRow?.metadata?.supercode;
|
|
671
|
-
if (!session?.session_id || session.surface
|
|
672
|
-
|
|
717
|
+
if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
|
|
718
|
+
if (session.surface === 'native' && fleet.native?.get(addressOf(session))?.alive !== true) return;
|
|
719
|
+
const live = fleet.sessions.find((x) => x.address === addressOf(session));
|
|
673
720
|
const observedAt = now();
|
|
674
721
|
const lastAt = Math.floor(Number(live?.last_message_at_ms) / 1000);
|
|
675
722
|
const reading = {
|
|
@@ -695,7 +742,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
695
742
|
try {
|
|
696
743
|
const where = whereOf(board, task);
|
|
697
744
|
// the ask names what the session may do, as its run's prompt did
|
|
698
|
-
const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface
|
|
745
|
+
const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface !== 'headless' ? 'pane' : 'headless');
|
|
699
746
|
const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { ...reading, turns: reading.turns_before, worked }, extra: { where, doors: said } });
|
|
700
747
|
effects.push(...r.effects);
|
|
701
748
|
const after = currentRun(db, id);
|
|
@@ -948,7 +995,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
948
995
|
closeRequested(board, { undelivered: [] }, { taskId: task.id });
|
|
949
996
|
const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
|
|
950
997
|
.map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
|
|
951
|
-
.find((r) => (r.session?.pane || r.session?.launch_key) && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
|
|
998
|
+
.find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
|
|
952
999
|
if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
|
|
953
1000
|
let prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
954
1001
|
if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
|
|
@@ -997,6 +1044,12 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
997
1044
|
};
|
|
998
1045
|
let session = null;
|
|
999
1046
|
let replaced = null;
|
|
1047
|
+
if (prev?.surface === 'native' && !prev.pane) {
|
|
1048
|
+
const reading = nativeReading(nativeSessions(prev.machine, prev.harness), prev);
|
|
1049
|
+
if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
|
|
1050
|
+
fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
|
|
1051
|
+
if (reading.alive && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
|
|
1052
|
+
}
|
|
1000
1053
|
if (prev?.forgotten) {
|
|
1001
1054
|
if (prev.pane && panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
|
|
1002
1055
|
replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
|
|
@@ -1165,14 +1218,15 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
1165
1218
|
if (!found) throw new Error(`the fleet lists no live session ${who}`);
|
|
1166
1219
|
const [, host, harness, sessionId] = found.address.split(':');
|
|
1167
1220
|
if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
|
|
1168
|
-
|
|
1221
|
+
const native = found.pane ? null : nativeReading(nativeSessions(host, harness), { session_id: sessionId, harness });
|
|
1222
|
+
if (found.pane ? !panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
|
|
1169
1223
|
const beforeRun = view(board, (db) => currentRun(db, id));
|
|
1170
1224
|
const beforeSession = beforeRun?.metadata?.supercode;
|
|
1171
1225
|
if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
|
|
1172
1226
|
if (beforeSession?.pane && beforeSession.pane !== found.pane && panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
|
|
1173
1227
|
|
|
1174
1228
|
// the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
|
|
1175
|
-
let dir = workspace;
|
|
1229
|
+
let dir = workspace ?? native?.row?.cwd;
|
|
1176
1230
|
if (!dir) {
|
|
1177
1231
|
const r = sc(['discover', '--on', host, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
|
|
1178
1232
|
dir = (json(r.stdout.trim(), {})?.sessions ?? []).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
|
|
@@ -1203,10 +1257,11 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
1203
1257
|
const implementer = lastSession(db, id, 'worker');
|
|
1204
1258
|
if (implementer?.session_id === sessionId) throw new Error('a reviewer must be independent of the implementer session');
|
|
1205
1259
|
}
|
|
1260
|
+
event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
|
|
1206
1261
|
if (!beforeRun) fire(db, board, entry, id, entry.workflow.run ? entry.workflow.dispatch.start : 'adopt', { roles: ['manager'], payload: { lock: claimer() } });
|
|
1207
1262
|
const runId = getTask(db, id).current_run_id;
|
|
1208
1263
|
if (!runId) throw new Error(`${id} waits on parents that are not done`);
|
|
1209
|
-
const session = { surface: 'pane', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
1264
|
+
const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
1210
1265
|
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, slot: currentRun(db, id)?.metadata?.supercode?.slot ?? beforeSession?.slot ?? expected?.session ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
|
|
1211
1266
|
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
|
|
1212
1267
|
subscribeIn(db, id, found.address, 'worker');
|
|
@@ -1245,16 +1300,23 @@ async function send(from, label, to, body, key, { queue = false, ask = false } =
|
|
|
1245
1300
|
* and marked board_message_sent. A question goes under the id its card waits on; a resend of a sent one is a no-op
|
|
1246
1301
|
* (the id is the send's idempotency key). One that fails is tried again next tick.
|
|
1247
1302
|
*/
|
|
1248
|
-
async function sendBoardMessages(board, report) {
|
|
1303
|
+
export async function sendBoardMessages(board, report, taskId = null) {
|
|
1249
1304
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, payload FROM task_events e WHERE kind = 'board_message' AND NOT EXISTS
|
|
1250
1305
|
(SELECT 1 FROM task_events s WHERE s.kind = 'board_message_sent' AND s.task_id = e.task_id AND json_extract(s.payload, '$.event') = e.id) ORDER BY id`).all());
|
|
1251
1306
|
for (const row of rows) {
|
|
1307
|
+
if (taskId && row.task_id !== taskId) continue;
|
|
1252
1308
|
const m = json(row.payload, {}) ?? {};
|
|
1253
1309
|
try {
|
|
1254
1310
|
await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
|
|
1255
1311
|
tx(board, (db) => event(db, row.task_id, 'board_message_sent', { event: row.id, to: m.to, ...(m.message_id ? { message_id: m.message_id } : {}) }));
|
|
1256
1312
|
} catch (error) {
|
|
1257
|
-
|
|
1313
|
+
const reason = String(error.message).slice(-200);
|
|
1314
|
+
report.undelivered.push([row.task_id, m.to, reason]);
|
|
1315
|
+
tx(board, db => {
|
|
1316
|
+
const previous = db.prepare("SELECT payload,created_at FROM task_events WHERE task_id=? AND kind='board_message_failed' AND json_extract(payload,'$.event')=? ORDER BY id DESC LIMIT 1").get(row.task_id, row.id);
|
|
1317
|
+
if (json(previous?.payload, {})?.reason !== reason || now() - (previous?.created_at ?? 0) >= 60)
|
|
1318
|
+
event(db, row.task_id, 'board_message_failed', { event: row.id, to: m.to, message_id: m.message_id, reason });
|
|
1319
|
+
});
|
|
1258
1320
|
}
|
|
1259
1321
|
}
|
|
1260
1322
|
}
|
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 { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
|
-
import { view, claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
15
|
+
import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
16
16
|
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
17
17
|
|
|
18
18
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
@@ -184,6 +184,10 @@ export function createCard(board, spec) {
|
|
|
184
184
|
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
185
185
|
let id = newTaskId();
|
|
186
186
|
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
187
|
+
const creationRoles = workflowFor(board.root).params.create_dependencies_by;
|
|
188
|
+
if ((parents.length || initial === 'blocked') && creationRoles && !callerRoles(db, arc?.id ?? id).some(role => creationRoles.includes(role))) {
|
|
189
|
+
throw new Error('Creating a dependency or initially blocked card requires a permitted manager role; file the task without a blocker and use request-state.');
|
|
190
|
+
}
|
|
187
191
|
assertWorkspaceOwner(db, workspaceTask, checked);
|
|
188
192
|
// The repository declaration stays separate from the resolved worktree path.
|
|
189
193
|
if (project) {
|
|
@@ -211,7 +215,8 @@ export function createCard(board, spec) {
|
|
|
211
215
|
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
212
216
|
const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
|
|
213
217
|
every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
214
|
-
event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
218
|
+
event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
219
|
+
if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
|
|
215
220
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
216
221
|
if (arc) {
|
|
217
222
|
// the arc's subscribers hear its subtasks, and the arc's session works them
|
|
@@ -382,6 +387,10 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
|
|
|
382
387
|
if (!await sessionNotice(board, e)) continue;
|
|
383
388
|
}
|
|
384
389
|
else if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
|
|
390
|
+
else if (e.kind === 'board_message') {
|
|
391
|
+
const { sendBoardMessages } = await import('./dispatch.mjs');
|
|
392
|
+
await sendBoardMessages(board, { undelivered: [] }, e.id);
|
|
393
|
+
}
|
|
385
394
|
else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
|
|
386
395
|
else if (e.kind === 'stop_run') stopWorker(board, e.runId);
|
|
387
396
|
else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
|
|
@@ -685,7 +694,7 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
|
|
|
685
694
|
|
|
686
695
|
/** Delete the thread events of done and archived cards older than `seconds` (Hermes's `gc` of events). */
|
|
687
696
|
export function gcEvents(board, seconds) {
|
|
688
|
-
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));
|
|
697
|
+
return tx(board, (db) => Number(run(db, "DELETE FROM task_events WHERE kind != 'created' AND created_at < ? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done', 'archived'))", now() - seconds).changes));
|
|
689
698
|
}
|
|
690
699
|
|
|
691
700
|
// ------------------------------------------------------------------ diagnostics
|
package/board/tools.mjs
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// unblocking tools are the orchestrator's, not a worker's. Answers are JSON text: `{ ok: true, … }` or `{ error }`.
|
|
6
6
|
import { mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { basename, join } from 'node:path';
|
|
8
|
-
import { DEFAULT_BOARD, event, json, openBoard, tx } from './store.mjs';
|
|
8
|
+
import { DEFAULT_BOARD, actorOf, event, json, openBoard, setActor, tx } from './store.mjs';
|
|
9
9
|
import * as engine from './engine.mjs';
|
|
10
|
-
import { loadWorkflow } from './workflow.mjs';
|
|
10
|
+
import { creatorOf, loadWorkflow } from './workflow.mjs';
|
|
11
11
|
|
|
12
12
|
const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
|
|
13
13
|
const error = (message) => JSON.stringify({ error: message });
|
|
@@ -25,7 +25,7 @@ export function redact(value) {
|
|
|
25
25
|
return value;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const LIFECYCLE = new Set(['kanban_complete', 'kanban_block', 'kanban_request_review', 'kanban_request_changes', 'kanban_heartbeat', 'kanban_attach', 'kanban_attach_url', 'kanban_unblock']);
|
|
28
|
+
const LIFECYCLE = new Set(['kanban_complete', 'kanban_block', 'kanban_request_state', 'kanban_request_review', 'kanban_request_changes', 'kanban_heartbeat', 'kanban_attach', 'kanban_attach_url', 'kanban_unblock']);
|
|
29
29
|
const ORCHESTRATOR_ONLY = new Set(['kanban_list', 'kanban_unblock']);
|
|
30
30
|
|
|
31
31
|
/** One board tool call. `root` is the home; `env` the worker's task environment (`HERMES_KANBAN_*`). */
|
|
@@ -33,30 +33,39 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
33
33
|
const own = env.HERMES_KANBAN_TASK || null;
|
|
34
34
|
const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
|
|
35
35
|
const author = env.HERMES_PROFILE || 'worker';
|
|
36
|
-
if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's;
|
|
36
|
+
if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's; read kanban_show for your task's declared doors, or use kanban_request_state, kanban_heartbeat or kanban_comment`);
|
|
37
37
|
const tid = args.task_id || own;
|
|
38
38
|
if (!['kanban_list', 'kanban_create', 'kanban_link'].includes(name) && !tid) return error('task_id is required (or HERMES_KANBAN_TASK in the environment)');
|
|
39
|
-
if (own && LIFECYCLE.has(name) && tid !== own) return error(`worker is scoped to task ${own}; refusing to change ${tid}. Use kanban_comment to hand off to other tasks, or kanban_create for follow-up work.`);
|
|
40
39
|
const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
|
|
41
40
|
// the board's workflow decides what each tool's event does; the worker is its run's role on its own task, and
|
|
42
41
|
// nobody on another (Hermes's worker scoping)
|
|
43
42
|
try { await loadWorkflow(root); } catch (e) { return error(e.message); }
|
|
44
43
|
engine.setCaller((db, cardId) => {
|
|
45
|
-
if (!own ||
|
|
44
|
+
if (!own || !runId) return own ? [] : ['manager'];
|
|
46
45
|
const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
|
|
47
|
-
|
|
46
|
+
const roles = cardId === own ? [meta.role ?? 'implementer'] : [];
|
|
47
|
+
if (actorOf() && creatorOf(db, cardId) === actorOf()) roles.push('creator');
|
|
48
|
+
return roles;
|
|
48
49
|
});
|
|
49
50
|
let board;
|
|
50
51
|
try { board = openBoard(root, slug); } catch (e) { return error(e.message); }
|
|
51
52
|
board.label = slug;
|
|
53
|
+
const callingRun = runId ? json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode : null;
|
|
54
|
+
setActor(own && runId ? callingRun?.address ?? `hermes-run:${slug}:${runId}` : null);
|
|
55
|
+
if (own && LIFECYCLE.has(name) && tid !== own && creatorOf(board.db, tid) !== actorOf()) {
|
|
56
|
+
board.close(); return error(`worker is scoped to task ${own}; refusing to change ${tid}. Use kanban_comment to hand off to other tasks.`);
|
|
57
|
+
}
|
|
52
58
|
const current = () => tx(board, (db) => engine.getTask(db, tid));
|
|
53
59
|
// a worker whose run was superseded (reclaimed, re-dispatched) no longer speaks for the card
|
|
54
60
|
const ownRun = () => {
|
|
55
|
-
if (!own
|
|
56
|
-
const t =
|
|
57
|
-
|
|
61
|
+
if (!own) return null;
|
|
62
|
+
const t = engine.getTask(board.db, own);
|
|
63
|
+
const r = runId && board.db.prepare('SELECT task_id, ended_at FROM task_runs WHERE id=?').get(runId);
|
|
64
|
+
return !r || r.task_id !== own || r.ended_at != null || Number(t?.current_run_id) !== runId
|
|
65
|
+
? `run ${runId ?? '(missing)'} of ${own} is no longer its current run; nothing was changed` : null;
|
|
58
66
|
};
|
|
59
67
|
try {
|
|
68
|
+
if (name !== 'kanban_show' && name !== 'kanban_list') { const stale = ownRun(); if (stale) return error(stale); }
|
|
60
69
|
switch (name) {
|
|
61
70
|
case 'kanban_show': {
|
|
62
71
|
const card = tx(board, (db) => { const c = engine.showCard(db, tid); c.context = engine.cardContext(db, tid); return c; });
|
|
@@ -104,6 +113,11 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
104
113
|
const { to: status } = await engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, on: args.on ?? null, waitingOn: args.waiting_on ?? null });
|
|
105
114
|
return ok({ task_id: tid, status });
|
|
106
115
|
}
|
|
116
|
+
case 'kanban_request_state': {
|
|
117
|
+
const stale = ownRun(); if (stale) return error(stale);
|
|
118
|
+
await engine.act(board, tid, 'request_state', { payload: { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null } });
|
|
119
|
+
return ok({ task_id: tid, requested: args.state, changed: false });
|
|
120
|
+
}
|
|
107
121
|
case 'kanban_request_review': {
|
|
108
122
|
const stale = ownRun(); if (stale) return error(stale);
|
|
109
123
|
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
package/board/worker-prompt.mjs
CHANGED
|
@@ -12,12 +12,12 @@ export function taskProtocol(taskId) {
|
|
|
12
12
|
`You were started for board task ${taskId} (it is also in HERMES_KANBAN_TASK). The board's tools are yours:`,
|
|
13
13
|
'- `kanban_show` reads your task: its body, its parents\' handoffs, earlier attempts and its thread. Read it first.',
|
|
14
14
|
'- `kanban_heartbeat` says you are still working on a long step; `kanban_comment` leaves a note on any task.',
|
|
15
|
-
'- `kanban_create` files follow-up work
|
|
16
|
-
'-
|
|
17
|
-
'-
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
15
|
+
'- `kanban_create` files follow-up work; dependency creation follows the declared permissions.',
|
|
16
|
+
'- Follow the workflow doors below: each home declares who may block, pause or finish a card.',
|
|
17
|
+
'- When the home requires a manager decision, `kanban_request_state` names block, pause, done or cancelled, a reason,',
|
|
18
|
+
' its dependency (`on` or `waiting_on`) or its evidence. It mails the manager and leaves your card and run active.',
|
|
19
|
+
'- `kanban_request_review` hands completed coding to the independent reviewer when the workflow permits it.',
|
|
20
|
+
'A role does not grant terminal authority by itself. The recorded creator and the home\'s rules decide it.',
|
|
21
21
|
].join('\n');
|
|
22
22
|
}
|
|
23
23
|
|
package/board/workflow.mjs
CHANGED
|
@@ -58,6 +58,13 @@ export function statusOf(db, task) {
|
|
|
58
58
|
return one(db, 'SELECT stage FROM supercode_cards WHERE task_id = ?', task.id)?.stage ?? task.status;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Creation history, not an editable display name or notification subscriber. */
|
|
62
|
+
export function creatorOf(db, id) {
|
|
63
|
+
const payload = json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind='created' ORDER BY id LIMIT 1", id)?.payload, {}) ?? {};
|
|
64
|
+
const address = payload.creator ?? payload.by;
|
|
65
|
+
return payload.source !== 'document' && /^(?:sc:[^:]+:[^:]+:|hermes-run:)/.test(address ?? '') ? address : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
/** What kanban.db stores for `status`. */
|
|
62
69
|
export function storedAs(workflow, status) {
|
|
63
70
|
return workflow.statuses[status]?.stored_as ?? status;
|
|
@@ -214,6 +221,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
214
221
|
const blocks = json(state.blocked_by, []) ?? [];
|
|
215
222
|
const card = {
|
|
216
223
|
...task,
|
|
224
|
+
creator_address: creatorOf(db, task.id),
|
|
225
|
+
dispatch_hold: one(db, "SELECT kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1", task.id)?.kind === 'dispatch_held',
|
|
217
226
|
status,
|
|
218
227
|
blocked_by: blocks,
|
|
219
228
|
paused: json(state.paused, null),
|
|
@@ -275,6 +284,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
275
284
|
for (const [key, expr] of Object.entries(loaded.get(board.root)?.workflow?.derived ?? {})) card[key] = evaluate(expr, { card, event, params, now: now() });
|
|
276
285
|
return {
|
|
277
286
|
card,
|
|
287
|
+
requester: actorOf(),
|
|
278
288
|
event,
|
|
279
289
|
params,
|
|
280
290
|
now: now(),
|
|
@@ -751,6 +761,7 @@ const ACTIONS = {
|
|
|
751
761
|
const id = `m-${randomBytes(12).toString('hex')}`;
|
|
752
762
|
ctx.scope.asked = id;
|
|
753
763
|
record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
|
|
764
|
+
ctx.effects.push({ kind: 'board_message', id: ctx.id });
|
|
754
765
|
},
|
|
755
766
|
send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
|
|
756
767
|
};
|
|
@@ -808,7 +819,7 @@ function runSession(ctx, change) {
|
|
|
808
819
|
* the board: `--root …` or `--at …`) and its transition's description: what a started session is told it can do.
|
|
809
820
|
*/
|
|
810
821
|
// the board tool a one-shot worker sends an event with (Hermes's kanban_* tools)
|
|
811
|
-
const TOOL_OF = { complete: 'kanban_complete', block: 'kanban_block', request_review: 'kanban_request_review', request_changes: 'kanban_request_changes', heartbeat: 'kanban_heartbeat', comment: 'kanban_comment' };
|
|
822
|
+
const TOOL_OF = { complete: 'kanban_complete', block: 'kanban_block', request_state: 'kanban_request_state', request_review: 'kanban_request_review', request_changes: 'kanban_request_changes', heartbeat: 'kanban_heartbeat', comment: 'kanban_comment' };
|
|
812
823
|
|
|
813
824
|
export function doors(workflow, status, role, id, where, surface = 'pane') {
|
|
814
825
|
const events = new Map();
|
package/board/ztrack.mjs
CHANGED
|
@@ -360,6 +360,7 @@ class ZtrackBoard {
|
|
|
360
360
|
this.mem.prepare('UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?').run(JSON.stringify(blocks), row.task_id);
|
|
361
361
|
for (const b of blocks.filter((b) => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: row.task_id });
|
|
362
362
|
}
|
|
363
|
+
this.checkDocumentControls();
|
|
363
364
|
if (observe) this.documentEvents();
|
|
364
365
|
}
|
|
365
366
|
taskCards(id, card) {
|
|
@@ -379,6 +380,41 @@ class ZtrackBoard {
|
|
|
379
380
|
const rows = this.cardRows(id, card, parent);
|
|
380
381
|
this.insert('tasks', rows.task); this.insert('supercode_cards', rows.state);
|
|
381
382
|
}
|
|
383
|
+
checkDocumentControls() {
|
|
384
|
+
// A raw edit cannot authenticate its author. Trusted engine writes already
|
|
385
|
+
// have matching publication hashes; check only external edits, after local
|
|
386
|
+
// subtask references have been resolved to canonical card identities.
|
|
387
|
+
if (this.seed || (this.saved.documentHashes?.open === hash(this.text) && this.saved.documentHashes?.archived === hash(this.archiveText))) return;
|
|
388
|
+
let fields = [];
|
|
389
|
+
try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
|
|
390
|
+
if (!fields.length) return;
|
|
391
|
+
const tasks = new Map(this.mem.prepare('SELECT * FROM tasks').all().map(t => [t.id, t]));
|
|
392
|
+
const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map(c => [c.task_id, c]));
|
|
393
|
+
const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
|
|
394
|
+
: field === 'blocked_by' ? json(state?.blocked_by, []).map(b => [b.kind, b.id]).sort()
|
|
395
|
+
: field === 'paused' ? json(state?.paused, null)
|
|
396
|
+
: field === 'archived' ? Boolean(state?.archived) : task?.[field] ?? null;
|
|
397
|
+
for (const id of new Set([...tasks.keys(), ...Object.keys(this.rows.cachedTasks)])) {
|
|
398
|
+
if (this.errors.has(id)) continue;
|
|
399
|
+
const oldTask = this.rows.cachedTasks[id], oldState = this.rows.cachedCards[id];
|
|
400
|
+
const task = tasks.get(id), state = states.get(id);
|
|
401
|
+
const forbidden = !task ? ['card removal'] : fields.filter(field =>
|
|
402
|
+
// A new display author is not an authenticated creator.
|
|
403
|
+
(oldTask || field !== 'created_by') && JSON.stringify(value(task, state, field)) !== JSON.stringify(value(oldTask, oldState, field)));
|
|
404
|
+
if (!forbidden.length) continue;
|
|
405
|
+
this.errors.set(id, `Document edit cannot change ${forbidden.join(', ')}; use this card's role-checked workflow verbs or request-state.`);
|
|
406
|
+
const source = this.documentRecords.get(state?.subtask_of ?? oldState?.subtask_of ?? id);
|
|
407
|
+
if (source) this.preserve.get(source.path)?.add(source.record.id);
|
|
408
|
+
this.mem.prepare('DELETE FROM tasks WHERE id=?').run(id);
|
|
409
|
+
this.mem.prepare('DELETE FROM supercode_cards WHERE task_id=?').run(id);
|
|
410
|
+
this.mem.prepare('DELETE FROM task_links WHERE child_id=?').run(id);
|
|
411
|
+
if (oldTask) this.insert('tasks', oldTask);
|
|
412
|
+
if (oldState) {
|
|
413
|
+
this.insert('supercode_cards', oldState);
|
|
414
|
+
for (const b of json(oldState.blocked_by, []).filter(b => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: id });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
382
418
|
cardRows(id, card, parent = null) {
|
|
383
419
|
const meta = card.metadata ?? {};
|
|
384
420
|
const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
"name": "kanban_complete",
|
|
65
|
-
"description": "
|
|
65
|
+
"description": "Complete through the home's declared workflow permissions with a structured handoff. Homes may reserve terminal closure to the recorded creator or manager; use kanban_request_state when you need their decision. Prefer ``summary`` for a human-readable 1-3 sentence description of what you did; put machine-readable facts in ``metadata`` (changed_files, tests_run, decisions, findings, etc). At least one of ``summary`` or ``result`` is required. If you created new tasks via ``kanban_create`` during this run, list their ids in ``created_cards`` — the kernel verifies them so phantom references are caught before they leak into downstream automation. If you produced deliverable files (charts, PDFs, spreadsheets, generated images), list their absolute paths in ``artifacts`` — the gateway notifier will upload them as native attachments to the human who subscribed to the task, so the deliverable lands in their chat alongside the summary instead of being a path they have to fetch by hand.",
|
|
66
66
|
"inputSchema": {
|
|
67
67
|
"type": "object",
|
|
68
68
|
"properties": {
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
"name": "kanban_block",
|
|
109
|
-
"description": "Stop work on this task until one thing ends the wait, and name it: `on`, the task it waits on (it goes to todo and resumes when that task closes), or `waiting_on`, the id of the message you sent whose answer it waits for (it resumes when an answer to that message lands in your mailbox). An outside event is a task on the card of whoever makes it happen or watches for it. `reason` is shown on the board. The same block twice goes to triage.",
|
|
109
|
+
"description": "Add a typed dependency only if the home permits your role to block. Otherwise use kanban_request_state with state block, the reason and on or waiting_on; that request leaves the card active. Stop work on this task until one thing ends the wait, and name it: `on`, the task it waits on (it goes to todo and resumes when that task closes), or `waiting_on`, the id of the message you sent whose answer it waits for (it resumes when an answer to that message lands in your mailbox). An outside event is a task on the card of whoever makes it happen or watches for it. `reason` is shown on the board. The same block twice goes to triage.",
|
|
110
110
|
"inputSchema": {
|
|
111
111
|
"type": "object",
|
|
112
112
|
"properties": {
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
},
|
|
172
172
|
{
|
|
173
173
|
"name": "kanban_request_changes",
|
|
174
|
-
"description": "Reviewer verdict: return the current review run to the original implementer with concrete required changes. This closes the review run, reapplies parent dependency gating, and requeues the task without using block-loop accounting. Only use from a task claimed from the review column; use
|
|
174
|
+
"description": "Reviewer verdict: return the current review run to the original implementer with concrete required changes. This closes the review run, reapplies parent dependency gating, and requeues the task without using block-loop accounting. Only use from a task claimed from the review column; use kanban_request_state when a manager must decide an external blocker.",
|
|
175
175
|
"inputSchema": {
|
|
176
176
|
"type": "object",
|
|
177
177
|
"properties": {
|
|
@@ -471,5 +471,42 @@
|
|
|
471
471
|
"child_id"
|
|
472
472
|
]
|
|
473
473
|
}
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
"name": "kanban_request_state",
|
|
477
|
+
"description": "Request a manager decision on block, pause, done or cancelled through the home workflow. Includes its rules in manager mail and leaves card/run state unchanged.",
|
|
478
|
+
"inputSchema": {
|
|
479
|
+
"type": "object",
|
|
480
|
+
"properties": {
|
|
481
|
+
"task_id": {
|
|
482
|
+
"type": "string"
|
|
483
|
+
},
|
|
484
|
+
"state": {
|
|
485
|
+
"type": "string",
|
|
486
|
+
"enum": [
|
|
487
|
+
"block",
|
|
488
|
+
"pause",
|
|
489
|
+
"done",
|
|
490
|
+
"cancelled"
|
|
491
|
+
]
|
|
492
|
+
},
|
|
493
|
+
"reason": {
|
|
494
|
+
"type": "string"
|
|
495
|
+
},
|
|
496
|
+
"on": {
|
|
497
|
+
"type": "string"
|
|
498
|
+
},
|
|
499
|
+
"waiting_on": {
|
|
500
|
+
"type": "string"
|
|
501
|
+
},
|
|
502
|
+
"evidence": {
|
|
503
|
+
"type": "string"
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
"required": [
|
|
507
|
+
"state",
|
|
508
|
+
"reason"
|
|
509
|
+
]
|
|
510
|
+
}
|
|
474
511
|
}
|
|
475
512
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.127",
|
|
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": {
|