@volter/supercode-orchestrator 0.4.63 → 0.4.65

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
  }
@@ -501,7 +501,8 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
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
503
  if (e.kind === 'tell') {
504
- await send(boardAddress(board.label), board.label, e.to, e.text, `board-tell-${e.id}-${now()}`);
504
+ // an ask is sent under the id the card's block waits on
505
+ await send(boardAddress(board.label), board.label, e.to, e.text, e.messageId ?? `board-tell-${e.id}-${now()}`, { ask: Boolean(e.messageId) });
505
506
  return;
506
507
  }
507
508
  if (e.kind === 'send_to_actor') {
@@ -840,10 +841,11 @@ function deliverNow(board, to, body, key) {
840
841
  sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
841
842
  }
842
843
 
843
- async function send(from, label, to, body, key, { queue = false } = {}) {
844
+ async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
844
845
  const request = {
845
846
  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.)`,
847
+ // a question the board waits on is answered by a reply; a notice is not
848
+ 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
849
  };
848
850
  // a delivery to a busy session can take a minute on a loaded machine; what it answered is the verdict (a process
849
851
  // killed after it answered `code: 0` delivered)
@@ -1097,6 +1099,8 @@ function readMailbox(board, address, report) {
1097
1099
  let envelope;
1098
1100
  try { envelope = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; }
1099
1101
  try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
1102
+ // an answer to a question the board asked is what a card waits on (read by the card's workflow), not a verb
1103
+ if (envelope.in_reply_to) continue;
1100
1104
  const argv = json(envelope.body, {})?.workflow;
1101
1105
  let answer;
1102
1106
  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 +1214,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1210
1214
  readMailbox(board, boardAddress(board.label), report); phase('mailbox');
1211
1215
  const effects = [];
1212
1216
  supervise(board, fleet, settings, report, entry, effects); closeRequested(board, report); phase('supervise');
1213
- report.promoted = recomputeReady(board);
1217
+ report.unsettled = [];
1218
+ report.promoted = recomputeReady(board, effects, report.unsettled);
1214
1219
  const before = report.started.length;
1215
1220
  if (dispatch) startCards(board, fleet, lanes, settings, report, entry, effects, { only, hostRunning });
1216
1221
  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,14 +84,38 @@ 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;
92
+ }
93
+
94
+ const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
95
+ /** Whether `body` is `was` with only outcomes ticked: each changed line was `- [ ] X` and is now `- [x] X` or
96
+ * `- [x] X: <pointer>`; no other line added, removed or changed. */
97
+ function onlyTicks(was, body) {
98
+ if (typeof body !== 'string') return false;
99
+ const before = String(was ?? '').split('\n');
100
+ const after = body.split('\n');
101
+ if (before.length !== after.length) return false;
102
+ return before.every((line, i) => {
103
+ if (line === after[i]) return true;
104
+ const open = OPEN_OUTCOME.exec(line);
105
+ if (!open) return false;
106
+ const ticked = `${open[1]}[x] ${open[2]}`;
107
+ return after[i] === ticked || (after[i].startsWith(`${ticked}: `) && after[i].length > ticked.length + 2);
108
+ });
87
109
  }
88
110
 
89
111
  /** The session message `id` was filed for (`supercode message show`): who was asked; or null. */
90
112
  function askedOf(id) {
91
113
  if (!id) return null;
92
114
  const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', id, '--json'], { encoding: 'utf8', timeout: 20_000 });
93
- 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;
94
119
  }
95
120
 
96
121
  /** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
@@ -130,6 +155,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
130
155
  // the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
131
156
  waiting_on: waitingOn,
132
157
  answer: () => answerTo(waitingOn),
158
+ // whether an edit of its text only ticks outcome lines (the card's own session's door to its acceptance)
159
+ only_ticks: (body) => onlyTicks(task.body, body),
133
160
  asked_of: () => askedOf(waitingOn),
134
161
  last_block_event: one(db, "SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1", task.id)?.kind ?? null,
135
162
  // where the latest interruption says the card resumes (any status the workflow declares); none recorded: null
@@ -509,6 +536,14 @@ const ACTIONS = {
509
536
  },
510
537
  // a message from the board to a session (`to`: its address)
511
538
  tell(ctx, { to, text }) { if (to) ctx.effects.push({ kind: 'tell', id: ctx.id, to: String(to), text: String(text ?? '') }); },
539
+ // a question from the board to a session, sent under a message id chosen now: `{{asked}}` in the actions after it is
540
+ // that id (what a block waits on), and the answer to it lands in the board's mailbox
541
+ ask(ctx, { to, text }) {
542
+ if (!to) throw new Error(`the workflow asks nobody about ${ctx.id} (ask.to is empty)`);
543
+ const id = `m-${randomBytes(12).toString('hex')}`;
544
+ ctx.scope.asked = id;
545
+ ctx.effects.push({ kind: 'tell', id: ctx.id, to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, messageId: id });
546
+ },
512
547
  send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
513
548
  };
514
549
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.63",
3
+ "version": "0.4.65",
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": {