@volter/supercode-orchestrator 0.4.64 → 0.4.66

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
@@ -1133,6 +1133,7 @@ async function serve(root, args = {}) {
1133
1133
  stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
1134
1134
  if (stuck[r.board] >= 6 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${stuck[r.board]} ticks; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
1135
1135
  for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
1136
+ for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
1136
1137
  for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
1137
1138
  for (const [t, to, why] of r.undelivered) log(` notice for ${t} to ${to} not delivered: ${why}`);
1138
1139
  }
@@ -500,10 +500,6 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
500
500
 
501
501
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
502
502
  export async function sessionEffect(board, e) {
503
- if (e.kind === 'tell') {
504
- await send(boardAddress(board.label), board.label, e.to, e.text, `board-tell-${e.id}-${now()}`);
505
- return;
506
- }
507
503
  if (e.kind === 'send_to_actor') {
508
504
  const run = tx(board, (db) => currentRun(db, e.id));
509
505
  const session = run?.metadata?.supercode;
@@ -840,10 +836,11 @@ function deliverNow(board, to, body, key) {
840
836
  sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
841
837
  }
842
838
 
843
- async function send(from, label, to, body, key, { queue = false } = {}) {
839
+ async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
844
840
  const request = {
845
841
  op: 'send', from, from_name: `${label} board`, to, queue, id: key,
846
- body: `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
842
+ // a question the board waits on is answered by a reply; a notice is not
843
+ body: ask ? `[A question from the ${label} board] ${body}` : `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
847
844
  };
848
845
  // a delivery to a busy session can take a minute on a loaded machine; what it answered is the verdict (a process
849
846
  // killed after it answered `code: 0` delivered)
@@ -852,6 +849,25 @@ async function send(from, label, to, body, key, { queue = false } = {}) {
852
849
  if (answer.code !== 0) throw new Error((answer.text || r.stderr || `no answer (exit ${r.code})`).trim().slice(-300));
853
850
  }
854
851
 
852
+ /**
853
+ * The messages the workflow has the board send (`tell`, `ask`: board_message events) that it has not sent yet, sent now
854
+ * 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
855
+ * (the id is the send's idempotency key). One that fails is tried again next tick.
856
+ */
857
+ async function sendBoardMessages(board, report) {
858
+ const rows = tx(board, (db) => db.prepare(`SELECT id, task_id, payload FROM task_events e WHERE kind = 'board_message' AND NOT EXISTS
859
+ (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());
860
+ for (const row of rows) {
861
+ const m = json(row.payload, {}) ?? {};
862
+ try {
863
+ await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
864
+ 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 } : {}) }));
865
+ } catch (error) {
866
+ report.undelivered.push([row.task_id, m.to, String(error.message).slice(-200)]);
867
+ }
868
+ }
869
+ }
870
+
855
871
  /** The board's own address: where its notices come from and where card updates are mailed. */
856
872
  export const boardAddress = (label) => `sc:${localMachine()}:board:${label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
857
873
 
@@ -1097,6 +1113,8 @@ function readMailbox(board, address, report) {
1097
1113
  let envelope;
1098
1114
  try { envelope = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; }
1099
1115
  try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
1116
+ // an answer to a question the board asked is what a card waits on (read by the card's workflow), not a verb
1117
+ if (envelope.in_reply_to) continue;
1100
1118
  const argv = json(envelope.body, {})?.workflow;
1101
1119
  let answer;
1102
1120
  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(', ')}`;
@@ -1210,7 +1228,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1210
1228
  readMailbox(board, boardAddress(board.label), report); phase('mailbox');
1211
1229
  const effects = [];
1212
1230
  supervise(board, fleet, settings, report, entry, effects); closeRequested(board, report); phase('supervise');
1213
- report.promoted = recomputeReady(board);
1231
+ report.unsettled = [];
1232
+ report.promoted = recomputeReady(board, effects, report.unsettled);
1233
+ await sendBoardMessages(board, report);
1214
1234
  const before = report.started.length;
1215
1235
  if (dispatch) startCards(board, fleet, lanes, settings, report, entry, effects, { only, hostRunning });
1216
1236
  hostRunning += report.started.length - before; phase('start');
package/board/engine.mjs CHANGED
@@ -367,14 +367,18 @@ function stopWorker(board, runId) {
367
367
  if (worker?.surface === 'headless' && worker.pid) terminate(Number(worker.pid));
368
368
  }
369
369
 
370
- /** Every card whose workflow status has an `always` move that holds takes it (the tick's recompute). Answers how many moved. */
371
- export function recomputeReady(board) {
370
+ /**
371
+ * Every card whose workflow status has an `always` or due `after` move that holds takes it (the tick's recompute).
372
+ * What the moves ask for (a message, a closed pane) joins `effects`; a card whose moves could not be read (a door its
373
+ * expressions call failed) is left where it is and named in `errors`. Answers how many moved.
374
+ */
375
+ export function recomputeReady(board, effects = [], errors = []) {
372
376
  const entry = workflowFor(board.root);
373
377
  return tx(board, (db) => {
374
378
  let moved = 0;
375
379
  for (const { id } of all(db, "SELECT id FROM tasks WHERE status NOT IN ('done', 'archived')")) {
376
380
  const before = statusOf(db, getTask(db, id));
377
- settle(db, board, entry, id);
381
+ try { settle(db, board, entry, id, effects); } catch (error) { errors.push([id, String(error.message).slice(-300)]); continue; }
378
382
  if (statusOf(db, getTask(db, id)) !== before) moved += 1;
379
383
  }
380
384
  return moved;
@@ -5,6 +5,7 @@
5
5
  // the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
6
6
  // pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
7
7
  import { spawnSync } from 'node:child_process';
8
+ import { randomBytes } from 'node:crypto';
8
9
  import { event as record, json, now } from './store.mjs';
9
10
  import { evaluate, holds, render } from './expr.mjs';
10
11
 
@@ -83,7 +84,11 @@ function lastCommitAt(path, id) {
83
84
  function answerTo(id) {
84
85
  if (!id) return null;
85
86
  const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', '--replies', id, '--json'], { encoding: 'utf8', timeout: 20_000 });
86
- try { return JSON.parse(r.stdout)?.[0]?.replies?.[0]?.id ?? null; } catch { return null; }
87
+ let rows = null;
88
+ try { rows = JSON.parse(r.stdout); } catch { /* read below */ }
89
+ // a door that cannot answer is said, never read as "no answer yet": a wait would silently never end
90
+ if (!Array.isArray(rows)) throw new Error(`the answer to ${id} could not be read (supercode message show --replies): ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim().slice(-200)}`);
91
+ return rows[0]?.replies?.[0]?.id ?? null;
87
92
  }
88
93
 
89
94
  const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
@@ -107,7 +112,10 @@ function onlyTicks(was, body) {
107
112
  function askedOf(id) {
108
113
  if (!id) return null;
109
114
  const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', id, '--json'], { encoding: 'utf8', timeout: 20_000 });
110
- try { return JSON.parse(r.stdout)?.[0]?.message?.session ?? null; } catch { return null; }
115
+ let rows = null;
116
+ try { rows = JSON.parse(r.stdout); } catch { /* read below */ }
117
+ if (!Array.isArray(rows)) throw new Error(`who ${id} was sent to could not be read (supercode message show): ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim().slice(-200)}`);
118
+ return rows[0]?.message?.session ?? null;
111
119
  }
112
120
 
113
121
  /** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
@@ -526,8 +534,17 @@ const ACTIONS = {
526
534
  return;
527
535
  }
528
536
  },
529
- // a message from the board to a session (`to`: its address)
530
- tell(ctx, { to, text }) { if (to) ctx.effects.push({ kind: 'tell', id: ctx.id, to: String(to), text: String(text ?? '') }); },
537
+ // a message from the board to a session (`to`: its address), kept on the card as a board_message event until the
538
+ // dispatcher has sent it: a move settled by any verb (a list settles too) is never lost with its effects
539
+ tell(ctx, { to, text }) { if (to) record(ctx.db, ctx.id, 'board_message', { to: String(to), text: String(text ?? '') }, null); },
540
+ // a question from the board to a session, sent under a message id chosen now: `{{asked}}` in the actions after it is
541
+ // that id (what a block waits on), and the answer to it lands in the board's mailbox
542
+ ask(ctx, { to, text }) {
543
+ if (!to) throw new Error(`the workflow asks nobody about ${ctx.id} (ask.to is empty)`);
544
+ const id = `m-${randomBytes(12).toString('hex')}`;
545
+ ctx.scope.asked = id;
546
+ record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
547
+ },
531
548
  send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
532
549
  };
533
550
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.64",
3
+ "version": "0.4.66",
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": {