@volter/supercode-orchestrator 0.4.12 → 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.
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // The command Hermes's board dispatcher runs as its worker when the orchestrator serves the home: the
3
- // loop sets `HERMES_BIN` to this file (docs/plans/hermes-compat.md row 14). Hermes's own argv and
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.
@@ -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 board's dispatcher (docs/plans/hermes-compat.md row 14). A home's board is Hermes's: `kanban.db` at
2
- // the root, above the profiles, in Hermes's schema, written by Hermes's code. Where a Hermes gateway
3
- // would host the dispatcher, the orchestrator hosts it the same way: gated by `kanban.dispatch_in_gateway`
4
- // (and `HERMES_KANBAN_DISPATCH_IN_GATEWAY`), holding Hermes's one-dispatcher lock, one tick every
5
- // `kanban.dispatch_interval_seconds` (60, at least 1), the first after five seconds; each tick is Hermes's
6
- // own (`kanbanTick`). What differs is the worker: Hermes's spawn runs `HERMES_BIN`, here a wrapper that
7
- // starts `bin/hermes-worker.mjs` on this daemon's own runtime, so each task runs on its assignee
8
- // profile's worker harness. `hermes kanban` reads and writes the same file.
9
- import { createHash } from 'node:crypto';
10
- import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
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 WORKER = fileURLToPath(new URL('./bin/hermes-worker.mjs', import.meta.url));
15
+ const ENTRY = fileURLToPath(new URL('./bin/orchestrator.mjs', import.meta.url));
18
16
 
19
- /** The `HERMES_BIN` for a root: a script that runs the worker command on this very runtime. */
20
- function workerCommand(root) {
21
- const dir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'supercode', 'hermes-home', createHash('sha256').update(resolve(root)).digest('hex').slice(0, 16));
22
- mkdirSync(dir, { recursive: true });
23
- const file = join(dir, 'hermes-worker');
24
- writeFileSync(file, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(WORKER)} "$@"\n`);
25
- chmodSync(file, 0o755);
26
- return file;
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, { python: install.python }); }
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
- for (const board of await kanbanTick(root, env)) {
60
- if (board.error) log('warn', `board ${board.board}: ${board.error}`);
61
- unserved(board);
62
- for (const [task, assignee] of board.spawned ?? []) log('info', `board ${board.board}: ${task} spawned for ${assignee}`);
63
- for (const [what, ids] of [['reclaimed', board.reclaimed], ['crashed', board.crashed], ['timed out', board.timed_out]]) {
64
- if (Array.isArray(ids) && ids.length) log('info', `board ${board.board}: ${what} ${ids.join(', ')}`);
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 task on its assignee's worker`);
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). Hermes's dispatcher spawns its worker
2
- // as `hermes -p <assignee> --cli --accept-hooks [--skills <s>]… [-m <model> [--provider <p>]]
3
- // [--reasoning <e>] [--toolsets <t>] chat -q "work kanban task <id>" [-Q]`, detached, in the task's
4
- // workspace, its output to the task's log, the task in its environment (`KANBAN_ENV`), and watches the
5
- // pid. The orchestrator's loop runs that dispatcher with `HERMES_BIN` naming `bin/hermes-worker.mjs`, so
6
- // this is the process Hermes spawned and watches: it runs the assignee profile's own worker for the one
7
- // query, with Hermes's task protocol, the task's skills and the home's memory in its system prompt and the
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
- /** Hermes: mirror the fire's output into the target conversation's transcript. */
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.12",
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": {
package/reducer.mjs CHANGED
@@ -549,8 +549,8 @@ function handleTick(state, ev, effects) {
549
549
  // ---------------------------------------------------------------- restart / adapter / operator
550
550
 
551
551
  // §4.7 on a clean stop: the daemon closes every runtime, so each binding that held one keeps it as the runtime to
552
- // resume, as a crash's pass marks it, and the next line reopens the same session. A turn cut off mid-way is told so
553
- // on its surface; an idle binding is not.
552
+ // resume, as a crash's pass marks it, and the restart reopens the same session (a clean stop is a restart's). A turn
553
+ // cut off mid-way is told so on its surface; an idle binding is not.
554
554
  function handleStopping(state, ev, effects) {
555
555
  const rt = ensureRuntime(state);
556
556
  for (const profile of Object.values(state.profiles)) {
@@ -558,6 +558,8 @@ function handleStopping(state, ev, effects) {
558
558
  if (b.ended_at || ks.includes('#') || !b.residue?.runtime_id) continue;
559
559
  b.residue.resume_runtime_id = b.residue.runtime_id;
560
560
  delete b.residue.runtime_id;
561
+ // a stop for a restart: what was live now is live again once the daemon is back (handleRestart)
562
+ if (b.key.platform !== 'webhook' && b.key.platform !== 'cron') b.residue.reopen_at_start = true;
561
563
  if (rt.turns[leaseId(profile.name, ks)] && !b.recurrence && b.key.platform !== 'webhook' && b.key.platform !== 'cron') {
562
564
  b.residue.interrupted_at = ev.at;
563
565
  reply(state, profile, b.key, REPLY.interrupted, ev.at, effects);
@@ -579,7 +581,16 @@ function handleRestart(state, ev, effects) {
579
581
  // attaches where the worker's door allows it and marks the rest
580
582
  // interrupted (`binding_interrupted` below).
581
583
  for (const [ks, b] of Object.entries(profile.bindings)) {
582
- if (b.ended_at || ks.includes('#') || !b.residue?.runtime_id) continue;
584
+ if (b.ended_at || ks.includes('#')) continue;
585
+ // one the clean stop closed reopens its own session now, so its conversation stays reachable (a message, a
586
+ // voice line) rather than waiting for its next inbound
587
+ if (!b.residue?.runtime_id) {
588
+ if (!b.residue?.reopen_at_start) continue;
589
+ delete b.residue.reopen_at_start;
590
+ live += 1;
591
+ startWorker(state, profile, ks, effects);
592
+ continue;
593
+ }
583
594
  live += 1;
584
595
  // a message arriving while the attach is in flight queues behind it,
585
596
  // rather than starting a fresh runtime that would replace the session