@volter/supercode-orchestrator 0.4.13 → 0.4.14
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/bin/hermes-worker.mjs +2 -3
- package/bin/orchestrator.mjs +16 -0
- package/board.mjs +38 -47
- package/hermes-cli.mjs +0 -51
- package/kanban-worker.mjs +7 -9
- package/ontology.d.ts +31 -1
- package/orchestration.mjs +5 -0
- package/package.json +1 -1
package/bin/hermes-worker.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The command
|
|
3
|
-
//
|
|
4
|
-
// environment; see `kanban-worker.mjs`.
|
|
2
|
+
// The command the board's dispatcher runs as a headless profile's worker (docs/plans/hermes-compat.md row 14):
|
|
3
|
+
// Hermes's own worker argv and environment; see `kanban-worker.mjs`.
|
|
5
4
|
import { runKanbanWorker } from '../kanban-worker.mjs';
|
|
6
5
|
|
|
7
6
|
// Hermes watches this pid: it exits the moment the turn is over, whatever handle is still open.
|
package/bin/orchestrator.mjs
CHANGED
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
// jobs create --harness orchestrator` writes the same bytes whether the daemon
|
|
48
48
|
// is up or down. It exits 0 on `ok`, 1 on a refusal.
|
|
49
49
|
//
|
|
50
|
+
// And `workflow <verb>`: supercode's own board, in Hermes Kanban's format, and its dispatcher
|
|
51
|
+
// (`supercode workflow <verb>` hands every verb but `load` here; board/cli.mjs).
|
|
52
|
+
//
|
|
50
53
|
// Everything it prints is one JSON object per line on stdout.
|
|
51
54
|
|
|
52
55
|
import { existsSync, mkdirSync, rmdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
@@ -559,6 +562,19 @@ async function main() {
|
|
|
559
562
|
process.exit(1);
|
|
560
563
|
}
|
|
561
564
|
}
|
|
565
|
+
if (argv[0] === 'workflow') {
|
|
566
|
+
// supercode's own board and its dispatcher (`supercode workflow <verb>`, board/cli.mjs)
|
|
567
|
+
const { runWorkflow } = await import('../board/cli.mjs');
|
|
568
|
+
try {
|
|
569
|
+
const code = await runWorkflow(argv.slice(1));
|
|
570
|
+
await closeWorld();
|
|
571
|
+
process.exit(code);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
process.stderr.write(`supercode workflow: ${error.message ?? error}\n`);
|
|
574
|
+
await closeWorld();
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
562
578
|
if (argv[0] === 'access') {
|
|
563
579
|
await accessMain(argv.slice(1));
|
|
564
580
|
return;
|
package/board.mjs
CHANGED
|
@@ -1,71 +1,62 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
|
-
import { dirname, join, resolve } from 'node:path';
|
|
1
|
+
// The home's board, dispatched while the orchestrator serves the home (docs/architecture/orchestrator.md §2.9,
|
|
2
|
+
// docs/plans/hermes-compat.md row 14). The board and its dispatcher are supercode's own (`board/`): Hermes Kanban's
|
|
3
|
+
// format and lifecycle, each profile's lane decided by its `worker` in the IR. Where a Hermes gateway would run
|
|
4
|
+
// its dispatcher, the daemon runs this one the same way: gated by `kanban.dispatch_in_gateway` (and
|
|
5
|
+
// `HERMES_KANBAN_DISPATCH_IN_GATEWAY`), holding the board's one-dispatcher lock (`kanban/.dispatcher.lock`, the
|
|
6
|
+
// lock Hermes's gateways take, so the two never dispatch one board together), one tick every
|
|
7
|
+
// `kanban.dispatch_interval_seconds` (60, at least 1), the first after five seconds. Each tick runs as
|
|
8
|
+
// `workflow dispatch` in a child process: a tick waits on supercode (the fleet, launches, mail), and the daemon's
|
|
9
|
+
// own loop never does.
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
13
11
|
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { hermesInstall, kanbanTick } from './hermes-cli.mjs';
|
|
15
12
|
import { holdDispatcherLock } from './hermes-locks.mjs';
|
|
13
|
+
import { summary } from './board/cli.mjs';
|
|
16
14
|
|
|
17
|
-
const
|
|
15
|
+
const ENTRY = fileURLToPath(new URL('./bin/orchestrator.mjs', import.meta.url));
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
function dispatchOnce(root) {
|
|
18
|
+
return new Promise((done) => {
|
|
19
|
+
const child = spawn(process.execPath, [ENTRY, 'workflow', 'dispatch', '--root', root, '--json'], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
20
|
+
let out = '';
|
|
21
|
+
let err = '';
|
|
22
|
+
child.stdout.setEncoding('utf8').on('data', (c) => { out += c; });
|
|
23
|
+
child.stderr.setEncoding('utf8').on('data', (c) => { err = (err + c).slice(-2000); });
|
|
24
|
+
child.on('error', (error) => done({ error: error.message }));
|
|
25
|
+
child.on('close', () => {
|
|
26
|
+
const line = out.trim().split('\n').pop();
|
|
27
|
+
try { done({ reports: JSON.parse(line) }); } catch { done({ error: (err || out).trim().slice(-500) || 'no answer' }); }
|
|
28
|
+
});
|
|
29
|
+
});
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
/** Start the board's dispatcher for the home at `root`; answers `{ stop }`, or null when it does not dispatch. */
|
|
32
|
+
/** Start the board's dispatcher for the home at `root`; answers `{ tick, stop }`, or null when it does not dispatch. */
|
|
30
33
|
export async function startBoard({ root, config = {}, log = () => {} }) {
|
|
31
34
|
const kanban = config.kanban ?? {};
|
|
32
35
|
const off = String(process.env.HERMES_KANBAN_DISPATCH_IN_GATEWAY ?? '').trim().toLowerCase();
|
|
33
36
|
if (['0', 'false', 'no', 'off'].includes(off)) { log('info', 'board: not dispatched (HERMES_KANBAN_DISPATCH_IN_GATEWAY)'); return null; }
|
|
34
37
|
if (kanban.dispatch_in_gateway === false) { log('info', 'board: not dispatched (kanban.dispatch_in_gateway: false)'); return null; }
|
|
35
|
-
const install = hermesInstall();
|
|
36
|
-
if (!install?.python) { log('warn', 'board: not dispatched; the board is Hermes\'s and no Hermes is installed on PATH'); return null; }
|
|
37
38
|
let lock;
|
|
38
|
-
try { lock = await holdDispatcherLock(root
|
|
39
|
+
try { lock = await holdDispatcherLock(root); }
|
|
39
40
|
catch (error) { log('info', `board: not dispatched: ${error.message}`); return null; }
|
|
40
|
-
const env = {
|
|
41
|
-
HERMES_BIN: workerCommand(root),
|
|
42
|
-
PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ''}`,
|
|
43
|
-
...(process.env.SUPERCODE_BIN ? { SUPERCODE_BIN: process.env.SUPERCODE_BIN } : {}),
|
|
44
|
-
...(process.env.XDG_STATE_HOME ? { XDG_STATE_HOME: process.env.XDG_STATE_HOME } : {}),
|
|
45
|
-
};
|
|
46
41
|
const seconds = Math.max(Number(kanban.dispatch_interval_seconds) || 60, 1);
|
|
47
42
|
let running = false;
|
|
48
43
|
let stopped = false;
|
|
49
44
|
const said = new Set();
|
|
50
|
-
// what a Hermes gateway does around the dispatcher and this one does not, said once per board
|
|
51
|
-
const unserved = (board) => {
|
|
52
|
-
if (board.subscriptions && !said.has(`subs:${board.board}`)) { said.add(`subs:${board.board}`); log('warn', `board ${board.board}: ${board.subscriptions} chat subscription(s) are delivered by Hermes's gateway notifier, which this orchestrator does not run`); }
|
|
53
|
-
if (board.triage && !said.has(`triage:${board.board}`)) { said.add(`triage:${board.board}`); log('warn', `board ${board.board}: ${board.triage} triage task(s) wait for decomposition, which Hermes's gateway runs on its auxiliary model and this orchestrator does not`); }
|
|
54
|
-
};
|
|
55
45
|
const tick = async () => {
|
|
56
46
|
if (running || stopped) return;
|
|
57
47
|
running = true;
|
|
58
48
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
49
|
+
const { reports, error } = await dispatchOnce(root);
|
|
50
|
+
if (error) { log('warn', `board: tick failed: ${error}`); return; }
|
|
51
|
+
for (const r of reports) {
|
|
52
|
+
const line = summary(r);
|
|
53
|
+
if (!line.endsWith('nothing to do')) log(r.error ? 'warn' : 'info', line);
|
|
54
|
+
for (const [task, why] of r.failed ?? []) log('warn', `board ${r.board}: ${task} did not start: ${why}`);
|
|
55
|
+
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a notice for ${task} to ${to} was not delivered: ${why}`);
|
|
56
|
+
if (r.chat_subscriptions && !said.has(`subs:${r.board}`)) { said.add(`subs:${r.board}`); log('warn', `board ${r.board}: ${r.chat_subscriptions} subscription(s) name a chat platform, which Hermes's gateway notifier serves and this dispatcher does not (it tells supercode sessions)`); }
|
|
57
|
+
if (r.triage && !said.has(`triage:${r.board}`)) { said.add(`triage:${r.board}`); log('warn', `board ${r.board}: ${r.triage} triage card(s) wait for decomposition, which Hermes's gateway runs on its auxiliary model and this dispatcher does not`); }
|
|
58
|
+
if (r.nonspawnable?.length && !said.has(`lane:${r.board}`)) { said.add(`lane:${r.board}`); log('warn', `board ${r.board}: ${r.nonspawnable.length} card(s) name an assignee that is no profile of this home, and wait`); }
|
|
66
59
|
}
|
|
67
|
-
} catch (error) {
|
|
68
|
-
log('warn', `board: tick failed: ${error.message}`);
|
|
69
60
|
} finally {
|
|
70
61
|
running = false;
|
|
71
62
|
}
|
|
@@ -73,7 +64,7 @@ export async function startBoard({ root, config = {}, log = () => {} }) {
|
|
|
73
64
|
const first = setTimeout(() => { void tick(); }, 5_000);
|
|
74
65
|
const timer = setInterval(() => { void tick(); }, seconds * 1000);
|
|
75
66
|
first.unref?.(); timer.unref?.();
|
|
76
|
-
log('info', `board: dispatching every ${seconds}s, each
|
|
67
|
+
log('info', `board: dispatching every ${seconds}s, each card as its profile's worker says`);
|
|
77
68
|
return {
|
|
78
69
|
tick,
|
|
79
70
|
stop() { stopped = true; clearTimeout(first); clearInterval(timer); lock.release(); },
|
package/hermes-cli.mjs
CHANGED
|
@@ -367,57 +367,6 @@ export function hermesSchedule(profileDir, text) {
|
|
|
367
367
|
return out;
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
-
// One tick of Hermes's board dispatcher, as a Hermes gateway runs it (gateway/kanban_watchers.py
|
|
371
|
-
// `_kanban_dispatcher_watcher`): the home's `kanban:` settings read as the gateway reads them, then
|
|
372
|
-
// `kanban_db.dispatch_once` on every board. Hermes claims, reclaims, promotes and spawns; its spawn runs
|
|
373
|
-
// `HERMES_BIN`, which the caller sets to the orchestrator's worker command.
|
|
374
|
-
const KANBAN_TICK = `
|
|
375
|
-
import json, sys
|
|
376
|
-
a = json.loads(sys.stdin.read())
|
|
377
|
-
from hermes_cli.config import load_config
|
|
378
|
-
from hermes_cli import kanban_db as kb
|
|
379
|
-
cfg = ((load_config() or {}).get("kanban") or {})
|
|
380
|
-
def pos_int(v):
|
|
381
|
-
try: v = int(v)
|
|
382
|
-
except (TypeError, ValueError): return None
|
|
383
|
-
return v if v >= 1 else None
|
|
384
|
-
max_in_progress = kb.resolve_max_in_progress(pos_int(cfg.get("max_in_progress")))
|
|
385
|
-
try: failure_limit = int(cfg.get("failure_limit", kb.DEFAULT_FAILURE_LIMIT))
|
|
386
|
-
except (TypeError, ValueError): failure_limit = kb.DEFAULT_FAILURE_LIMIT
|
|
387
|
-
if failure_limit < 1: failure_limit = kb.DEFAULT_FAILURE_LIMIT
|
|
388
|
-
try: stale = int(cfg.get("dispatch_stale_timeout_seconds", 0) or 0)
|
|
389
|
-
except (TypeError, ValueError): stale = 0
|
|
390
|
-
default_assignee = (cfg.get("default_assignee") or "").strip() or None
|
|
391
|
-
try: boards = kb.list_boards(include_archived=False)
|
|
392
|
-
except Exception: boards = [kb.read_board_metadata(kb.DEFAULT_BOARD)]
|
|
393
|
-
out = []
|
|
394
|
-
for b in boards:
|
|
395
|
-
slug = b.get("slug") or kb.DEFAULT_BOARD
|
|
396
|
-
conn = None
|
|
397
|
-
try:
|
|
398
|
-
conn = kb.connect(board=slug)
|
|
399
|
-
r = kb.dispatch_once(conn, board=slug, max_spawn=cfg.get("max_spawn"), max_in_progress=max_in_progress,
|
|
400
|
-
failure_limit=failure_limit, stale_timeout_seconds=stale, default_assignee=default_assignee,
|
|
401
|
-
max_in_progress_per_profile=pos_int(cfg.get("max_in_progress_per_profile")),
|
|
402
|
-
reconcile_orphans=bool(cfg.get("reconcile_orphans", True)))
|
|
403
|
-
count = lambda sql: conn.execute(sql).fetchone()[0]
|
|
404
|
-
out.append({"board": slug, "spawned": [list(x) for x in r.spawned], "reclaimed": r.reclaimed, "crashed": r.crashed,
|
|
405
|
-
"timed_out": r.timed_out, "skipped_locked": bool(getattr(r, "skipped_locked", False)),
|
|
406
|
-
"subscriptions": count("SELECT COUNT(*) FROM kanban_notify_subs"), "triage": count("SELECT COUNT(*) FROM tasks WHERE status = 'triage'")})
|
|
407
|
-
except Exception as error:
|
|
408
|
-
out.append({"board": slug, "error": str(error)})
|
|
409
|
-
finally:
|
|
410
|
-
if conn is not None: conn.close()
|
|
411
|
-
print("@@hermes@@" + json.dumps(out, default=str))
|
|
412
|
-
`;
|
|
413
|
-
|
|
414
|
-
/** One dispatcher tick over the board at `root` (Hermes's `kanban_home`), `env` adding `HERMES_BIN` and the like. */
|
|
415
|
-
export async function kanbanTick(root, env = {}) {
|
|
416
|
-
const install = hermesInstall();
|
|
417
|
-
if (!install?.python) throw new Error('the board is Hermes\'s, and no Hermes is installed on PATH');
|
|
418
|
-
return hermesAnswer('kanban dispatch', await python(install, KANBAN_TICK, {}, { ...hermesEnv(install, root), ...env }, 120_000));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
370
|
/**
|
|
422
371
|
* Hermes's MCP trust gate for a relayed tool call (tools/mcp_tool.py `_trust_gate_check`): a tool of one of the
|
|
423
372
|
* profile's servers configured anything but `trust: full` (unset is full) is asked in a conversation and denied
|
package/kanban-worker.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
// A worker for one board task (docs/plans/hermes-compat.md row 14).
|
|
2
|
-
// as
|
|
3
|
-
// [--
|
|
4
|
-
// workspace, its output to the task's log, the task in its environment (`KANBAN_ENV`), and watches the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// board's tools on its door, prints the answer to the log and exits, as `hermes chat -q` does. A profile
|
|
9
|
-
// whose worker is Hermes runs Hermes itself.
|
|
1
|
+
// A worker for one board task (docs/plans/hermes-compat.md row 14). The board's dispatcher (`board/dispatch.mjs`)
|
|
2
|
+
// starts a headless profile's worker as Hermes's dispatcher starts its own: `-p <assignee> --cli --accept-hooks
|
|
3
|
+
// [--skills <s>]… [-m <model> [--provider <p>]] [--reasoning <e>] chat -q "work kanban task <id>"`, detached, in the
|
|
4
|
+
// task's workspace, its output to the task's log, the task in its environment (`KANBAN_ENV`), and watches the pid.
|
|
5
|
+
// This is that process: it runs the assignee profile's own worker for the one query, with Hermes's task protocol,
|
|
6
|
+
// the task's skills and the home's memory in its system prompt and the board's tools on its door, prints the answer
|
|
7
|
+
// to the log and exits, as `hermes chat -q` does. A profile whose worker is Hermes runs Hermes itself.
|
|
10
8
|
import { spawn } from 'node:child_process';
|
|
11
9
|
import { existsSync, statSync } from 'node:fs';
|
|
12
10
|
import { basename, dirname, join, resolve } from 'node:path';
|
package/ontology.d.ts
CHANGED
|
@@ -208,7 +208,10 @@ export interface Job {
|
|
|
208
208
|
failure_deliver?: Target | null;
|
|
209
209
|
/** The creating conversation. */
|
|
210
210
|
origin?: JobOrigin | null;
|
|
211
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* The fire is a turn in the live session of the conversation it delivers to (steering a running turn where
|
|
213
|
+
* the worker allows), instead of a session of its own; Hermes mirrors it into that transcript.
|
|
214
|
+
*/
|
|
212
215
|
attach_to_session?: boolean | null;
|
|
213
216
|
/** Remaining-runs counter for `once` jobs. */
|
|
214
217
|
repeat?: Repeat | null;
|
|
@@ -540,6 +543,16 @@ export interface Worker {
|
|
|
540
543
|
locator?: string | null;
|
|
541
544
|
}
|
|
542
545
|
|
|
546
|
+
/** Whose Claude home (or harness home) a worker runs in. */
|
|
547
|
+
export type WorkerHome =
|
|
548
|
+
/** The profile folder is the harness's whole config home (its persona, skills and MCP servers only). */
|
|
549
|
+
| 'profile'
|
|
550
|
+
/**
|
|
551
|
+
* The user's own harness home (their instructions, skills, hooks, plugins, MCP servers and login), with the
|
|
552
|
+
* profile's persona and skills brought in beside it for the session.
|
|
553
|
+
*/
|
|
554
|
+
| 'user';
|
|
555
|
+
|
|
543
556
|
/** The worker specification (O-record; Hermes has no worker choice). */
|
|
544
557
|
export interface WorkerSpec {
|
|
545
558
|
/** Any registry id whose runtime can start a session. */
|
|
@@ -554,4 +567,21 @@ export interface WorkerSpec {
|
|
|
554
567
|
env?: Record<string, EnvValue>;
|
|
555
568
|
/** Permission prompt policy. */
|
|
556
569
|
permission?: PermissionPolicy;
|
|
570
|
+
/** Whose harness home the worker runs in. */
|
|
571
|
+
home?: WorkerHome;
|
|
572
|
+
/** How the board runs this profile's tasks. */
|
|
573
|
+
surface?: WorkerSurface;
|
|
574
|
+
/** At most this many of the profile's board tasks run at once; the rest wait in `ready`. */
|
|
575
|
+
capacity?: number | null;
|
|
557
576
|
}
|
|
577
|
+
|
|
578
|
+
/** How a board task's session runs when the board dispatches it to this profile. */
|
|
579
|
+
export type WorkerSurface =
|
|
580
|
+
/** Hermes's worker: one query, headless, closed when it answers. */
|
|
581
|
+
| 'headless'
|
|
582
|
+
/**
|
|
583
|
+
* An interactive session in a terminal pane on the machine the card names, started with a
|
|
584
|
+
* session id the dispatcher chose, kept open, resumed when lost and replaced from the card only
|
|
585
|
+
* when it cannot be resumed.
|
|
586
|
+
*/
|
|
587
|
+
| 'pane';
|
package/orchestration.mjs
CHANGED
|
@@ -155,6 +155,11 @@ export async function load(root) {
|
|
|
155
155
|
return { state: stateOf(orchestration), vault };
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** The home at `root` as the IR reads it, and nothing written: a reader's view (the board's lanes and settings). */
|
|
159
|
+
export async function read(root) {
|
|
160
|
+
return (await through((d) => d.orchestrationLoad({ root }))).orchestration;
|
|
161
|
+
}
|
|
162
|
+
|
|
158
163
|
/** `save(state, root)` — our folder, canonical and byte-stable: an artifact whose record has not changed keeps its bytes. */
|
|
159
164
|
export async function save(state, root = state.root) {
|
|
160
165
|
await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state) }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
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": {
|