@volter/supercode-orchestrator 0.4.16 → 0.4.17

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 CHANGED
@@ -269,7 +269,7 @@ function callerName() {
269
269
  export function summary(r) {
270
270
  const parts = [`board ${r.board}:`];
271
271
  if (r.error) parts.push(`error ${r.error}`);
272
- for (const [k, label] of [['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices']]) {
272
+ for (const [k, label] of [['mailed', 'mailed updates'], ['started', 'started'], ['queued', 'waiting'], ['failed', 'failed'], ['lost', 'lost'], ['crashed', 'crashed'], ['timed_out', 'timed out'], ['reclaimed', 'reclaimed'], ['auto_blocked', 'gave up'], ['notified', 'notices']]) {
273
273
  if (r[k]?.length) parts.push(`${label} ${r[k].length}`);
274
274
  }
275
275
  if (r.promoted) parts.push(`promoted ${r.promoted}`);
@@ -15,7 +15,7 @@
15
15
  // subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
16
16
  // launch and a crash between the two steps is answered by the next tick.
17
17
  import { spawn as spawnChild, spawnSync } from 'node:child_process';
18
- import { existsSync, mkdirSync, readFileSync, openSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync } from 'node:fs';
19
19
  import { hostname, tmpdir } from 'node:os';
20
20
  import { basename, dirname, join } from 'node:path';
21
21
  import { randomUUID } from 'node:crypto';
@@ -276,10 +276,11 @@ function opening(board, task, label) {
276
276
  const skills = task.skills?.length ? `\nSkills pinned to this card: ${task.skills.join(', ')}.` : '';
277
277
  const at = `--root ${board.root}${board.slug !== 'default' ? ` --board ${board.slug}` : ''}`;
278
278
  // the board's own door is on the machine that keeps it; a session elsewhere writes to its card through its manager
279
- const door = normalMachine(task.machine) === localMachine()
280
- ? `\nYour card is yours to keep current: \`supercode workflow show ${task.id} ${at}\` reads it; \`comment\`, \`block --kind <needs_input|capability|transient|dependency>\` `
281
- + `and \`review\` update it; \`create <title> --subtask-of ${task.id}\` files a subtask, and \`complete <subtask>\` marks one done. Its reviewers, not you, mark the card itself done.`
282
- : '';
279
+ const local = normalMachine(task.machine) === localMachine();
280
+ const where = local ? at : `--at ${boardAddress(label)}`;
281
+ const door = `\nYour card is yours to keep current: \`supercode workflow comment ${task.id} "<text>" ${where}\`, \`block ${task.id} "<why>" --kind <needs_input|capability|transient|dependency> ${where}\` `
282
+ + `and \`review ${task.id} --summary "<what>" ${where}\` update it; \`create "<title>" --subtask-of ${task.id} ${where}\` files a subtask, and \`complete <subtask> ${where}\` marks one done`
283
+ + `${local ? '' : ' (each is filed with the board and answered by a board notice)'}. Its reviewers, not you, mark the card itself done.`;
283
284
  return `You are the session working card ${task.id} on the ${label} board. The board started you; updates to the card `
284
285
  + `(comments, reassignment, blocks, completion) and to the cards it waits on reach you as board notices.${door}${skills}\n\n${context}`;
285
286
  }
@@ -479,11 +480,19 @@ function send(from, label, to, body, key) {
479
480
  if (r.code !== 0 || answer.code !== 0) throw new Error((answer.text || r.stderr || 'no answer').trim().slice(-300));
480
481
  }
481
482
 
482
- function notify(board, report) {
483
- const from = `sc:${localMachine()}:board:${board.label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
483
+ /** The board's own address: where its notices come from and where card updates are mailed. */
484
+ export const boardAddress = (label) => `sc:${localMachine()}:board:${label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
485
+
486
+ function notify(board, fleet, report) {
487
+ const from = boardAddress(board.label);
484
488
  for (const sub of subscriptions(board.db)) {
485
489
  if (sub.platform !== 'supercode') continue;
486
490
  const role = ['worker', 'dependency'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
491
+ // a session that worked or watched a card and is gone hears nothing more; a creator is retried until it is back
492
+ if (role !== 'creator' && fleet.ok && !fleet.alive.has(sub.chat_id)) {
493
+ tx(board, (db) => db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id));
494
+ continue;
495
+ }
487
496
  const kinds = MAIL_KINDS[role];
488
497
  const events = board.db.prepare(`SELECT id, kind, payload, created_at FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
489
498
  .all(sub.task_id, sub.last_event_id, ...kinds);
@@ -516,6 +525,53 @@ function notify(board, report) {
516
525
  }
517
526
  }
518
527
 
528
+ // ------------------------------------------------------------------ the board's mailbox
529
+
530
+ const ENTRY = fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url));
531
+ const NOT_BY_MAIL = new Set(['serve', 'dispatch', 'init', 'attach']);
532
+
533
+ /** The board's mailbox on this machine: the directory supercode files mail for `address` in, or null. */
534
+ function mailboxOf(address) {
535
+ const config = process.env.SUPERCODE_HOME || (process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'supercode') : join(process.env.HOME ?? '', '.config', 'supercode'));
536
+ const root = join(config, 'mail');
537
+ if (!existsSync(root)) return null;
538
+ for (const dir of readdirSync(root)) {
539
+ if (!dir.startsWith('board-')) continue;
540
+ try { if (readFileSync(join(root, dir, 'address'), 'utf8').trim() === address) return join(root, dir); } catch { /* not a mailbox */ }
541
+ }
542
+ return null;
543
+ }
544
+
545
+ /**
546
+ * Card updates filed as mail (`supercode workflow <verb> … --at <this board's address>`, from any machine): each is
547
+ * run as its sender, the session supercode found the command running under, so a card's session gets exactly what
548
+ * it may do on this machine; the answer goes back as a notice and the envelope is filed as read.
549
+ */
550
+ function readMailbox(board, address, report) {
551
+ const box = mailboxOf(address);
552
+ if (!box || !existsSync(join(box, 'new'))) return;
553
+ for (const name of readdirSync(join(box, 'new')).filter((n) => n.endsWith('.json')).sort()) {
554
+ const path = join(box, 'new', name);
555
+ let envelope;
556
+ try { envelope = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; }
557
+ try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
558
+ const argv = json(envelope.body, {})?.workflow;
559
+ let answer;
560
+ if (!Array.isArray(argv) || !argv.length || NOT_BY_MAIL.has(argv[0])) answer = `not applied: a mailed card update is one \`supercode workflow\` verb other than ${[...NOT_BY_MAIL].join(', ')}`;
561
+ else {
562
+ const clean = [];
563
+ for (let i = 0; i < argv.length; i += 1) { if (argv[i] === '--root' || argv[i] === '--board') i += 1; else clean.push(String(argv[i])); }
564
+ const sid = String(envelope.from ?? '').split(':')[3] ?? '';
565
+ const r = spawnSync(process.execPath, [ENTRY, 'workflow', ...clean, '--root', board.root, '--board', board.slug], {
566
+ encoding: 'utf8', timeout: 180_000, env: { ...process.env, CLAUDE_CODE_SESSION_ID: sid, CODEX_THREAD_ID: '' },
567
+ });
568
+ answer = `${clean.slice(0, 2).join(' ')}: ${(r.status === 0 ? r.stdout : r.stderr || r.stdout).trim().slice(-600) || `exit ${r.status}`}`;
569
+ }
570
+ report.mailed.push([envelope.from, argv?.[0] ?? '?']);
571
+ try { send(address, board.label, envelope.from, answer, `board-answer-${envelope.id}`); } catch (error) { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }
572
+ }
573
+ }
574
+
519
575
  // ------------------------------------------------------------------ the tick
520
576
 
521
577
  /** The board's name in notices: the home's owner folder (`…/volter-manager/home` → `volter-manager`), and the slug. */
@@ -533,15 +589,16 @@ export function tick({ root, lanes, settings, only = null, slugs = null, dispatc
533
589
  const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), error: 'no pane lanes' };
534
590
  const out = [];
535
591
  for (const slug of slugs ?? listBoards(root)) {
536
- const report = { board: slug, started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
592
+ const report = { board: slug, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
537
593
  let board;
538
594
  try {
539
595
  board = openBoard(root, slug);
540
596
  board.label = boardLabel(root, slug);
597
+ readMailbox(board, boardAddress(board.label), report);
541
598
  supervise(board, fleet, settings, report);
542
599
  report.promoted = recomputeReady(board, settings.failureLimit);
543
600
  if (dispatch) start(board, fleet, lanes, settings, report, { only });
544
- notify(board, report);
601
+ notify(board, fleet, report);
545
602
  // what a Hermes gateway serves around its dispatcher and this one does not: said by the caller
546
603
  report.chat_subscriptions = Number(board.db.prepare("SELECT COUNT(*) AS n FROM kanban_notify_subs WHERE platform != 'supercode'").get().n);
547
604
  report.triage = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE status = 'triage'").get().n);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
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": {