@volter/supercode-orchestrator 0.4.124 → 0.4.126
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 +31 -22
- package/board/dispatch.mjs +9 -2
- package/board/engine.mjs +11 -3
- package/board/store.mjs +6 -1
- package/board/tools.mjs +24 -10
- package/board/wake.mjs +134 -0
- package/board/worker-prompt.mjs +6 -6
- package/board/workflow.mjs +11 -1
- package/board/ztrack.mjs +36 -0
- package/board.mjs +22 -11
- package/mcp/hermes-kanban-tools.json +40 -3
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -13,8 +13,9 @@ import { view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_
|
|
|
13
13
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
14
14
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
15
15
|
import * as engine from './engine.mjs';
|
|
16
|
-
import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
|
|
17
|
-
import {
|
|
16
|
+
import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
17
|
+
import { watchBoard } from './wake.mjs';
|
|
18
|
+
import { creatorOf, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
18
19
|
import { evaluate } from './expr.mjs';
|
|
19
20
|
|
|
20
21
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
@@ -34,6 +35,7 @@ Hermes Kanban's verbs, with their flags:
|
|
|
34
35
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
35
36
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
36
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>]
|
|
37
39
|
unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
|
|
38
40
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
39
41
|
specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
@@ -55,8 +57,8 @@ supercode's own:
|
|
|
55
57
|
goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
|
|
56
58
|
|
|
57
59
|
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
|
|
61
|
+
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
60
62
|
`;
|
|
61
63
|
|
|
62
64
|
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
|
|
@@ -238,6 +240,7 @@ export async function runWorkflow(argv) {
|
|
|
238
240
|
|
|
239
241
|
// a caller that is a card's own session (its harness names it; the board recorded which session it launched)
|
|
240
242
|
const listing = ['list','ls'].includes(verb);
|
|
243
|
+
await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
241
244
|
const sid = listing && !args.mine ? null : callerSession();
|
|
242
245
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
243
246
|
if (sid) setActor(`sc:${localMachine()}:${sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${sid}`);
|
|
@@ -246,7 +249,6 @@ export async function runWorkflow(argv) {
|
|
|
246
249
|
}
|
|
247
250
|
// the board's workflow (the IR's): what each verb does, and who may send it, is its
|
|
248
251
|
// every verb reads the board through its workflow (a list settles cards too), so every verb loads it
|
|
249
|
-
if (verb !== 'help' && !listing) await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
250
252
|
engine.setCaller((db, cardId) => rolesFor(db, cardId, sid, workflowFor(root).params));
|
|
251
253
|
// 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
|
|
252
254
|
if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
|
|
@@ -840,10 +842,10 @@ export async function runWorkflow(argv) {
|
|
|
840
842
|
if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
|
|
841
843
|
need(1, 'a card id');
|
|
842
844
|
const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
|
|
843
|
-
const payload = { ...fields, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
|
|
845
|
+
const payload = { ...fields, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
|
|
844
846
|
const results = [];
|
|
845
847
|
for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
|
|
846
|
-
say(args, results.map(([t, s]) => `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
848
|
+
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;
|
|
847
849
|
}
|
|
848
850
|
}
|
|
849
851
|
}
|
|
@@ -1060,16 +1062,10 @@ function ownCard(db, sessionId) {
|
|
|
1060
1062
|
|
|
1061
1063
|
const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
|
|
1062
1064
|
|
|
1063
|
-
/**
|
|
1064
|
-
* What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, heartbeat,
|
|
1065
|
-
* request review, attach a file, specify or decompose it in triage; on its subtasks, file, complete, block, unblock,
|
|
1066
|
-
* comment and attach. Never its arc's completion, and never another arc's card: anything across arcs goes through the
|
|
1067
|
-
* fleet's manager.
|
|
1068
|
-
*/
|
|
1069
1065
|
/**
|
|
1070
1066
|
* Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
|
|
1071
1067
|
* is `manager`; the session of the card's current run has that run's role; the card's working session between runs
|
|
1072
|
-
* is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
|
|
1068
|
+
* 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.
|
|
1073
1069
|
*/
|
|
1074
1070
|
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
1075
1071
|
if (!sessionId) return ['manager', 'owner'];
|
|
@@ -1079,6 +1075,7 @@ function rolesFor(db, cardId, sessionId, params = {}) {
|
|
|
1079
1075
|
const task = engine.getTask(db, cardId);
|
|
1080
1076
|
if (!task) return [];
|
|
1081
1077
|
const roles = [];
|
|
1078
|
+
if (creatorOf(db, cardId)?.endsWith(`:${sessionId}`)) roles.push('creator');
|
|
1082
1079
|
const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
|
|
1083
1080
|
if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
|
|
1084
1081
|
else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
|
|
@@ -1099,6 +1096,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1099
1096
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1100
1097
|
const card = engine.getTask(db, rest[0]);
|
|
1101
1098
|
if (!card) return; // the verb itself answers "no card"
|
|
1099
|
+
if (creatorOf(db, card.id)?.endsWith(`:${callerSession()}`)) return; // its declared creator doors still govern the act
|
|
1102
1100
|
// a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
|
|
1103
1101
|
if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
|
|
1104
1102
|
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}`);
|
|
@@ -1139,7 +1137,8 @@ async function serve(root, args = {}) {
|
|
|
1139
1137
|
const params = { ...(Number(args.max) > 0 ? { max_spawn: Number(args.max) } : {}), ...(Number(args['failure-limit']) > 0 ? { failure_limit: Number(args['failure-limit']) } : {}) };
|
|
1140
1138
|
if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
|
|
1141
1139
|
let stopped = false;
|
|
1142
|
-
|
|
1140
|
+
let events;
|
|
1141
|
+
const stop = () => { stopped = true; events?.close(); };
|
|
1143
1142
|
process.on('SIGTERM', stop);
|
|
1144
1143
|
process.on('SIGINT', stop);
|
|
1145
1144
|
// what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
|
|
@@ -1154,9 +1153,12 @@ async function serve(root, args = {}) {
|
|
|
1154
1153
|
const installed = () => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? null; } catch { return null; } };
|
|
1155
1154
|
const running = installed();
|
|
1156
1155
|
log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
|
|
1156
|
+
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
1157
|
+
log('dispatch wakes on board writes, document edits, board mail and session catalogue events; interval is a safety sweep');
|
|
1157
1158
|
const stuck = {};
|
|
1158
1159
|
const warned = {};
|
|
1159
1160
|
let pausedSaid = false;
|
|
1161
|
+
let wakeReason = null;
|
|
1160
1162
|
try {
|
|
1161
1163
|
while (!stopped) {
|
|
1162
1164
|
// An installer owns this operational window, not a card pause. Finish the
|
|
@@ -1177,6 +1179,8 @@ async function serve(root, args = {}) {
|
|
|
1177
1179
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
1178
1180
|
let interval = 60;
|
|
1179
1181
|
const began = Date.now();
|
|
1182
|
+
if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
1183
|
+
wakeReason = null;
|
|
1180
1184
|
try {
|
|
1181
1185
|
const state = await laneState(root);
|
|
1182
1186
|
const settings = { ...state.settings, ...override };
|
|
@@ -1186,9 +1190,9 @@ async function serve(root, args = {}) {
|
|
|
1186
1190
|
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
1187
1191
|
if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
|
1188
1192
|
if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
|
|
1189
|
-
//
|
|
1190
|
-
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board]
|
|
1191
|
-
if (stuck[r.board] >=
|
|
1193
|
+
// Event bursts do not shorten the six-sweep stuck warning.
|
|
1194
|
+
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
|
|
1195
|
+
if (stuck[r.board] && Date.now() - stuck[r.board] >= interval * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
|
|
1192
1196
|
for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
|
|
1193
1197
|
for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
|
|
1194
1198
|
for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
|
|
@@ -1201,14 +1205,19 @@ async function serve(root, args = {}) {
|
|
|
1201
1205
|
const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
|
|
1202
1206
|
let moved = newer();
|
|
1203
1207
|
// the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
await
|
|
1207
|
-
|
|
1208
|
+
events.refresh();
|
|
1209
|
+
if (!stopped && !moved) {
|
|
1210
|
+
wakeReason = await events.wait(Math.max(0, interval * 1000 - (Date.now() - began)));
|
|
1211
|
+
// A busy event stream is coalesced to at most one tick per second.
|
|
1212
|
+
const remaining = 1000 - (Date.now() - began);
|
|
1213
|
+
if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
|
|
1214
|
+
moved = newer();
|
|
1208
1215
|
}
|
|
1209
1216
|
if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
|
|
1210
1217
|
}
|
|
1211
1218
|
} finally {
|
|
1219
|
+
events?.close();
|
|
1220
|
+
process.off('SIGTERM', stop); process.off('SIGINT', stop);
|
|
1212
1221
|
if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
|
|
1213
1222
|
}
|
|
1214
1223
|
log(stopped ? 'stopped (a signal)' : 'stopped');
|
package/board/dispatch.mjs
CHANGED
|
@@ -1245,16 +1245,23 @@ async function send(from, label, to, body, key, { queue = false, ask = false } =
|
|
|
1245
1245
|
* 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
1246
|
* (the id is the send's idempotency key). One that fails is tried again next tick.
|
|
1247
1247
|
*/
|
|
1248
|
-
async function sendBoardMessages(board, report) {
|
|
1248
|
+
export async function sendBoardMessages(board, report, taskId = null) {
|
|
1249
1249
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, payload FROM task_events e WHERE kind = 'board_message' AND NOT EXISTS
|
|
1250
1250
|
(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
1251
|
for (const row of rows) {
|
|
1252
|
+
if (taskId && row.task_id !== taskId) continue;
|
|
1252
1253
|
const m = json(row.payload, {}) ?? {};
|
|
1253
1254
|
try {
|
|
1254
1255
|
await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
|
|
1255
1256
|
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
1257
|
} catch (error) {
|
|
1257
|
-
|
|
1258
|
+
const reason = String(error.message).slice(-200);
|
|
1259
|
+
report.undelivered.push([row.task_id, m.to, reason]);
|
|
1260
|
+
tx(board, db => {
|
|
1261
|
+
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);
|
|
1262
|
+
if (json(previous?.payload, {})?.reason !== reason || now() - (previous?.created_at ?? 0) >= 60)
|
|
1263
|
+
event(db, row.task_id, 'board_message_failed', { event: row.id, to: m.to, message_id: m.message_id, reason });
|
|
1264
|
+
});
|
|
1258
1265
|
}
|
|
1259
1266
|
}
|
|
1260
1267
|
}
|
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,7 @@ 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 } : {}) });
|
|
215
219
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
216
220
|
if (arc) {
|
|
217
221
|
// the arc's subscribers hear its subtasks, and the arc's session works them
|
|
@@ -382,6 +386,10 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
|
|
|
382
386
|
if (!await sessionNotice(board, e)) continue;
|
|
383
387
|
}
|
|
384
388
|
else if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
|
|
389
|
+
else if (e.kind === 'board_message') {
|
|
390
|
+
const { sendBoardMessages } = await import('./dispatch.mjs');
|
|
391
|
+
await sendBoardMessages(board, { undelivered: [] }, e.id);
|
|
392
|
+
}
|
|
385
393
|
else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
|
|
386
394
|
else if (e.kind === 'stop_run') stopWorker(board, e.runId);
|
|
387
395
|
else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
|
|
@@ -685,7 +693,7 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
|
|
|
685
693
|
|
|
686
694
|
/** Delete the thread events of done and archived cards older than `seconds` (Hermes's `gc` of events). */
|
|
687
695
|
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));
|
|
696
|
+
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
697
|
}
|
|
690
698
|
|
|
691
699
|
// ------------------------------------------------------------------ diagnostics
|
package/board/store.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { dirname, join } from 'node:path';
|
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
17
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
18
18
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
19
|
+
import { signalBoardWrite } from './wake.mjs';
|
|
19
20
|
|
|
20
21
|
export const DEFAULT_BOARD = 'default';
|
|
21
22
|
|
|
@@ -163,7 +164,11 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
|
|
|
163
164
|
* Run `fn(db)` in one write transaction of the board's backing: answers what `fn` answers; rolls back on a throw.
|
|
164
165
|
* A read inside the transaction is the state it writes over.
|
|
165
166
|
*/
|
|
166
|
-
export const tx = (board, fn) =>
|
|
167
|
+
export const tx = (board, fn) => {
|
|
168
|
+
const result = board.tx(fn);
|
|
169
|
+
signalBoardWrite(board.root, actor);
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
167
172
|
export const view = (board, fn) => fn(board.db);
|
|
168
173
|
|
|
169
174
|
// A deferred native transaction, with the whole operation retried on an optimistic conflict.
|
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/wake.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Wake hints never own board state. A missed hint is repaired by the safety
|
|
2
|
+
// sweep; a writer never waits for a dispatcher or for another writer.
|
|
3
|
+
import { watch, existsSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export function signalBoardWrite(root, actor) {
|
|
11
|
+
if (!root || actor?.includes(':board:')) return;
|
|
12
|
+
const path = join(root, '.dispatch-wake.json');
|
|
13
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
14
|
+
try {
|
|
15
|
+
writeFileSync(temporary, JSON.stringify({ at: Date.now(), actor }) + '\n', { mode: 0o600 });
|
|
16
|
+
renameSync(temporary, path);
|
|
17
|
+
} catch { try { unlinkSync(temporary); } catch { /* hints cannot fail a committed verb */ } }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One coalescing queue shared by standalone serve and the hosted dispatcher. */
|
|
21
|
+
export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
22
|
+
const watchers = new Map();
|
|
23
|
+
let pending = null, waiter = null, closed = false, child = null, retry = null, lastError = null;
|
|
24
|
+
const wake = (source, at = Date.now()) => {
|
|
25
|
+
if (closed) return;
|
|
26
|
+
if (!pending) pending = { at, sources: new Set() };
|
|
27
|
+
pending.at = Math.min(pending.at, at);
|
|
28
|
+
pending.sources.add(source);
|
|
29
|
+
waiter?.();
|
|
30
|
+
};
|
|
31
|
+
const directory = (path, key, accept) => {
|
|
32
|
+
const id = `${path}\0${key}`;
|
|
33
|
+
if (watchers.has(id) || !existsSync(path)) return;
|
|
34
|
+
try {
|
|
35
|
+
const handle = watch(path, (_event, filename) => {
|
|
36
|
+
if (filename == null || accept(String(filename))) wake(key);
|
|
37
|
+
});
|
|
38
|
+
handle.on('error', error => {
|
|
39
|
+
handle.close(); watchers.delete(id);
|
|
40
|
+
log(`dispatch watch ${key} failed: ${error.message}; safety sweep remains active`);
|
|
41
|
+
});
|
|
42
|
+
watchers.set(id, handle);
|
|
43
|
+
} catch (error) { log(`dispatch watch ${key} unavailable: ${error.message}; safety sweep remains active`); }
|
|
44
|
+
};
|
|
45
|
+
const file = (path, key) => {
|
|
46
|
+
directory(dirname(path), key, name => name === basename(path));
|
|
47
|
+
try {
|
|
48
|
+
const target = realpathSync(path);
|
|
49
|
+
if (target !== path) directory(dirname(target), key, name => name === basename(target));
|
|
50
|
+
} catch { /* the parent watcher sees a newly created file */ }
|
|
51
|
+
};
|
|
52
|
+
const refresh = () => {
|
|
53
|
+
directory(root, 'board-write', name => {
|
|
54
|
+
if (name !== '.dispatch-wake.json') return false;
|
|
55
|
+
try { const receipt = JSON.parse(readFileSync(join(root, name), 'utf8')); wake('board-write', receipt.at); } catch { wake('board-write'); }
|
|
56
|
+
return false;
|
|
57
|
+
});
|
|
58
|
+
for (const name of ['workflow.yaml', 'config.yaml', 'installation-window.json', 'ESTOP']) file(join(root, name), name);
|
|
59
|
+
file(fileURLToPath(new URL('../package.json', import.meta.url)), 'installation');
|
|
60
|
+
const boards = join(root, 'kanban', 'boards');
|
|
61
|
+
directory(join(root, 'kanban'), 'board-registry', name => ['boards', 'ztrack.json', 'current'].includes(name));
|
|
62
|
+
directory(boards, 'board-registry', () => true);
|
|
63
|
+
const dirs = [join(root, 'kanban'), ...(existsSync(boards) ? readdirSync(boards).map(name => join(boards, name)) : [])];
|
|
64
|
+
for (const dir of dirs) {
|
|
65
|
+
file(join(dir, 'ztrack.json'), 'board-config');
|
|
66
|
+
try {
|
|
67
|
+
const config = JSON.parse(readFileSync(join(dir, 'ztrack.json'), 'utf8'));
|
|
68
|
+
const document = resolve(dir, config.document);
|
|
69
|
+
file(document, `document:${document}`);
|
|
70
|
+
file(`${document}.archive.md`, `document:${document}.archive.md`);
|
|
71
|
+
} catch { /* other backing or not yet created */ }
|
|
72
|
+
}
|
|
73
|
+
const config = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'supercode') : join(env.HOME ?? '', '.config', 'supercode'));
|
|
74
|
+
const mail = join(config, 'mail');
|
|
75
|
+
directory(mail, 'mailbox-registry', name => name.startsWith('board-'));
|
|
76
|
+
if (existsSync(mail)) for (const name of readdirSync(mail)) {
|
|
77
|
+
if (name.startsWith('board-')) directory(join(mail, name, 'new'), 'board-mail', name => name.endsWith('.json'));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
// The retained Teams catalogue includes local and remote session changes.
|
|
81
|
+
// This is a read-only stream, with no model or new native harness session.
|
|
82
|
+
const follow = () => {
|
|
83
|
+
if (closed) return;
|
|
84
|
+
let buffer = '', error = '';
|
|
85
|
+
const command = env.SUPERCODE_TEAMS_ENTRY ? process.execPath : binary;
|
|
86
|
+
const args = [...(env.SUPERCODE_TEAMS_ENTRY ? [env.SUPERCODE_TEAMS_ENTRY] : []), 'sessions', 'list', '--watch', '--json'];
|
|
87
|
+
child = spawn(command, args, { cwd: tmpdir(), env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
88
|
+
child.stdout.setEncoding('utf8').on('data', chunk => {
|
|
89
|
+
buffer += chunk;
|
|
90
|
+
if (buffer.length > 8 * 1024 * 1024) { error = 'catalogue frame exceeds 8 MiB'; child.kill(); return; }
|
|
91
|
+
let end;
|
|
92
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
93
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
94
|
+
try {
|
|
95
|
+
const event = JSON.parse(line);
|
|
96
|
+
lastError = null;
|
|
97
|
+
if (!['heartbeat', 'ping', 'keepalive'].includes(event.type)) wake('session-catalogue');
|
|
98
|
+
} catch { /* CLI diagnostics are not events */ }
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.stderr.setEncoding('utf8').on('data', chunk => { error = (error + chunk).slice(-500); });
|
|
102
|
+
child.on('error', failure => { error = failure.message; });
|
|
103
|
+
child.on('close', () => {
|
|
104
|
+
child = null;
|
|
105
|
+
if (!closed) {
|
|
106
|
+
const reason = error.trim() || 'connection closed';
|
|
107
|
+
if (reason !== lastError) log(`dispatch session stream ended: ${reason}; reconnecting, safety sweep remains active`);
|
|
108
|
+
lastError = reason;
|
|
109
|
+
retry = setTimeout(follow, 5000);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
refresh(); follow();
|
|
114
|
+
return {
|
|
115
|
+
refresh,
|
|
116
|
+
wake,
|
|
117
|
+
async wait(milliseconds) {
|
|
118
|
+
if (!pending && !closed) await new Promise(done => {
|
|
119
|
+
const timer = setTimeout(finish, Math.max(0, milliseconds));
|
|
120
|
+
function finish() { clearTimeout(timer); waiter = null; done(); }
|
|
121
|
+
waiter = finish;
|
|
122
|
+
});
|
|
123
|
+
// Bound coalescing: a sustained event stream cannot postpone dispatch.
|
|
124
|
+
if (pending && !closed) await new Promise(done => setTimeout(done, 100));
|
|
125
|
+
const result = pending; pending = null;
|
|
126
|
+
return result ? { at: result.at, sources: [...result.sources] } : null;
|
|
127
|
+
},
|
|
128
|
+
close() {
|
|
129
|
+
closed = true; waiter?.(); clearTimeout(retry);
|
|
130
|
+
for (const handle of watchers.values()) handle.close();
|
|
131
|
+
watchers.clear(); child?.kill('SIGTERM');
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
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,7 @@ 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),
|
|
217
225
|
status,
|
|
218
226
|
blocked_by: blocks,
|
|
219
227
|
paused: json(state.paused, null),
|
|
@@ -275,6 +283,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
275
283
|
for (const [key, expr] of Object.entries(loaded.get(board.root)?.workflow?.derived ?? {})) card[key] = evaluate(expr, { card, event, params, now: now() });
|
|
276
284
|
return {
|
|
277
285
|
card,
|
|
286
|
+
requester: actorOf(),
|
|
278
287
|
event,
|
|
279
288
|
params,
|
|
280
289
|
now: now(),
|
|
@@ -751,6 +760,7 @@ const ACTIONS = {
|
|
|
751
760
|
const id = `m-${randomBytes(12).toString('hex')}`;
|
|
752
761
|
ctx.scope.asked = id;
|
|
753
762
|
record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
|
|
763
|
+
ctx.effects.push({ kind: 'board_message', id: ctx.id });
|
|
754
764
|
},
|
|
755
765
|
send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
|
|
756
766
|
};
|
|
@@ -808,7 +818,7 @@ function runSession(ctx, change) {
|
|
|
808
818
|
* the board: `--root …` or `--at …`) and its transition's description: what a started session is told it can do.
|
|
809
819
|
*/
|
|
810
820
|
// 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' };
|
|
821
|
+
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
822
|
|
|
813
823
|
export function doors(workflow, status, role, id, where, surface = 'pane') {
|
|
814
824
|
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 });
|
package/board.mjs
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// docs/plans/hermes-compat.md row 14). The board and its dispatcher are supercode's own (`board/`): Hermes Kanban's
|
|
3
3
|
// format and lifecycle, each profile's lane decided by its `worker` in the IR. Where a Hermes gateway would run
|
|
4
4
|
// its dispatcher, the daemon runs this one the same way: when the board's workflow says so (its `dispatch.serve`),
|
|
5
|
-
// taking no file locks,
|
|
6
|
-
//
|
|
5
|
+
// taking no file locks, waking on events with `dispatch.tick` as a safety sweep.
|
|
6
|
+
// Each tick runs as
|
|
7
7
|
// `workflow dispatch` in a child process: a tick waits on supercode (the fleet, launches, mail), and the daemon's
|
|
8
8
|
// own loop never does.
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { summary } from './board/cli.mjs';
|
|
12
|
-
import { notifyChats } from './board/dispatch.mjs';
|
|
12
|
+
import { commandEnv, notifyChats, supercodeBin } from './board/dispatch.mjs';
|
|
13
|
+
import { watchBoard } from './board/wake.mjs';
|
|
13
14
|
import { workflowEntry } from './board/workflow.mjs';
|
|
14
15
|
import { evaluate, holds } from './board/expr.mjs';
|
|
15
16
|
|
|
@@ -52,8 +53,8 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
|
|
|
52
53
|
try { await notifyChats(root, adapters, log, wake); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); }
|
|
53
54
|
for (const r of reports) {
|
|
54
55
|
const line = summary(r);
|
|
55
|
-
stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board]
|
|
56
|
-
if (stuck[r.board] >=
|
|
56
|
+
stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
|
|
57
|
+
if (stuck[r.board] && Date.now() - stuck[r.board] >= seconds * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log('warn', `board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s`); warned[r.board] = Date.now(); }
|
|
57
58
|
if (r.paused && !said.has(`paused:${r.board}`)) { said.add(`paused:${r.board}`); log('info', `board ${r.board}: new starts paused by ${r.paused}`); }
|
|
58
59
|
if (!r.paused) said.delete(`paused:${r.board}`);
|
|
59
60
|
if (!line.endsWith('nothing to do')) log(r.error ? 'warn' : 'info', line);
|
|
@@ -67,12 +68,22 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
|
|
|
67
68
|
running = false;
|
|
68
69
|
}
|
|
69
70
|
};
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
const events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log: message => log('warn', message) });
|
|
72
|
+
void (async () => {
|
|
73
|
+
let wakeReason = null;
|
|
74
|
+
while (!stopped) {
|
|
75
|
+
const began = Date.now();
|
|
76
|
+
if (wakeReason) log('info', `board: dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
77
|
+
await tick();
|
|
78
|
+
events.refresh();
|
|
79
|
+
wakeReason = await events.wait(Math.max(0, seconds * 1000 - (Date.now() - began)));
|
|
80
|
+
const remaining = 1000 - (Date.now() - began);
|
|
81
|
+
if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
|
|
82
|
+
}
|
|
83
|
+
})().catch(error => log('warn', `board: dispatch loop failed: ${error.message}`));
|
|
84
|
+
log('info', `board: dispatching on events, safety sweep every ${seconds}s, each card as its profile's worker says`);
|
|
74
85
|
return {
|
|
75
|
-
tick,
|
|
76
|
-
stop() { stopped = true;
|
|
86
|
+
tick() { events.wake('requested'); },
|
|
87
|
+
stop() { stopped = true; events.close(); },
|
|
77
88
|
};
|
|
78
89
|
}
|
|
@@ -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.126",
|
|
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": {
|