@volter/supercode-orchestrator 0.4.149 → 0.4.150

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/ci.mjs CHANGED
@@ -66,14 +66,28 @@ function repositoryWorkspace(board, id) {
66
66
 
67
67
  const pullRequestLists = new Map();
68
68
 
69
- function branchCheckout(board, id, repository) {
70
- const task = board.db.prepare('SELECT t.branch_name, c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
71
- try { return checkoutOf(task?.machine, repository, task?.branch_name ?? `wt/${id}`)?.path ?? null; } catch { return null; }
69
+ function branchCheckout(board, id, repository, branch) {
70
+ const task = board.db.prepare('SELECT c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
71
+ try { return checkoutOf(task?.machine, repository, branch)?.path ?? null; } catch { return null; }
72
+ }
73
+
74
+ /** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
75
+ * workspace and a branch, e.g. `create "<what>" --subtask-of <arc> --workspace worktree:<repository> --branch <name>`.
76
+ * Their PRs are the arc's whatever the branch is named. */
77
+ function registeredBranches(board, id) {
78
+ const rows = board.db.prepare(`SELECT t.branch_name, t.workspace_kind, c.workspace_anchor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
79
+ WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.workspace_kind IN ('worktree','dir') AND t.branch_name IS NOT NULL`).all(id, id);
80
+ const out = [];
81
+ for (const row of rows) {
82
+ if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
83
+ out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
84
+ }
85
+ return out;
72
86
  }
73
87
 
74
88
  /** Every opened PR of the arc: each open PR whose head is the card's branch or a `wt/<card id>` branch, in the
75
- * card's own repository and in every repository this machine keeps a checkout of. One review covers them all;
76
- * the caller decides the transition. */
89
+ * card's own repository and in every repository this machine keeps a checkout of, and each open PR on a sibling
90
+ * checkout the arc registered (`registeredBranches`). One review covers them all; the caller decides the transition. */
77
91
  export function openReviewPullRequests(board, id) {
78
92
  const own = repositoryWorkspace(board, id);
79
93
  if (!own) return null;
@@ -81,20 +95,23 @@ export function openReviewPullRequests(board, id) {
81
95
  const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
82
96
  const ownRepository = originOf(own);
83
97
  if (!ownRepository) return null;
84
- const mine = (head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
98
+ const registered = registeredBranches(board, id);
99
+ const mine = (repository, head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`))
100
+ || registered.some((r) => r.repository === repository && r.branch === head);
85
101
  const prs = [];
86
- for (const repository of new Set([ownRepository, ...knownRepositories()])) {
102
+ for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
87
103
  // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
88
104
  let list = pullRequestLists.get(repository);
89
105
  if (!list || Date.now() - list.at >= 60_000) {
90
106
  let items = [];
91
- try { items = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository'])); }
92
- catch (error) { if (repository === ownRepository) throw error; } // another repository unreachable here is not this arc's
107
+ try { items = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft'])); }
108
+ catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
93
109
  list = { at: Date.now(), items };
94
110
  if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
95
111
  pullRequestLists.set(repository, list);
96
112
  }
97
- for (const pr of list.items) if (!pr.isCrossRepository && mine(pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
113
+ // a draft is not handed over yet: its review starts when it is marked ready
114
+ for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
98
115
  }
99
116
  if (!prs.length) return null;
100
117
  return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
@@ -192,11 +209,12 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
192
209
  if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
193
210
  const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
194
211
  // An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
195
- // card's own checkout, and this machine's checkout of another repository on the card's branch when it keeps one,
196
- // must also hold that head with nothing uncommitted.
197
- const path = originOf(own) === repository ? own : branchCheckout(board, id, repository);
198
- const observed = JSON.parse(run('gh', ['pr', 'view', url, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository']));
212
+ // card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
213
+ // with nothing uncommitted.
214
+ const observed = JSON.parse(run('gh', ['pr', 'view', url, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft']));
215
+ const path = originOf(own) === repository && run('git', ['branch', '--show-current'], own) === observed.headRefName ? own : branchCheckout(board, id, repository, observed.headRefName);
199
216
  if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
217
+ if (observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
200
218
  const head = supplied ?? observed.headRefOid;
201
219
  if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
202
220
  if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
@@ -206,8 +224,8 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
206
224
  if (stage === 'request_review' && path) {
207
225
  run('git', ['check-ref-format', '--branch', branch], path);
208
226
  if (run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
209
- run('git', ['diff', '--quiet', 'HEAD'], path);
210
- if (run('git', ['ls-files', '--others', '--exclude-standard'], path)) throw new Error(`Commit the untracked work in ${path} before requesting review.`);
227
+ const uncommitted = run('git', ['status', '--porcelain', '--untracked-files=normal'], path).split('\n').filter(Boolean);
228
+ if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
211
229
  const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
212
230
  if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
213
231
  }
package/board/cli.mjs CHANGED
@@ -7,13 +7,13 @@
7
7
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { basename, join, resolve } from 'node:path';
10
- import { tmpdir } from 'node:os';
10
+ import { tmpdir, userInfo } from 'node:os';
11
11
  import { read } from '../orchestration.mjs';
12
12
  import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
13
13
  import { isLocalWorkspace } from './workspace.mjs';
14
14
  import { openZtrackBoard } from './ztrack.mjs';
15
15
  import * as engine from './engine.mjs';
16
- import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
16
+ import { requestAdoption, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
17
17
  import { watchBoard } from './wake.mjs';
18
18
  import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
19
19
  import { evaluate } from './expr.mjs';
@@ -29,14 +29,14 @@ Hermes Kanban's verbs, with their flags:
29
29
  [--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
30
30
  swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
31
31
  list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
32
- assign <id> <profile|none> reassign <id> <profile> [--reclaim] [--reason] set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
32
+ set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
33
33
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
34
34
  comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
35
35
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
36
36
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
37
- pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
37
+ pause <id> --reason <why> resume <id> cancel <id>
38
38
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
39
- unblock <id>... [--reason] request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
39
+ unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
40
40
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
41
41
  specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
42
42
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -71,7 +71,7 @@ supercode's own:
71
71
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
72
72
  `;
73
73
 
74
- const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha']);
74
+ const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
75
75
  const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
76
76
  'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
77
77
  // flags that take every following word up to the next flag (Hermes's nargs='+')
@@ -264,8 +264,8 @@ export async function runWorkflow(argv) {
264
264
  if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
265
265
  return roles;
266
266
  });
267
- // a verb with no session behind it (a person at the CLI, the board file's sync) is the manager's, as its roles are
268
- if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
267
+ // a verb with no session behind it is recorded as the OS user's; it holds no role on any card
268
+ if (!sid && verb !== 'help') setActor(`user:${userInfo().username}`);
269
269
 
270
270
  switch (verb) {
271
271
  case 'migrate': {
@@ -314,9 +314,8 @@ export async function runWorkflow(argv) {
314
314
  let answer = { id: created.id, existing: created.existing };
315
315
  if (args.adopt) {
316
316
  withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
317
- const { settings } = await laneState(root);
318
- const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
319
- answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
317
+ withBoard(b => requestAdoption(b, created.id, args.adopt));
318
+ answer = { ...answer, adoption_requested: args.adopt };
320
319
  }
321
320
  // Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
322
321
  // thread records the start. A create never runs a dispatch tick inline.
@@ -326,7 +325,7 @@ export async function runWorkflow(argv) {
326
325
  const task = withBoard((b) => view(b, (db) => engine.getTask(db, created.id)));
327
326
  const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
328
327
  if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
329
- const said = answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
328
+ const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
330
329
  say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
331
330
  return answer.failed ? 1 : 0;
332
331
  }
@@ -466,17 +465,18 @@ export async function runWorkflow(argv) {
466
465
  return 0;
467
466
  }
468
467
 
469
- case 'assign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.assign(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
470
- case 'reassign': {
471
- need(2, 'a card id and a profile');
472
- await withBoardAsync(async (b) => {
473
- const task = engine.getTask(b.db, id);
474
- if (task?.current_run_id != null && args.reclaim) await engine.reclaim(b, id, args.reason ?? 'reassign');
475
- await engine.assign(b, id, rest[1] === 'none' ? null : rest[1]);
476
- });
477
- say(args, `Reassigned ${id} to ${rest[1]}`, { ok: true }); return 0;
468
+ // One door sets a card's properties; the workflow's `set` rules say who may and what follows (a live run
469
+ // whose session must change is ended, and the dispatcher starts the next itself). `assign`/`reassign` are
470
+ // Hermes's names for setting the assignee, and `set-model` its name for setting the model.
471
+ case 'set': {
472
+ need(3, 'a card id, a property (assignee, machine, model or every) and its value (or none)');
473
+ const value = rest[2] === 'none' ? null : rest[2];
474
+ const cards = ids([id]);
475
+ for (const t of cards) await withBoardAsync((b) => engine.set(b, t, rest[1], value, { provider: args.provider ?? null }));
476
+ say(args, cards.map((t) => `${t}: ${rest[1]} ${value ?? '(none)'}`).join('\n'), { ok: true }); return 0;
478
477
  }
479
- case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.setModel(b, id, rest[1] ?? null, args.provider ?? null)); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
478
+ case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.set(b, id, 'model', rest[1] ?? null, { provider: args.provider ?? null })); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
479
+ case 'assign': case 'reassign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
480
480
  case 'set-goal': {
481
481
  // a card into goal mode (or out of it with --off) from its next idle turn: the heartbeat keeps its session going
482
482
  need(1, 'a card id');
@@ -485,7 +485,6 @@ export async function runWorkflow(argv) {
485
485
  for (const t of ids()) await withBoardAsync((b) => engine.setGoal(b, t, { on: !args.off, maxTurns }));
486
486
  say(args, ids().map((t) => args.off ? `${t} is no longer a goal card` : `${t} is a goal card${maxTurns ? ` (${maxTurns} turns)` : ''}`).join('\n'), { ok: true }); return 0;
487
487
  }
488
- case 'move': need(2, 'a card id and a machine (or none)'); await withBoardAsync((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
489
488
 
490
489
  case 'claim': {
491
490
  need(1, 'a card id');
@@ -571,13 +570,21 @@ export async function runWorkflow(argv) {
571
570
  case 'unblock': {
572
571
  need(1, 'a card id');
573
572
  const results = [];
574
- for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? null }))).to]);
573
+ for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? args['waiting-on'] ?? null }))).to]);
575
574
  // Hermes records the reason as a comment on each card it unblocks
576
575
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
577
576
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
578
577
  }
579
578
  case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
580
- case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
579
+ case 'request-changes': {
580
+ need(2, 'a card id and the reason');
581
+ // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
582
+ const findings = [].concat(args.finding ?? []).map((f) => String(f).trim()).filter(Boolean);
583
+ const filed = [];
584
+ for (const finding of findings) filed.push((await withBoardAsync((b) => engine.createCard(b, { title: finding, subtaskOf: id }))).id);
585
+ await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null }));
586
+ say(args, `${id} sent back for changes${filed.length ? `; findings filed as ${filed.join(', ')}` : ''}`, { ok: true, findings: filed }); return 0;
587
+ }
581
588
  case 'reopen': {
582
589
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
583
590
  need(1, 'a card id');
@@ -805,11 +812,6 @@ export async function runWorkflow(argv) {
805
812
  return report.status === 'corrupt' ? 1 : 0;
806
813
  }
807
814
 
808
- case 'set-every': {
809
- need(1, 'a card id');
810
- await withBoardAsync((b) => engine.act(b, id, 'set_every', { payload: { every: rest[1] === 'none' ? null : rest[1] } }));
811
- say(args, `${id} recurrence updated`, { ok: true }); return 0;
812
- }
813
815
  case 'cancel': {
814
816
  need(1, 'a card id');
815
817
  await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
@@ -844,9 +846,8 @@ export async function runWorkflow(argv) {
844
846
  case 'adopt': {
845
847
  need(2, 'a card id and a session');
846
848
  withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
847
- const { settings } = await laneState(root);
848
- const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl, assignee: args.assignee ?? null, role: args.role ?? null }));
849
- say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
849
+ withBoard((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
850
+ say(args, `${id} is held for ${rest[1]}; the dispatcher verifies and binds it on its next tick (session_adopted, or adoption_failed with the reason)`, { ok: true, requested: rest[1] });
850
851
  return 0;
851
852
  }
852
853
  case 'goto': {
@@ -1093,12 +1094,12 @@ function ownCard(db, sessionId) {
1093
1094
  const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
1094
1095
 
1095
1096
  /**
1096
- * Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
1097
- * is `manager`; the session of the card's current run has that run's role; the card's working session between runs
1097
+ * Who a caller is on card `cardId`, in the workflow's roles: a caller with no session behind it has no role (the
1098
+ * manager acts from its own session, named in params.managers); the session of the card's current run has that run's role; the card's working session between runs
1098
1099
  * is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
1099
1100
  */
1100
1101
  function rolesFor(db, cardId, sessionId, params = {}) {
1101
- if (!sessionId) return ['manager', 'owner'];
1102
+ if (!sessionId) return [];
1102
1103
  // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
1103
1104
  const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
1104
1105
  if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
@@ -1127,7 +1128,7 @@ function authorize(db, verb, rest, args, own) {
1127
1128
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1128
1129
  // verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
1129
1130
  // (what a session may do to a card is its workflow's: each transition's `by`)
1130
- if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1131
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'set', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1131
1132
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1132
1133
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1133
1134
  if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
@@ -1205,13 +1206,21 @@ async function serve(root, args = {}) {
1205
1206
  // An installer owns this operational window, not a card pause. Finish the
1206
1207
  // current tick, acknowledge the request, then leave every run untouched.
1207
1208
  // A replacement serve sees the same request before its first tick.
1209
+ // A request whose installer is gone (its pane closed, or it was killed before clearing it) is void.
1208
1210
  const installation = join(root, 'installation-window.json');
1209
- if (existsSync(installation)) {
1210
- const request = JSON.parse(readFileSync(installation, 'utf8'));
1211
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
1212
+ const request = existsSync(installation) ? readJson(installation) : null;
1213
+ const installerAlive = (pid) => { try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code === 'EPERM'; } };
1214
+ if (request?.token && request.pid && !installerAlive(request.pid)) {
1215
+ log(`installation window ${request.token} of ${request.owner} is void: its installer ${request.pid} is gone`);
1216
+ for (const path of [installation, join(root, 'installation-window.ack.json')]) if (readJson(path)?.token === request.token) try { unlinkSync(path); } catch { /* gone */ }
1217
+ } else if (request?.token) {
1211
1218
  const acknowledgment = join(root, 'installation-window.ack.json');
1212
- const previous = existsSync(acknowledgment) ? JSON.parse(readFileSync(acknowledgment, 'utf8')) : null;
1219
+ const previous = readJson(acknowledgment);
1213
1220
  if (previous?.token !== request.token || previous?.pid !== process.pid) {
1214
- writeFileSync(acknowledgment, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
1221
+ const temporary = `${acknowledgment}.${process.pid}`;
1222
+ writeFileSync(temporary, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
1223
+ renameSync(temporary, acknowledgment);
1215
1224
  log(`installation window held by ${request.owner}; dispatch waits`);
1216
1225
  }
1217
1226
  await new Promise(resolve => setTimeout(resolve, 250));
@@ -19,13 +19,13 @@ import { inspectCompletionEvidence, openReviewPullRequests, reviewDue, withCompl
19
19
  import { createRequire } from 'node:module';
20
20
  import { spawn as spawnChild, spawnSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
22
- import { hostname, tmpdir } from 'node:os';
22
+ import { hostname, tmpdir, totalmem } from 'node:os';
23
23
  import { basename, dirname, join } from 'node:path';
24
24
  import { createHash, randomUUID } from 'node:crypto';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
27
27
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
28
- import { checkWorkspace, assertWorkspaceOwner, resolveWorkspace } from './workspace.mjs';
28
+ import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
29
29
  import { evaluate, holds, render } from './expr.mjs';
30
30
  import { identifyNotice } from './notices.mjs';
31
31
  import { pendingRequestMail } from './request-mail.mjs';
@@ -123,6 +123,8 @@ export function observeFleet() {
123
123
  const presence = machines.code === 0 ? lastJson(machines.stdout) : null;
124
124
  const online = new Set([localMachine()]);
125
125
  for (const m of presence?.items ?? []) if (m.linked) online.add(normalMachine(m.name));
126
+ presenceReading = { known: presence?.connected === true, online };
127
+ notePresence(presenceReading);
126
128
  return { ok: true, sessions, alive: new Set(sessions.map((s) => s.address).filter(Boolean)), online,
127
129
  presenceKnown: presence?.connected === true, machineEvents: presence,
128
130
  error: presence?.connected ? null : 'machine event stream unavailable; retaining existing runs until availability is known' };
@@ -143,6 +145,7 @@ const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ?
143
145
 
144
146
  /** Native activity comes from the machine's harness discovery, not catalogue age. */
145
147
  function nativeSessions(machine, harness) {
148
+ if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
146
149
  const result = sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
147
150
  const page = result.code === 0 ? lastJson(result.stdout) : null;
148
151
  if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
@@ -173,13 +176,12 @@ function pidAlive(pid) {
173
176
 
174
177
  /**
175
178
  * Hermes's default for kanban.max_in_progress when none is set: one worker per 512 MiB of the host's memory, between
176
- * two and eight, from /proc/meminfo; none where that cannot be read (which caps nothing).
179
+ * two and eight. The memory is the host's own reading (os.totalmem: /proc/meminfo on Linux, hw.memsize on macOS), so
180
+ * the cap holds on every host, not only where /proc exists.
177
181
  */
178
182
  function derivedMaxInProgress() {
179
- try {
180
- const total = Number(/^MemTotal:\s+(\d+)/m.exec(readFileSync('/proc/meminfo', 'utf8'))?.[1]);
181
- return Number.isFinite(total) && total > 0 ? Math.max(2, Math.min(Math.floor(total / 1024 / 512), 8)) : null;
182
- } catch { return null; }
183
+ const total = totalmem();
184
+ return Number.isFinite(total) && total > 0 ? Math.max(2, Math.min(Math.floor(total / 1024 / 1024 / 512), 8)) : null;
183
185
  }
184
186
 
185
187
  /**
@@ -219,6 +221,17 @@ export function lanesOf(profiles = {}) {
219
221
  }
220
222
 
221
223
  const onMachine = machine => normalMachine(machine ?? localMachine()) === localMachine() ? [] : ['--machine', machine];
224
+ // The latest presence reading (observeFleet). A machine it reads as offline is not asked: each remote call would
225
+ // wait out its timeout, and one offline machine held every close and launch on this one.
226
+ let presenceReading = null;
227
+ const offlineMachine = (machine) => {
228
+ const m = normalMachine(machine ?? localMachine());
229
+ return m !== localMachine() && presenceReading?.known === true && !presenceReading.online.has(m);
230
+ };
231
+ /** `supercode <args>` for `machine`: on another machine through its Teams server, answered at once when it is offline. */
232
+ const scOn = (machine, args, options) => offlineMachine(machine)
233
+ ? { code: 1, stdout: '', stderr: `machine ${normalMachine(machine)} is offline` }
234
+ : sc([...args, ...onMachine(machine)], options);
222
235
  const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
223
236
 
224
237
  // ------------------------------------------------------------------ supervise
@@ -242,7 +255,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
242
255
  for (const { id } of rows) {
243
256
  const s = currentRun(board.db, id)?.metadata?.supercode;
244
257
  if (!s?.pane || observed.has(s.machine)) continue;
245
- const r = sc(['teams', 'panes', 'ls', ...onMachine(s.machine)], { timeout: 60_000 });
258
+ const r = scOn(s.machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
246
259
  const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
247
260
  observed.set(s.machine, Array.isArray(list) ? new Set(list.filter(p => !p.dead).map((p) => p.pane)) : null);
248
261
  if (!Array.isArray(list)) report.failed.push([id, `pane liveness unavailable; retaining run: ${(r.stderr || r.stdout).trim().slice(-200)}`]);
@@ -357,7 +370,17 @@ function supervise(board, fleet, settings, report, entry, effects) {
357
370
  if (session?.session_id) {
358
371
  if(exits.has(id)) {
359
372
  const exit=exits.get(id);
360
- observe('exited',{code:exit.failed?1:0,error:`native session ${session.session_id} ended: ${exit.how}`,session_id:session.session_id,pane:session.pane},'crashed');
373
+ // an interactive session that ended cleanly was closed (by a person, or by itself): the workflow says what
374
+ // follows, and it is not a crash to restart
375
+ const ended = { code: exit.failed ? 1 : 0, error: `native session ${session.session_id} ended: ${exit.how}`, session_id: session.session_id, pane: session.pane };
376
+ if (!exit.failed) {
377
+ try {
378
+ const r = fire(db, board, entry, id, 'session_closed', { roles: ['platform'], payload: ended });
379
+ effects.push(...r.effects); (report.closed_by_hand ??= []).push(id);
380
+ return;
381
+ } catch (error) { if (!error.noTransition) { report.failed.push([id, `session_closed: ${String(error.message).slice(-200)}`]); return; } }
382
+ }
383
+ observe('exited', ended, 'crashed');
361
384
  return;
362
385
  }
363
386
  if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
@@ -413,6 +436,7 @@ function stopSession(board, session) {
413
436
  if (pidAlive(session.pid)) signal('SIGKILL');
414
437
  return;
415
438
  }
439
+ if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
416
440
  if (!session.pane && session.surface === 'native') {
417
441
  const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
418
442
  if (r.code !== 0) throw new Error(`closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
@@ -527,7 +551,8 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
527
551
  if (why) { note(task.id, why); report.queued.push([task.id, why]); continue; }
528
552
  if (dry) {
529
553
  report.would.push({ id: task.id, status: plan.target, role: plan.runSpec?.role ?? null, assignee: plan.profile, machine: task.machine ?? null, surface: plan.lane.surface });
530
- for (const l of limits) if (holds(l.counts, scope)) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
554
+ // a card this tick starts counts toward its groups (its scope was read before the start, with no run yet)
555
+ for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
531
556
  continue;
532
557
  }
533
558
  let moved, checked;
@@ -549,7 +574,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
549
574
  continue;
550
575
  }
551
576
  effects.push(...moved.effects);
552
- for (const l of limits) if (holds(l.counts, cardScope.get(task.id) ?? scope)) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
577
+ for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
553
578
  try {
554
579
  const session = launchRun(board, entry, task.id, plan.lane, fleet, settings);
555
580
  if (session) {
@@ -690,20 +715,17 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
690
715
  if (reading.alive) {
691
716
  const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
692
717
  if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
693
- if (noticeHoldsClose(session, null)) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been filed`);
694
718
  stopSession(board, session);
695
719
  }
696
720
  } else {
697
721
  if (!panes.has(machine)) {
698
- const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000, as: boardAddress(board.label) });
722
+ const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000, as: boardAddress(board.label) });
699
723
  const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
700
724
  panes.set(machine, Array.isArray(list) ? { live: new Set(list.filter(p => !p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
701
725
  }
702
726
  const observed = panes.get(machine);
703
727
  if (observed.error) throw new Error(observed.error);
704
728
  if (observed.live.has(session.pane)) {
705
- const pendingNotice = view(board, (db) => db.prepare("SELECT id FROM supercode_effects WHERE json_extract(effect, '$.kind')='session_notice' AND json_extract(effect, '$.runId')=? LIMIT 1").get(row.id));
706
- if (noticeHoldsClose(session, pendingNotice)) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been filed: ${session.lifecycle_notice_error ?? 'pending'}`);
707
729
  const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
708
730
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
709
731
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
@@ -800,9 +822,10 @@ export async function sessionNotice(board, e) {
800
822
  const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
801
823
  if (prior?.close_confirmed_at) {
802
824
  tx(board, (db) => {
825
+ // A closed session has nothing to be told; its next run starts with its own prompt.
803
826
  const unissued = unissuedLaunch(prior.launch_cancellation, prior);
804
- event(db, e.id, unissued ? 'session_notice_not_required' : 'session_notice_failed', { address: e.address, verb: e.verb, notice: e.durableId,
805
- error: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closure already confirmed; notice cannot be delivered', retry: false,
827
+ event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId,
828
+ reason: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closed',
806
829
  ...(unissued ? { receipt: prior.launch_cancellation } : {}) }, e.runId);
807
830
  if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
808
831
  });
@@ -820,19 +843,13 @@ export async function sessionNotice(board, e) {
820
843
  tx(board, (db) => {
821
844
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
822
845
  if (session) setRunSession(db, e.runId, { ...session,
823
- ...(error ? { lifecycle_notice_error: error, lifecycle_notice_failures: Number(session.lifecycle_notice_failures ?? 0) + 1 }
824
- : { lifecycle_notice_error: null, lifecycle_notice_failures: 0, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
846
+ ...(error ? { lifecycle_notice_error: error }
847
+ : { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
825
848
  if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : receipt?.delivered ? 'session_notice_delivered' : 'session_notice_queued', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
826
849
  });
827
850
  return !error;
828
851
  }
829
852
 
830
- // A close waits for its run's lifecycle notice to be filed, but not past this many failed
831
- // attempts: an unreachable session must not hold its pane, and its slot's next launch, forever.
832
- const NOTICE_ATTEMPTS_BEFORE_CLOSE = 3;
833
- const noticeHoldsClose = (session, pending) => Boolean(pending || session.lifecycle_notice_pending)
834
- && Number(session.lifecycle_notice_failures ?? 0) < NOTICE_ATTEMPTS_BEFORE_CLOSE;
835
-
836
853
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
837
854
  export async function sessionEffect(board, e) {
838
855
  if (e.kind === 'send_to_actor') {
@@ -898,8 +915,16 @@ const BLOCKER = /\b(quota|rate[\s_-]?limit|429|403|auth\w*|unauthorized|forbidde
898
915
  const PR_URL = /https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i;
899
916
 
900
917
 
901
- /** This machine's memory pressure as Hermes reads it (Linux's MemAvailable; `unknown` elsewhere, which restricts nothing). */
918
+ /**
919
+ * This machine's memory pressure: on Linux as Hermes reads it (MemAvailable); on macOS the kernel's own level
920
+ * (kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical); `unknown` elsewhere, which restricts nothing.
921
+ */
902
922
  function memoryPressure() {
923
+ if (process.platform === 'darwin') {
924
+ const r = spawnSync('sysctl', ['-n', 'kern.memorystatus_vm_pressure_level'], { encoding: 'utf8', timeout: 5_000 });
925
+ const level = Number(String(r.stdout ?? '').trim());
926
+ return level >= 4 ? 'critical' : level >= 2 ? 'elevated' : level === 1 ? 'ok' : 'unknown';
927
+ }
903
928
  try {
904
929
  const info = Object.fromEntries(readFileSync('/proc/meminfo', 'utf8').split('\n').map((l) => /^(\w+):\s+(\d+)/.exec(l)).filter(Boolean).map((m) => [m[1], Number(m[2])]));
905
930
  const avail = info.MemAvailable;
@@ -937,7 +962,7 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
937
962
 
938
963
  /** A transport error is not a negative launch receipt. The key remains owned by its run. */
939
964
  function launchReceipt(board, session) {
940
- const r = sc(['teams', 'launches', 'get', session.launch_key, ...onMachine(session.machine)],
965
+ const r = scOn(session.machine, ['teams', 'launches', 'get', session.launch_key],
941
966
  { timeout: 30_000, as: boardAddress(board.label) });
942
967
  if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: install the matching Teams CLI and machine capability (teams launches get): ${(r.stderr || r.stdout).trim().slice(-300)}`);
943
968
  if (r.stdout.trim() === 'null') return null;
@@ -947,7 +972,7 @@ function launchReceipt(board, session) {
947
972
  }
948
973
 
949
974
  function cancelUnissuedLaunch(board, session) {
950
- const result = sc(['teams', 'launches', 'cancel', session.launch_key, ...onMachine(session.machine)],
975
+ const result = scOn(session.machine, ['teams', 'launches', 'cancel', session.launch_key],
951
976
  { timeout: 30_000, as: boardAddress(board.label) });
952
977
  if (result.code !== 0) throw unavailable(`launch cancellation unavailable on ${session.machine}; retain ${session.launch_key}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
953
978
  const receipt = lastJson(result.stdout);
@@ -1112,7 +1137,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1112
1137
  if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
1113
1138
  const fresh = observeFleet();
1114
1139
  if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
1115
- const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000 });
1140
+ const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
1116
1141
  const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
1117
1142
  if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1118
1143
  const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
@@ -1245,7 +1270,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1245
1270
 
1246
1271
  /** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
1247
1272
  function panePresent(machine, pane) {
1248
- const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000 });
1273
+ const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
1249
1274
  const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
1250
1275
  if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1251
1276
  return panes.some((p) => p.pane === pane && !p.dead);
@@ -1322,14 +1347,39 @@ function resolveLocalWorkspace(board, task) {
1322
1347
  * never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
1323
1348
  * its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
1324
1349
  */
1325
- export function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null } = {}) {
1326
- // Hold before discovery or any other process I/O: a failed attachment remains
1327
- // visible and cannot race a dispatcher into launching a replacement worker.
1350
+ /**
1351
+ * The manager asks the board to adopt session `who` for card `id`: the card is held at once (no replacement can be
1352
+ * launched), and the dispatcher's next tick verifies the session from the fleet reading it already takes and binds
1353
+ * it (`completeAdoptions`). The verb does no process I/O, so it answers as fast as any board write.
1354
+ */
1355
+ export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
1356
+ if (!who) throw new Error('name the session to adopt');
1328
1357
  tx(board, db => {
1329
1358
  if (!getTask(db, id)) throw new Error(`no card ${id}`);
1330
- event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested' });
1359
+ event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
1331
1360
  });
1332
- const fleet = observeFleet();
1361
+ }
1362
+
1363
+ /** Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. */
1364
+ export function completeAdoptions(board, fleet, settings, report) {
1365
+ const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload FROM task_events e WHERE e.kind = 'dispatch_held'
1366
+ AND json_extract(e.payload, '$.reason') = 'adoption requested'
1367
+ AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ('dispatch_held','dispatch_released','session_adopted','adoption_failed'))`).all());
1368
+ for (const row of pending) {
1369
+ const asked = json(row.payload, {}) ?? {};
1370
+ try {
1371
+ const got = adoptSession(board, row.task_id, asked.session, { workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null });
1372
+ (report.adopted ??= []).push([row.task_id, got.address]);
1373
+ } catch (error) {
1374
+ const message = String(error.message).slice(-400);
1375
+ tx(board, db => event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }));
1376
+ report.failed.push([row.task_id, `adoption of ${asked.session}: ${message}`]);
1377
+ }
1378
+ }
1379
+ }
1380
+
1381
+ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null, fleet = null } = {}) {
1382
+ fleet ??= observeFleet();
1333
1383
  if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
1334
1384
  const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
1335
1385
  if (!found) throw new Error(`the fleet lists no live session ${who}`);
@@ -1432,7 +1482,7 @@ export async function sendBoardMessages(board, report, taskId = null) {
1432
1482
  const task = view(board, db => getTask(db, first.task_id));
1433
1483
  const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
1434
1484
  const receipt = await send(boardAddress(board.label), board.label, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
1435
- if (!receipt.delivered) throw new Error(receipt.reason ?? 'queued; native transcript confirmation pending');
1485
+ if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'message was not filed');
1436
1486
  tx(board, db => {
1437
1487
  for (const member of mail.members) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, receipt, ...(mail.message_id ? { message_id: mail.message_id, request_id: member.message_id ?? mail.message_id } : {}) });
1438
1488
  });
@@ -1510,8 +1560,11 @@ async function notify(board, fleet, report, entry) {
1510
1560
  if (!events.length) return;
1511
1561
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
1512
1562
  const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, status: statusOf(db, t), stored_as: t.status } : t; });
1563
+ // a worker whose run has ended is not woken by a notice (a second session would start working the card beside
1564
+ // the one the next run gets): the notice is filed in its mailbox, read when it next works
1565
+ const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
1513
1566
  let cursor = Number(sub.last_event_id);
1514
- let failed = null, pending = false;
1567
+ let failed = null;
1515
1568
  try {
1516
1569
  for (const ev of events) {
1517
1570
  const p = json(ev.payload, {}) ?? {};
@@ -1525,9 +1578,11 @@ async function notify(board, fleet, report, entry) {
1525
1578
  } else text = noticeOf(entry, role, ev.kind, task, p);
1526
1579
  if (text) {
1527
1580
  const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
1528
- const receipt = await send(from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !entry.workflow.run && sub.delivery_mode === 'notify', subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
1529
- if (!receipt.delivered) { pending = Boolean(receipt.queued); throw new Error(receipt.reason ?? 'native delivery confirmation pending'); }
1530
- tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
1581
+ const receipt = await send(from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !working || (!entry.workflow.run && sub.delivery_mode === 'notify'), subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
1582
+ // A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
1583
+ // board does not send it again.
1584
+ if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
1585
+ tx(board, (db) => event(db, sub.task_id, receipt.delivered ? 'notice_delivered' : 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
1531
1586
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
1532
1587
  }
1533
1588
  cursor = ev.id;
@@ -1535,7 +1590,7 @@ async function notify(board, fleet, report, entry) {
1535
1590
  } catch (error) {
1536
1591
  failed = String(error.message).slice(-200);
1537
1592
  tx(board, (db) => {
1538
- const kind = pending ? 'notice_queued' : 'notice_failed';
1593
+ const kind = 'notice_failed';
1539
1594
  const previous = db.prepare('SELECT payload FROM task_events WHERE task_id=? AND kind=? ORDER BY id DESC LIMIT 1').get(sub.task_id, kind);
1540
1595
  const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
1541
1596
  if (JSON.stringify(json(previous?.payload, {})) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
@@ -1547,7 +1602,7 @@ async function notify(board, fleet, report, entry) {
1547
1602
  const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
1548
1603
  const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
1549
1604
  db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
1550
- const failures = failed && !pending ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1605
+ const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1551
1606
  if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
1552
1607
  if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...identity);
1553
1608
  if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
@@ -1805,8 +1860,9 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
1805
1860
  const ready = view(board, eligible);
1806
1861
  if (!ready) continue;
1807
1862
  if (ready !== true) { report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
1863
+ let payload = null;
1808
1864
  try {
1809
- const payload = openReviewPullRequests(board, id);
1865
+ payload = openReviewPullRequests(board, id);
1810
1866
  // The arc's open PRs ask for one review only while some head has no verdict: after changes are
1811
1867
  // requested the next run is the implementer's, until a new head is pushed.
1812
1868
  if (!payload || !reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) continue;
@@ -1824,7 +1880,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
1824
1880
  effects.push(...moved.effects);
1825
1881
  (report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
1826
1882
  }
1827
- } catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
1883
+ } catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
1828
1884
  }
1829
1885
  }
1830
1886
 
@@ -1872,7 +1928,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1872
1928
  readMailbox(board, boardAddress(board.label), report); phase('mailbox');
1873
1929
  await refreshRelationAnswers(board); phase('answers');
1874
1930
  const effects = [];
1875
- reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1931
+ completeAdoptions(board, fleet, settings, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1876
1932
  report.unsettled = [];
1877
1933
  report.promoted = recomputeReady(board, effects, report.unsettled);
1878
1934
  if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only });
package/board/engine.mjs CHANGED
@@ -214,6 +214,11 @@ export function createCard(board, spec) {
214
214
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
215
215
  let id = newTaskId();
216
216
  while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
217
+ // where a card's blocks are relations, a card created blocked names what it waits on: one that names nothing is
218
+ // ready at once (board-ir-2 started six minutes after it was created "blocked")
219
+ if (initial === 'blocked' && !entry.workflow.statuses?.blocked && !parents.length) {
220
+ throw new Error('A card created blocked names what it waits on: --parent <card> (it starts when that card is done); or create it and block it with --waiting-on <message>.');
221
+ }
217
222
  const creationRoles = workflowFor(board.root).params.create_dependencies_by;
218
223
  if ((parents.length || initial === 'blocked') && creationRoles && !callerRoles(db, arc?.id ?? id).some(role => creationRoles.includes(role))) {
219
224
  throw new Error('Creating a dependency or initially blocked card requires a permitted manager role; file the task without a blocker and use request-state.');
@@ -279,8 +284,20 @@ function wouldCycle(db, parent, child) {
279
284
 
280
285
 
281
286
 
282
- /** The machine a card's session runs on. Moving it is a decision: a running card is reclaimed first. */
283
- export const setMachine = (board, id, machine, { roles } = {}) => act(board, id, 'move', { roles, payload: { machine: machine || null } });
287
+ /**
288
+ * Set one of a card's properties (`assignee`, `machine`, `model`, `every`) through the workflow's `set` verb, whose
289
+ * rules say who may and what follows: a change to how its session runs ends a live run and lets the dispatcher start
290
+ * the next one itself. A workflow without `set` (Hermes's own) has a verb per property.
291
+ */
292
+ export const SETTABLE = ['assignee', 'machine', 'model', 'every'];
293
+ export function set(board, id, field, value, { provider = null, roles } = {}) {
294
+ if (!SETTABLE.includes(field)) throw new Error(`set takes one of ${SETTABLE.join(', ')}, not ${field}`);
295
+ if (provider && (field !== 'model' || !value)) throw new Error('a provider goes with a model');
296
+ value = value || null;
297
+ if (workflowFor(board.root).workflow.verbs?.set) return act(board, id, 'set', { roles, payload: { field, value, provider } });
298
+ const verb = { assignee: ['assign', { assignee: value }], machine: ['move', { machine: value }], model: ['set_model', { model: value, provider }], every: ['set_every', { every: value }] }[field];
299
+ return act(board, id, verb[0], { roles, payload: verb[1] });
300
+ }
284
301
 
285
302
  // ------------------------------------------------------------------ lifecycle
286
303
 
@@ -545,12 +562,7 @@ export async function specify(board, id, { title = null, body = null, workspace
545
562
  }
546
563
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
547
564
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
548
- export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
549
- if (provider && !model) throw new Error('a provider needs a model');
550
- return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
551
- };
552
565
  export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
553
- export const assign = (board, id, profile, { roles } = {}) => act(board, id, 'assign', { roles, payload: { assignee: profile || null } });
554
566
 
555
567
  /** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
556
568
  export async function decompose(board, id, children, { author = 'user', roles } = {}) {
package/board/tools.mjs CHANGED
@@ -132,8 +132,11 @@ export async function boardTool(root, name, args = {}, env = {}) {
132
132
  }
133
133
  case 'kanban_request_changes': {
134
134
  const stale = ownRun(); if (stale) return error(stale);
135
+ // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
136
+ const findings = [].concat(args.findings ?? []).map((f) => redact(String(f)).trim()).filter(Boolean);
137
+ const filed = findings.map((finding) => engine.createCard(board, { title: finding, subtaskOf: tid }).id);
135
138
  await engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
136
- return ok({ task_id: tid });
139
+ return ok({ task_id: tid, findings: filed });
137
140
  }
138
141
  case 'kanban_heartbeat': {
139
142
  const stale = ownRun(); if (stale) return error(stale);
@@ -583,7 +583,7 @@ const FORMAT = {
583
583
  commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
584
584
  archived: () => null,
585
585
  status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
586
- machine_set: ({ e, before }) => ({ machine: e.machine ?? null, was: before.machine }),
586
+ machine_set: ({ e, before }) => ({ machine: e.field === 'machine' ? e.value ?? null : e.machine ?? null, was: before.machine }),
587
587
  };
588
588
  const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
589
589
 
@@ -806,29 +806,15 @@ const ACTIONS = {
806
806
  const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
807
807
  const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
808
808
  if (typeof slot === 'object' && slot && !harness) return;
809
- const released = new Set();
810
809
  for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
811
810
  const whole = json(row.metadata, {}) ?? {};
812
811
  const session = whole.supercode;
813
812
  if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
814
813
  requestSessionClose(ctx.db, ctx.id, { runId: row.id });
815
814
  const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
816
- if (session.session_id) released.add(session.session_id);
817
815
  latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
818
816
  run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
819
817
  }
820
- const gaveUp = one(ctx.db, "SELECT created_at,payload FROM task_events WHERE task_id=? AND kind='gave_up' ORDER BY id DESC LIMIT 1", ctx.id);
821
- const failures = Number(json(gaveUp?.payload, {})?.failures ?? 0);
822
- if (!failures || !released.size) return;
823
- const recent = all(ctx.db, 'SELECT outcome,metadata FROM task_runs WHERE task_id=? AND ended_at<=? ORDER BY id DESC LIMIT ?', ctx.id, gaveUp.created_at, failures);
824
- if (recent.length !== failures || !recent.every(row => {
825
- const failure = json(row.metadata, {})?.last_events?.spawn_failed?.payload;
826
- return row.outcome === 'spawn_failed' && failure?.resume_failed === true && released.has(failure.session_id);
827
- })) return;
828
- const blocks = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id=?', ctx.id)?.blocked_by, []);
829
- for (const block of blocks.filter(block => block.kind === 'message' && block.reason === 'failure limit reached' && block.since === gaveUp.created_at))
830
- ACTIONS.relation(ctx, { remove: block.id, reason: 'The manager released the session responsible for these stale continuation failures.' });
831
- run(ctx.db, 'UPDATE tasks SET consecutive_failures=0,last_failure_error=NULL WHERE id=?', ctx.id);
832
818
  },
833
819
  // an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
834
820
  // a card it does not apply to now is passed over
@@ -1012,15 +998,21 @@ function settleRelations(db, board, entry, id, effects) {
1012
998
  if (!json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []).some((b) => b.id === block.id)) continue;
1013
999
  const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
1014
1000
  const status = dependency ? statusOf(db, dependency) : null;
1015
- const answer = block.kind === 'message' ? answerTo(block.id) : null;
1001
+ // an answer is handed to the workflow once; the block stays until a verb lifts it
1002
+ const answer = block.kind === 'message' && !block.answered ? answerTo(block.id) : null;
1016
1003
  const policy = entry.workflow.relations.blocked_by;
1017
1004
  const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
1005
+ if (answer) {
1006
+ const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
1007
+ run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, answered: answer } : b)), id);
1008
+ }
1018
1009
  if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
1019
- else if (policy.aged && policy.age_seconds != null) {
1010
+ // a block that has aged is told once, not every age interval for as long as it lasts
1011
+ else if (policy.aged && policy.age_seconds != null && !block.aged_at) {
1020
1012
  const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
1021
1013
  if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
1022
1014
  const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
1023
- run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, notified_at: now() } : b)), id);
1015
+ run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, aged_at: now() } : b)), id);
1024
1016
  effects.push(...fire(db, board, entry, id, policy.aged, { roles: ['platform'], payload: { on: block.id, reason: block.reason } }).effects);
1025
1017
  }
1026
1018
  }
@@ -5,7 +5,13 @@ import { basename, dirname } from 'node:path';
5
5
  import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
6
6
  import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
7
7
  export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
8
+ // The dispatcher's latest presence reading (observeFleet): a machine it reads as offline is not asked.
9
+ let presence = null;
10
+ export const notePresence = (reading) => { presence = reading; };
8
11
  function workspaceDoor(machine, verb, value, options = {}) {
12
+ if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && !presence.online.has(normalMachine(machine))) {
13
+ throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
14
+ }
9
15
  if (!machine || normalMachine(machine) === localMachine()) {
10
16
  const map = new Workspaces(teamsHome());
11
17
  if (verb === 'inspect') return inspectCheckout(value);
package/board/ztrack.mjs CHANGED
@@ -70,8 +70,13 @@ class ZtrackBoard {
70
70
  this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
71
71
  this.file = `${marker}.board.db`;
72
72
  mkdirSync(dirname(this.path), { recursive: true });
73
- if (!existsSync(this.file)) this.create(`${marker}.runtime.db`);
73
+ const legacy = `${marker}.runtime.db`;
74
+ // A 0-byte file is no store: a read through the sqlite3 CLI creates one at a path that has none (on 2026-10-01 a
75
+ // `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
76
+ if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
77
+ if (!existsSync(this.file)) this.create(legacy);
74
78
  this.conn = new DatabaseSync(this.file);
79
+ this.refuseUnimported(legacy);
75
80
  this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
76
81
  this.active = false; this.overlay = false; this.seen = null;
77
82
  this.prepareConnection();
@@ -146,6 +151,24 @@ class ZtrackBoard {
146
151
  }
147
152
  }
148
153
 
154
+ // A store holding fewer runs than the earlier store beside it was made without the one-time import (on 2026-10-01
155
+ // one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
156
+ // running card). The board refuses it, so nothing runs on a board without its history.
157
+ refuseUnimported(legacy) {
158
+ if (!existsSync(legacy)) return;
159
+ const here = this.conn.prepare('SELECT COUNT(*) AS n FROM main.task_runs').get().n;
160
+ let runs = 0;
161
+ const old = new DatabaseSync(legacy, { readOnly: true });
162
+ try {
163
+ if (old.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runtime_rows'").get()) {
164
+ runs = old.prepare("SELECT COUNT(*) AS n FROM runtime_rows WHERE name = 'task_runs' AND value IS NOT NULL").get().n;
165
+ }
166
+ } finally { old.close(); }
167
+ if (here >= runs) return;
168
+ this.conn.close();
169
+ throw new Error(`${this.file} holds ${here} runs, but ${legacy} holds ${runs}: it was created without importing them. Move it aside (keep it; writes made to it are not in the earlier store) and run any board command to import.`);
170
+ }
171
+
149
172
  // A board created from the previous runtime store imports its rows once; the old file stays as it was.
150
173
  create(legacy) {
151
174
  const tmp = `${this.file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
package/cron.mjs CHANGED
@@ -750,18 +750,24 @@ function firePrompt(job) {
750
750
  return job.prompt ?? '';
751
751
  }
752
752
 
753
+ // supercode's normal machine name (session addresses spell a machine this way)
754
+ const machineName = (name) => String(name ?? '').split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
755
+
753
756
  function startFire(state, profile, job, now, effects) {
754
757
  const fire = claimFire(profile, job, now);
755
758
  if (!fire) return;
756
759
  effects.push({ type: 'save' }); // the claim is durable before anything runs (at-most-once)
757
- if (state.runtime?.hostedJobs) {
760
+ // A job that names another machine runs each fire in a session there: the loop launches it on that machine, and the
761
+ // launch's receipt finishes the fire as a host's would.
762
+ const remote = job.machine && state.runtime?.machine && machineName(job.machine) !== state.runtime.machine ? machineName(job.machine) : null;
763
+ if (state.runtime?.hostedJobs || remote) {
758
764
  fire.started_at = now;
759
765
  fire.status = 'running';
760
766
  fire.session_id = null; // a host callback is not a fabricated native worker session
761
767
  fire.residue = { execution: 'host_task' };
762
768
  state.runtime.hostedFires[`${profile.name}:${fire.id}`] = true;
763
769
  effects.push({ type: 'save' });
764
- effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id });
770
+ effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id, ...(remote ? { machine: remote } : {}) });
765
771
  return;
766
772
  }
767
773
  // A gate (a script, a monitor source) runs before any model, off the event
package/loop.mjs CHANGED
@@ -23,6 +23,9 @@ import { performRemoveProfileDir } from './operator.mjs';
23
23
  import { harnessHomes } from './mcp/verbs.mjs';
24
24
  import { checkMonitor, runJobScript } from './gates.mjs';
25
25
  import { startBoard } from './board.mjs';
26
+ import { localMachine } from './board/dispatch.mjs';
27
+ import { execFile } from 'node:child_process';
28
+ import { promisify } from 'node:util';
26
29
 
27
30
  /**
28
31
  * Where a worker keeps its transcripts inside its config home (`<profile>/<harness>`), as discovery and
@@ -54,6 +57,7 @@ export class Orchestrator {
54
57
  this.hostedTasks = new Set();
55
58
  state.runtime ??= {};
56
59
  state.runtime.hostedJobs = !!executeJob;
60
+ state.runtime.machine = localMachine(); // a job naming another machine fires there (cron.mjs startFire)
57
61
  state.runtime.hostedFires = {};
58
62
  this.vault = vault;
59
63
  this.client = client;
@@ -263,12 +267,12 @@ export class Orchestrator {
263
267
  }
264
268
  }
265
269
 
266
- executeHostedJob({ profile: name, job_id, fire_id }) {
270
+ executeHostedJob({ profile: name, job_id, fire_id, machine = null }) {
267
271
  const profile = this.state.profiles[name];
268
272
  const job = structuredClone(profile.jobs[job_id]);
269
273
  const fire = structuredClone(profile.fires.find((f) => f.id === fire_id));
270
274
  // Host work never blocks the state queue; completion returns as an event.
271
- const task = Promise.resolve().then(() => this.executeJob({ profile: name, job, fire }))
275
+ const task = Promise.resolve().then(() => machine ? this.launchRemoteFire({ profile, job, fire, machine }) : this.executeJob({ profile: name, job, fire }))
272
276
  .then((receipt) => {
273
277
  if (!receipt || typeof receipt.output !== 'string' || receipt.accepted !== true)
274
278
  throw new Error('The host did not confirm this scheduled task');
@@ -279,6 +283,23 @@ export class Orchestrator {
279
283
  this.hostedTasks.add(task);
280
284
  }
281
285
 
286
+ /**
287
+ * A fire of a job pinned to another machine: a session of the profile's worker harness on that machine, in a window
288
+ * there, given the job's prompt (`supercode open --new --on`, keyed by the fire so a retry never opens a second).
289
+ * The launch is the fire's receipt; the session does the job's work and reports through its own doors.
290
+ */
291
+ async launchRemoteFire({ profile, job, fire, machine }) {
292
+ const harness = profile.worker?.harness;
293
+ const program = { 'claude-code': 'claude', codex: 'codex' }[harness];
294
+ if (!program) throw new Error(`profile ${profile.name}'s worker (${harness ?? 'none'}) cannot run a fire on ${machine}`);
295
+ const args = ['open', '--new', program, '--detach', '--window=true', '--on', machine, '--key', `cron-${fire.id}`, '--input', job.prompt ?? ''];
296
+ if (job.workdir && job.workdir.startsWith('/')) args.push('--cwd', job.workdir);
297
+ if (job.model) args.push('--', '--model', job.model);
298
+ const { stdout } = await promisify(execFile)(process.env.SUPERCODE_BIN || 'supercode', args, { timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
299
+ const pane = /"pane"\s*:\s*"([^"]+)"/.exec(stdout)?.[1] ?? null;
300
+ return { accepted: true, output: `Fire ${fire.id} of ${job.id} launched on ${machine}${pane ? ` in pane ${pane}` : ''}.`, machine, pane };
301
+ }
302
+
282
303
  /**
283
304
  * A fire's gate, off the queue (a script may run for Hermes's hour): the
284
305
  * monitor source first, then the pre-run script unless the monitor already
@@ -226,6 +226,13 @@
226
226
  "board": {
227
227
  "type": "string",
228
228
  "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
229
+ },
230
+ "findings": {
231
+ "type": "array",
232
+ "items": {
233
+ "type": "string"
234
+ },
235
+ "description": "Each finding, filed as a task on the arc before the card returns; an arc with no open task is refused."
229
236
  }
230
237
  },
231
238
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.149",
3
+ "version": "0.4.150",
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": {