@volter/supercode-orchestrator 0.4.23 → 0.4.24

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
@@ -466,7 +466,7 @@ export async function runWorkflow(argv) {
466
466
  if (args.reason) for (const [t] of results) withBoard((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
467
467
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
468
468
  }
469
- case 'request-review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, force: Boolean(args.force) })); say(args, `${id} → review`, { ok: true }); return 0;
469
+ case 'request-review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
470
470
  case 'request-changes': need(2, 'a card id and the reason'); withBoard((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? callerName() })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
471
471
  case 'reopen': {
472
472
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
@@ -909,6 +909,17 @@ function importBoard(root, archive, as) {
909
909
  // ------------------------------------------------------------------ a card's own session
910
910
 
911
911
  /** The card whose latest session is `sessionId` (the caller works it), or null. */
912
+ /**
913
+ * The run the caller works on card `id`, as Hermes's `_worker_run_id_for` names it: a one-shot worker's
914
+ * `HERMES_KANBAN_TASK`/`HERMES_KANBAN_RUN_ID`, or the run the board recorded for the caller's session. Null for anyone else.
915
+ */
916
+ function workerRun(db, id, sessionId) {
917
+ if (process.env.HERMES_KANBAN_TASK === id && Number.isInteger(Number(process.env.HERMES_KANBAN_RUN_ID)) && process.env.HERMES_KANBAN_RUN_ID) return Number(process.env.HERMES_KANBAN_RUN_ID);
918
+ if (!sessionId) return null;
919
+ const hit = db.prepare('SELECT id FROM task_runs WHERE task_id = ? AND metadata LIKE ? ORDER BY id DESC LIMIT 1').get(id, `%"session_id":"${sessionId}"%`);
920
+ return hit ? Number(hit.id) : null;
921
+ }
922
+
912
923
  function ownCard(db, sessionId) {
913
924
  const hit = db.prepare('SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1').get(`%"session_id":"${sessionId}"%`);
914
925
  if (!hit) return null;
package/board/engine.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
9
9
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
10
- import { spawnSync } from 'node:child_process';
10
+ import { spawn, spawnSync } from 'node:child_process';
11
11
  import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { DatabaseSync } from 'node:sqlite';
@@ -60,6 +60,15 @@ export function parseWorkspace(word = 'scratch') {
60
60
 
61
61
  // ------------------------------------------------------------------ runs
62
62
 
63
+ /**
64
+ * Hermes's `_terminate_reclaimed_worker`: SIGTERM to the worker's process group, SIGKILL if it is still alive five
65
+ * seconds later. The wait runs in a detached shell, so the board's write lock is never held through it.
66
+ */
67
+ function terminate(pid) {
68
+ try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch { return; } }
69
+ spawn('/bin/sh', ['-c', 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$0" 2>/dev/null || exit 0; sleep 0.5; done; kill -KILL -"$0" 2>/dev/null || kill -KILL "$0" 2>/dev/null', String(pid)], { detached: true, stdio: 'ignore' }).unref();
70
+ }
71
+
63
72
  /** Close the card's open run; answers its id, or null when none was open. */
64
73
  export function endRun(db, taskId, { outcome, status = null, summary = null, error = null, metadata = undefined }) {
65
74
  const row = one(db, 'SELECT current_run_id FROM tasks WHERE id = ?', taskId);
@@ -70,9 +79,8 @@ export function endRun(db, taskId, { outcome, status = null, summary = null, err
70
79
  // a run taken back (reclaim, archive, a reopened ancestor) stops its one-shot worker on this machine, as Hermes
71
80
  // terminates a reclaimed worker (a board's one-shot workers run where the board is); a pane session is a person-visible session and is never killed from the board
72
81
  const worker = outcome === 'reclaimed' ? json(was, {})?.supercode : null;
73
- if (worker?.surface === 'headless' && worker.pid) {
74
- try { process.kill(-worker.pid, 'SIGTERM'); } catch { try { process.kill(worker.pid, 'SIGTERM'); } catch { /* gone */ } }
75
- }
82
+ const lock = one(db, 'SELECT claim_lock FROM task_runs WHERE id = ?', runId)?.claim_lock ?? one(db, 'SELECT claim_lock FROM tasks WHERE id = ?', taskId)?.claim_lock;
83
+ if (worker?.surface === 'headless' && worker.pid && String(lock ?? '').startsWith(`${claimer().split(':')[0]}:`)) terminate(Number(worker.pid));
76
84
  run(db, `UPDATE task_runs SET status = ?, outcome = ?, summary = ?, error = ?, metadata = ?, ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
77
85
  WHERE id = ? AND ended_at IS NULL`, status ?? outcome, outcome, summary, error, keep, now(), runId);
78
86
  run(db, 'UPDATE tasks SET current_run_id = NULL WHERE id = ?', taskId);
@@ -424,13 +432,15 @@ export function unblock(board, id) {
424
432
  }
425
433
 
426
434
  /** Implementation done, awaiting review (not a block): the review lane, optionally under a reviewer profile. */
427
- export function requestReview(board, id, { summary = null, metadata = null, reviewer = null, force = false } = {}) {
435
+ export function requestReview(board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null } = {}) {
428
436
  return tx(board, (db) => {
429
437
  const task = must(db, id);
430
438
  if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
431
439
  if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card goes to review`);
432
440
  // a live claim is its worker's to hand off; anyone else says so on purpose (Hermes's guard)
433
- if (task.status === 'running' && task.claim_lock && !force) throw new Error(`${id} is running under a live claim; its worker requests review, or pass --force to override on purpose`);
441
+ // (Hermes's expected_run_id: the worker names the run it holds; a superseded run changes nothing)
442
+ if (expectedRunId !== null && Number(task.current_run_id) !== Number(expectedRunId)) throw new Error(`run ${expectedRunId} of ${id} is no longer its current run; nothing was changed`);
443
+ if (task.status === 'running' && task.claim_lock && !force && expectedRunId === null) throw new Error(`${id} is running under a live claim; its worker requests review, or pass --force to override on purpose`);
434
444
  // a re-review goes back to the reviewer who asked for the changes
435
445
  if (!reviewer) {
436
446
  const changed = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'changes_requested' ORDER BY id DESC LIMIT 1", id);
package/board/tools.mjs CHANGED
@@ -97,7 +97,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
97
97
  }
98
98
  case 'kanban_request_review': {
99
99
  const stale = ownRun(); if (stale) return error(stale);
100
- engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, force: own === tid });
100
+ engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
101
101
  return ok({ task_id: tid, status: 'review' });
102
102
  }
103
103
  case 'kanban_request_changes': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
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": {