@volter/supercode-orchestrator 0.5.228 → 0.5.230

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.
Files changed (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
package/board/listing.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { setImmediate as nextTurn } from 'node:timers/promises';
4
4
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
5
5
  import { executionOf } from './session-work.mjs';
6
+ import { DISPATCH_WAIT_SQL } from './queries.mjs';
6
7
  import { CARD_VISIBILITY_SQL } from './visibility.mjs';
7
8
  import { cardState, cardStateSql } from './card-state.mjs';
8
9
 
@@ -14,6 +15,8 @@ export const READ_BY_OWNER = 'board_read_by_owner';
14
15
  export const byOwner = (reason) => Object.assign(new Error(`this listing is the board owner's to answer: ${reason}`), { code: READ_BY_OWNER });
15
16
  export const LIST_CONNECTION = Symbol.for('supercode.board.listing.connection');
16
17
  export const LIST_REVISION = Symbol.for('supercode.board.listing.revision');
18
+ export const READ_BOUNDARY = Symbol.for('supercode.board.read.boundary');
19
+ export const readBoundary = (db, fn) => db[READ_BOUNDARY] ? db[READ_BOUNDARY](fn) : fn();
17
20
  const parse = (text, fallback) => { try { return JSON.parse(text) ?? fallback; } catch { return fallback; } };
18
21
  const statements = new WeakMap();
19
22
  const prepared = (db, sql) => {
@@ -53,6 +56,8 @@ export const TASK_SUMMARY_INDEX = 'idx_tasks_list_summary_readable';
53
56
  export const TASK_SUMMARY_INDEX_SQL = `CREATE INDEX IF NOT EXISTS ${TASK_SUMMARY_INDEX} ON tasks(${HUMAN_FIELDS},current_run_id,session_id,workflow_template_id,current_step_key)`;
54
57
  const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('supercode', json_object(
55
58
  'address',json_extract(r.metadata,'$.supercode.address'),'session_id',json_extract(r.metadata,'$.supercode.session_id'),
59
+ 'pane',json_extract(r.metadata,'$.supercode.pane'),'pid',json_extract(r.metadata,'$.supercode.pid'),'launch_key',json_extract(r.metadata,'$.supercode.launch_key'),
60
+ 'launch_deferred',json_extract(r.metadata,'$.supercode.launch_deferred'),'launch_deferred_error',json_extract(r.metadata,'$.supercode.launch_deferred_error'),'launched_at',json_extract(r.metadata,'$.supercode.launched_at'),
56
61
  'close_confirmed_at',json_extract(r.metadata,'$.supercode.close_confirmed_at'),'released',json_extract(r.metadata,'$.supercode.released'),
57
62
  'presence_observation',json_extract(r.metadata,'$.supercode.presence_observation'),'idle_observation',json_extract(r.metadata,'$.supercode.idle_observation'),
58
63
  'machine',json_extract(r.metadata,'$.supercode.machine'),'offline_since',json_extract(r.metadata,'$.supercode.offline_since'),
@@ -61,12 +66,13 @@ const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('superco
61
66
  ), 'last_events', json_object('heartbeat', json_extract(r.metadata,'$.last_events.heartbeat'))) END`;
62
67
  // What a working card's session reading needs beside its current run (session-work.mjs executionOf, readingContext reads
63
68
  // the same): the current run's latest heartbeat event (a declared wait, --until) and, with no current run, its latest
64
- // run. Read only for the working lanes.
69
+ // run. The dispatcher's applicable wait is read in any open lane with no current run.
65
70
  const WORKING_LANE = "COALESCE(c.stage,t.status) IN ('running','review')";
66
71
  const READING_CONTEXT = `CASE WHEN ${WORKING_LANE} THEN (SELECT json_object('payload',CASE WHEN json_valid(e.payload) THEN json(e.payload) END,'at',e.created_at,'run_id',e.run_id)
67
72
  FROM task_events e WHERE e.task_id = t.id AND e.kind = 'heartbeat' AND e.run_id = t.current_run_id ORDER BY e.id DESC LIMIT 1) END AS heartbeat_event,
68
73
  CASE WHEN ${WORKING_LANE} AND t.current_run_id IS NULL THEN (SELECT json_object('status',l.status,'outcome',l.outcome,'ended_at',l.ended_at,'error',substr(l.error,1,300))
69
- FROM task_runs l WHERE l.task_id = t.id ORDER BY l.id DESC LIMIT 1) END AS last_run`;
74
+ FROM task_runs l WHERE l.task_id = t.id ORDER BY l.id DESC LIMIT 1) END AS last_run,
75
+ (${DISPATCH_WAIT_SQL}) AS dispatch_wait`;
70
76
  export const LIST_CANDIDATES_SQL = `SELECT MAX(id) AS last, json_group_array(id) AS ids FROM (
71
77
  SELECT id FROM tasks WHERE id > ? AND id <= ? ORDER BY id LIMIT ${LIST_BATCH})`;
72
78
 
@@ -81,7 +87,7 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
81
87
 
82
88
  export function listPageSql(detailed, tagCount = 0, options = {}) {
83
89
  return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
84
- n.number,c.machine,c.stage,c.paused,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
90
+ n.number,c.machine,c.stage,c.paused,c.backlog,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
85
91
  r.id AS run_id,r.status AS run_status,r.started_at AS run_started_at,r.ended_at AS run_ended_at,${RUN_METADATA} AS run_metadata,
86
92
  ${READING_CONTEXT}
87
93
  ${detailed ? `,(SELECT json_group_array(parent_id) FROM (SELECT parent_id FROM task_links WHERE child_id = t.id ORDER BY parent_id)) AS parents_json,
@@ -89,7 +95,7 @@ export function listPageSql(detailed, tagCount = 0, options = {}) {
89
95
  FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN supercode_card_numbers n ON n.task_id = t.id
90
96
  LEFT JOIN task_runs r ON r.id = t.current_run_id
91
97
  WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR ${cardStateSql(options)} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?))
92
- AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
98
+ AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? IS NULL OR (c.backlog IS NOT NULL AND c.backlog != 'null') = ?) AND ${CARD_VISIBILITY_SQL}
93
99
  AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
94
100
  AND (? IS NULL OR t.workflow_template_id = ?) AND (? IS NULL OR t.current_step_key = ?)
95
101
  ${Array.from({ length: tagCount }, () => `AND CASE WHEN json_valid(c.tags) THEN
@@ -112,7 +118,7 @@ export async function listCardsInTurns(db, options = {}) {
112
118
  }
113
119
 
114
120
  async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = false,
115
- tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false }, revision, { inTurns = true } = {}) {
121
+ tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false, backlog = null }, revision, { inTurns = true } = {}) {
116
122
  const out = [];
117
123
  const page = prepared(db, listPageSql(detailed, tags.length, { nativeHolds })), candidates = prepared(db, LIST_CANDIDATES_SQL);
118
124
  const ceiling = measured('last candidate', () => prepared(db, "SELECT COALESCE(MAX(id), '') AS cursor FROM tasks").get().cursor);
@@ -122,13 +128,13 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
122
128
  const range = measured('candidate range', () => candidates.get(cursor, ceiling));
123
129
  if (range.last == null) break;
124
130
  const rows = measured(detailed ? 'JSON task rows and latest blocks' : 'human task rows', () => page.all(range.ids,
125
- status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
131
+ status, status, status, status, assignee, assignee, machine, machine, backlog == null ? null : Number(Boolean(backlog)), backlog == null ? null : Number(Boolean(backlog)), includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
126
132
  tenant, tenant, session, session, workflowTemplateId, workflowTemplateId, stepKey, stepKey, ...tags.flatMap(tag => [tag, tag, tag])));
127
133
  measured('row projection', () => {
128
- for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, heartbeat_event, last_run, parents_json, block_payload, ...task } of rows) {
134
+ for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, heartbeat_event, last_run, dispatch_wait, parents_json, block_payload, ...task } of rows) {
129
135
  const metadata = run_id ? parse(run_metadata, {}) : null;
130
136
  const run = run_id ? { id: run_id, status: run_status, started_at: run_started_at, ended_at: run_ended_at, metadata } : null;
131
- task.execution = executionOf(run, { heartbeat: parse(heartbeat_event, null), last: parse(last_run, null) });
137
+ task.execution = executionOf(run, { heartbeat: parse(heartbeat_event, null), last: parse(last_run, null), queue: parse(dispatch_wait, null) });
132
138
  if (detailed) {
133
139
  task.skills = parse(task.skills, []);
134
140
  task.workflow_status = task.stage ?? task.status;
@@ -0,0 +1,59 @@
1
+ // A run-on-the-machine verb (client.mjs: boards, init, gc, repair, migrate, dispatch, watch, tail) runs in a process of its
2
+ // own, never a board generation, so prepareBoardRestingAdmission (resting-admission.mjs) never binds its reader: every
3
+ // fenced transaction it opened over a home holding a backlog or a bounded pause refused (`boards list`, a cold `init`).
4
+ // It is admitted on the incumbent a generation is: a running owner's own status, else the record the home's stable owner
5
+ // keeps (board-generation.json), which a cold owner lends its first generation (generation-owner.mjs). With neither, or
6
+ // a legacy incumbent, it stays legacy and such a home still refuses it. selectedReader and recordedReader are the
7
+ // owner's admission's (resting-admission.mjs), kept here so that storage module, and so the board format, stays as it is.
8
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
9
+ import { isAbsolute, join } from 'node:path';
10
+ import { askBoardOwner } from '@volter/teams/board-owner';
11
+ import { nativeJsonEqual } from '@volter/supercode-interchange/native-json';
12
+ import { readSourceCandidate } from '@volter/supercode-teams/source-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
13
+ import { sourceLockPath } from '@volter/supercode-teams/source-lock'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
14
+ import { readRetainedRelease } from '@volter/supercode-teams/release-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
15
+ import { restingContract } from './resting-protocol.mjs';
16
+ import { bindRestingReader, restingReaderBound } from './resting-state.mjs';
17
+
18
+ const MODE = 'backlog-bounded-pause-v1';
19
+
20
+ function selectedReader(selected, env) {
21
+ if (!selected || typeof selected.entry !== 'string' || !isAbsolute(selected.entry)) return null;
22
+ const entry = realpathSync(selected.entry);
23
+ if (!selected.candidate === !selected.release) return null;
24
+ if (selected.candidate) {
25
+ const source = readSourceCandidate(realpathSync(selected.candidate), sourceLockPath(env));
26
+ if (entry !== realpathSync(join(source.value.lock.root, 'sdk/orchestrator/bin/orchestrator.mjs')) ||
27
+ realpathSync(selected.native) !== realpathSync(source.program)) return null;
28
+ } else {
29
+ const release = readRetainedRelease(selected.release);
30
+ if (entry !== realpathSync(release.board) || realpathSync(selected.native) !== realpathSync(release.program)) return null;
31
+ }
32
+ return restingContract(entry, { optional: true });
33
+ }
34
+
35
+ function recordedReader(status, env) {
36
+ const prior = status.restingReaderProtocol === 1 && status.lastRestingReader;
37
+ if (prior?.version !== 1 || !prior.selected || !prior.resting) return null;
38
+ const actual = selectedReader(prior.selected, env);
39
+ return actual && nativeJsonEqual(actual, prior.resting) ? prior.selected : null;
40
+ }
41
+
42
+ export async function admitLocalRestingReader(root, env = process.env) {
43
+ if (restingReaderBound()) return;
44
+ let compatible = false;
45
+ try {
46
+ root = realpathSync(root);
47
+ const answer = await askBoardOwner(root, { argv: ['handover', '--action', 'status'] }, { waitMs: 0, timeoutMs: 8000 });
48
+ let status = null;
49
+ if (answer.code === 0 && !answer.unanswered) status = JSON.parse(Array.isArray(answer.stdout) ? answer.stdout.join('') : answer.stdout);
50
+ else if (answer.down && existsSync(join(root, 'board-generation.json'))) {
51
+ const record = JSON.parse(readFileSync(join(root, 'board-generation.json'), 'utf8'));
52
+ if (record?.version === 1 && record.restingReaderProtocol === 1) status = { restingReaderProtocol: 1, lastRestingReader: record.lastRestingReader ?? null };
53
+ }
54
+ const selected = status?.selected ?? (status ? recordedReader(status, env) : null);
55
+ const reader = selected ? selectedReader(selected, env) : null;
56
+ compatible = reader?.protocol.readModes.includes(MODE) === true && reader.codec?.formatFeatures?.includes('acceptance-layout-v1') === true;
57
+ } catch { compatible = false; }
58
+ bindRestingReader(compatible);
59
+ }
@@ -1,3 +1,4 @@
1
+ import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
1
2
  // The board owner's one-per-home lock, held by a process of its own. A POSIX record lock belongs to the process that took
2
3
  // it, and any open and close of the same file in that process drops it; no reader in the owner can reach a lock another
3
4
  // process holds. The holder takes SQLite's exclusive lock on `board.lock` (the default VFS), says so, and exits when its
@@ -11,7 +12,10 @@ let lock;
11
12
  try {
12
13
  lock = new DatabaseSync(process.argv[1]);
13
14
  lock.exec('PRAGMA busy_timeout = 0; PRAGMA locking_mode = EXCLUSIVE; BEGIN EXCLUSIVE');
14
- } catch (error) { process.stdout.write('held ' + String(error.message).replace(/\\n/g, ' ') + '\\n'); process.exit(1); }
15
+ } catch (error) {
16
+ // SQLITE_BUSY (5) is another process holding the lock; any other failure (an unopenable path) is not an owner.
17
+ process.stdout.write(((error.errcode & 0xff) === 5 ? 'held ' : 'failed ') + String(error.message).replace(/\\n/g, ' ') + '\\n'); process.exit(1);
18
+ }
15
19
  process.stdout.write('ready\\n');
16
20
  process.stdin.on('data', () => {});
17
21
  process.stdin.on('end', () => process.exit(0));
@@ -19,16 +23,18 @@ process.stdin.on('error', () => process.exit(0));
19
23
  `;
20
24
 
21
25
  /**
22
- * Hold `path` for this process's life. Resolves once the holder has the lock; rejects with its reason when another
23
- * owner holds it. `onLost` is told if the holder ends while this process still runs (the lock is then gone).
24
- * Returns `release()`, which ends the holder.
26
+ * Hold `path` for this process's life. Resolves once the holder has the lock; rejects with its reason otherwise, the
27
+ * rejection's `code` 'EHELD' only when another owner holds it. `onLost` is told if the holder ends while this process still runs (the lock is then gone).
28
+ * Returns idempotent `release()`, which joins the holder's exit.
25
29
  */
26
30
  export function holdLock(path, { onLost = () => {} } = {}) {
27
31
  return new Promise((resolve, reject) => {
28
32
  const child = spawn(process.execPath, ['--no-warnings', '-e', HOLDER, path], { stdio: ['pipe', 'pipe', 'ignore'] });
33
+ const exited = new Promise(done => child.once('exit', done));
29
34
  let said = '';
30
35
  let ready = false;
31
36
  let released = false;
37
+ let releasing;
32
38
  child.stdout.setEncoding('utf8');
33
39
  child.stdout.on('data', (chunk) => {
34
40
  said += chunk;
@@ -37,8 +43,14 @@ export function holdLock(path, { onLost = () => {} } = {}) {
37
43
  if (line === 'ready') {
38
44
  ready = true;
39
45
  child.unref(); child.stdout.unref?.(); child.stdin.unref?.();
40
- resolve({ release: () => { released = true; child.stdin.end(); } });
41
- } else reject(new Error(line.replace(/^held /, '')));
46
+ resolve({ release: () => releasing ??= (async () => {
47
+ released = true;
48
+ // The lock is normally unreferenced. Keep its exit alive while its owner joins retirement.
49
+ child.ref(); child.stdin.ref?.();
50
+ stopWorkChild(child, () => child.stdin.end());
51
+ await exited;
52
+ })() });
53
+ } else reject(line.startsWith('held ') ? Object.assign(new Error(line.slice(5)), { code: 'EHELD' }) : new Error(line.replace(/^failed /, '')));
42
54
  });
43
55
  child.on('error', (error) => { if (!ready) reject(error); });
44
56
  child.on('exit', (code, signal) => {
@@ -1,3 +1,5 @@
1
+ import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
2
+ const execFileSync = (...args) => measureSyncChild(() => workLedgerNativeExecFileSync(...args));
1
3
  // The fleet as each machine the board works on keeps it: its panes, the harness session beneath each live pane's
2
4
  // process, and what a board launch needs of it (`capabilities`: its board process guard's harnesses, and whether it has
3
5
  // the keyed launch door). Each machine pushes this as its record (order 3 PR 3, @volter/teams machine-records): this
@@ -7,7 +9,7 @@
7
9
  // A machine whose record is current is `online`. One whose record is out of date (its stream closed, its panes or the
8
10
  // sessions under them not read) or was never read is `unknown` (its runs are kept, as an unread machine's always were), unless its record says it was unlinked from the
9
11
  // team: then it is offline. This machine's own record not being given is no reading at all.
10
- import { execFileSync } from 'node:child_process';
12
+ import { execFileSync as workLedgerNativeExecFileSync } from 'node:child_process';
11
13
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
12
14
  import { machineCall, machineRecordStream } from './doors.mjs';
13
15
  import { RecordView } from './record-view.mjs';
@@ -19,9 +21,12 @@ const recordView = new RecordView(machineRecordStream);
19
21
  const paneFacts = (held) => {
20
22
  const record = held?.record;
21
23
  if (!record?.panes) return '';
22
- const under = new Map((record.panes.activities ?? []).map(({ pid, activity }) => [pid, activity]));
24
+ const under = new Map((record.panes.activities ?? []).map((row) => [row.pid, row]));
23
25
  return JSON.stringify([
24
- (record.panes.panes ?? []).map((pane) => [pane.pane, Boolean(pane.dead), under.get(pane.pid)?.session_id ?? null, under.get(pane.pid)?.presence === 'running']).sort(),
26
+ (record.panes.panes ?? []).map((pane) => {
27
+ const row = under.get(pane.pid);
28
+ return [pane.pane, Boolean(pane.dead), row?.activity?.harness ?? null, row?.activity?.session_id ?? null, row?.locator?.storage ?? null, row?.activity?.presence === 'running'];
29
+ }).sort(),
25
30
  (record.panes.ended ?? []).map((exit) => exit.pane).sort(),
26
31
  ]);
27
32
  };
@@ -45,23 +50,46 @@ export function onPanesChanged(listener) { paneListeners.add(listener); return (
45
50
  /** The admitted, committed records, including native turn/health changes which need not change a pane. */
46
51
  export function onRecordsChanged(listener) { recordListeners.add(listener); return () => recordListeners.delete(listener); }
47
52
  export async function readFleetRecords() { return recordView.read(DEADLINE_MS); }
53
+ /**
54
+ * One machine's panes as its held record has them at this moment, with no door call and no wait: its pane rows (as
55
+ * panes.list gives them: pane, dead, pid, cwd, activeCommand) and the harness session under each live pane
56
+ * (`activities`, by pid, with its locator). Null when that record is not current (the record stream not open and
57
+ * caught up, the machine's own stream out of date, its panes or the sessions under them not read): the caller then
58
+ * asks the machine's door, as it did before the record was read here.
59
+ */
60
+ export function heldPanes(machine) {
61
+ if (!fleetRecordsCurrent()) return null;
62
+ const held = recordView.records?.[machine];
63
+ if (notCurrent(held)) return null;
64
+ const panes = held.record.panes.panes ?? [];
65
+ // a record from a daemon that does not name each pane's foreground program (an older one) is no reading of what a
66
+ // closing pane runs: its door is asked, as before
67
+ if (panes.some((pane) => !Object.hasOwn(pane, 'activeCommand'))) return null;
68
+ return { panes, activities: held.record.panes.activities ?? [] };
69
+ }
48
70
  export function fleetRecordsCurrent() { return Boolean(recordView.ready && recordView.stream && Date.now() - recordView.heardAt <= 45_000); }
49
71
  const DEADLINE_MS = 10_000;
50
72
  const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms).unref?.())]);
51
73
  // One machine's reading, from its held record: its panes, what a board launch needs of it, and the session under each
52
74
  // live pane.
53
- function readOne(name, held) {
54
- if (!held) return { machine: name, error: 'this machine follows no record of it (not linked to the team, or not yet read)', code: 'unavailable' };
55
- if (held.stream === 'unlinked') return { machine: name, error: held.why ?? 'unlinked from the team', code: 'machine_offline' };
56
- if (!held.record) return { machine: name, error: `its record was never read: ${held.why ?? 'no reason given'}`, code: 'unavailable' };
57
- if (held.stream !== 'open' && held.stream !== 'self') return { machine: name, error: `its record may be out of date: its stream is ${held.stream} since ${held.since ? new Date(held.since).toISOString() : 'unknown'} (${held.why ?? 'no reason given'})`, code: 'stale' };
75
+ /** Why a machine's held record is no current reading of its panes (`{ error, code }`), or null when it is one. */
76
+ function notCurrent(held) {
77
+ if (!held) return { error: 'this machine follows no record of it (not linked to the team, or not yet read)', code: 'unavailable' };
78
+ if (held.stream === 'unlinked') return { error: held.why ?? 'unlinked from the team', code: 'machine_offline' };
79
+ if (!held.record) return { error: `its record was never read: ${held.why ?? 'no reason given'}`, code: 'unavailable' };
80
+ if (held.stream !== 'open' && held.stream !== 'self') return { error: `its record may be out of date: its stream is ${held.stream} since ${held.since ? new Date(held.since).toISOString() : 'unknown'} (${held.why ?? 'no reason given'})`, code: 'stale' };
58
81
  const record = held.record;
59
82
  // panes not read, or read without the sessions under them (or while the session index is lost), are no reading of
60
83
  // what runs there: the machine is unknown, never one whose panes or sessions are absent (refused is not absent). A
61
84
  // part the index could not read (`sessions_index_parts`) is not: it keeps the rows its last read committed, and the
62
85
  // sessions under the panes are read by their processes, not from it; the record names it.
63
- const notCurrent = record.unread?.panes ?? record.unread?.sessions_index ?? record.unread?.claude_registry_watch ?? record.unread?.pane_exits_watch ?? record.panes?.activities_omitted ?? record.panes?.sessions_unread ?? (record.panes ? null : 'its record holds no pane list');
64
- if (notCurrent) return { machine: name, error: `its panes are not current: ${notCurrent}`, code: 'stale' };
86
+ const unread = record.unread?.panes ?? record.unread?.sessions_index ?? record.unread?.claude_registry_watch ?? record.unread?.pane_exits_watch ?? record.panes?.activities_omitted ?? record.panes?.sessions_unread ?? (record.panes ? null : 'its record holds no pane list');
87
+ return unread ? { error: `its panes are not current: ${unread}`, code: 'stale' } : null;
88
+ }
89
+ function readOne(name, held) {
90
+ const why = notCurrent(held);
91
+ if (why) return { machine: name, ...why };
92
+ const record = held.record;
65
93
  const panes = record.panes?.panes ?? [];
66
94
  const capabilities = record.capabilities ?? { guard: [], launches: false };
67
95
  // Since when its record has been current without a break: this machine's own since it last woke (localWake), another's
@@ -80,6 +108,27 @@ export function localWake() {
80
108
  } catch { return null; }
81
109
  }
82
110
 
111
+ /** The parts of a record its session rows and their activity come from: one not read leaves no row of it held. */
112
+ const SESSION_SOURCES = ['sessions', 'sessions_index', 'sessions_index_parts', 'claude_registry_watch', 'sessions_activity'];
113
+ /**
114
+ * The sessions a machine's held record lists with their activity, grouped by address without losing distinct stores,
115
+ * as a discovery row of each (`locator`,
116
+ * `cwd`, `updated_at_ms`, `activity`: the machine's follow of its live sessions), where that record is current with
117
+ * every source of its session list read; none otherwise. A session the record lists without an activity (one past the
118
+ * sessions the machine follows, or a follower not granted it) is not held.
119
+ */
120
+ function heldSessions(name, held, into, transcripts) {
121
+ if (!held?.record || (held.stream !== 'open' && held.stream !== 'self') || !Array.isArray(held.record.sessions)) return;
122
+ if (SESSION_SOURCES.some((source) => held.record.unread?.[source])) return;
123
+ for (const row of held.record.sessions) {
124
+ if (!row?.locator || typeof row.address !== 'string' || !row.address.startsWith(`sc:${name}:`)) continue;
125
+ // `transcripts`: every listed session's transcript locator, activity or not
126
+ transcripts.set(row.address, [...(transcripts.get(row.address) ?? []), { locator: row.locator }]);
127
+ if (!row.activity) continue;
128
+ into.set(row.address, [...(into.get(row.address) ?? []), { locator: row.locator, cwd: row.cwd ?? null, updated_at_ms: row.last_message_at_ms ?? null, activity: row.activity }]);
129
+ }
130
+ }
131
+
83
132
  /** One reading of `machines` (normalized names) and this machine, `local`: every record, from this machine's daemon. */
84
133
  export async function readMachineFleet(machines, local) {
85
134
  const names = [...new Set([local, ...machines])];
@@ -96,11 +145,16 @@ export async function readMachineFleet(machines, local) {
96
145
  }
97
146
  catch (error) { heldError = String(error?.message ?? error).slice(0, 300); }
98
147
  const rows = names.map((name) => heldError ? { machine: name, error: `this machine's daemon did not give the records it holds: ${heldError}`, code: 'unavailable' } : readOne(name, held[name]));
148
+ // `held`: each machine's live session rows with their activity, grouped by address (heldSessions);
149
+ // `transcripts`: each listed transcript locator, grouped by address, retaining distinct native stores
150
+ const heldRows = new Map(), transcripts = new Map();
151
+ if (!heldError) for (const name of names) heldSessions(name, held[name], heldRows, transcripts);
99
152
  noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'}, held records)`, performance.now() - started);
100
153
  // `panes`: each answering machine's live pane ids, whether or not a session was read in them
154
+ // `alive`: address-only presence for mail routing; retained conversations use the locators in `sessions`
101
155
  // `ended`: the panes each machine saw end (its tmux's exit log, or a pane its host keeps dead), with how each ended
102
156
  // `linkedSince`: each online machine's unbroken current reading began (epoch ms; this machine's last wake; null unknown)
103
- const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map(), linkedSince = new Map();
157
+ const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map(), linkedSince = new Map();
104
158
  let localError = null, localCode = null;
105
159
  for (const row of rows) {
106
160
  if (row.error) {
@@ -124,7 +178,6 @@ export async function readMachineFleet(machines, local) {
124
178
  const pane = live.find((item) => item.pid === pid);
125
179
  if (!pane || !activity?.session_id || !activity.harness) continue;
126
180
  const address = `sc:${row.machine}:${activity.harness}:${activity.session_id}`, running = activity.presence === 'running';
127
- native.set(address, { alive: running ? true : null, states: [running ? true : null] });
128
181
  if (running) alive.add(address);
129
182
  sessions.push({ address, machine: row.machine, harness: activity.harness, session_id: activity.session_id, locator: locator ?? null, pane: pane.pane, name: pane.title ?? pane.label ?? null,
130
183
  starter_session: pane.starter_session ?? null,
@@ -142,5 +195,5 @@ export async function readMachineFleet(machines, local) {
142
195
  // why, as any other is: the machines that answered are current beside it. Only no record of this machine at all
143
196
  // leaves the whole reading untaken.
144
197
  const ok = online.has(local) || (!heldError && localCode === 'stale');
145
- return { ok, presenceKnown: ok, online, offline, unknown, errors, alive, native, sessions, capabilities, panes, ended, linkedSince, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
198
+ return { ok, presenceKnown: ok, online, offline, unknown, errors, alive, sessions, held: heldRows, transcripts, capabilities, panes, ended, linkedSince, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
146
199
  }
@@ -1,3 +1,4 @@
1
+ import { registerWorkService } from '@volter/supercode-harness-sdk/work-ledger';
1
2
  // The thread that serves the board owner's socket (owner-door.mjs). A listing is answered here, read on a connection of
2
3
  // its own; every other request is handed to the owner's thread and its answer written back.
3
4
  import { parentPort, workerData } from 'node:worker_threads';
@@ -7,19 +8,18 @@ import { boardOwnerSocket } from '@volter/teams/board-owner';
7
8
  import { parse } from './args.mjs';
8
9
  import { answerOne } from './cli.mjs';
9
10
  import { knowLocalMachine } from './dispatch.mjs';
10
- import { holdOwnerHealth } from './owner-health.mjs';
11
11
  import { ownHome } from './owner-files.mjs';
12
12
  import { writeOwnerAnswer } from './reply.mjs';
13
13
  import { holdWorkflow } from './workflow.mjs';
14
14
 
15
15
  const { root, machine, readListings = true } = workerData;
16
+ registerWorkService({role:'board-door-worker',scope:{kind:'install',id:root},workerPurpose:'board-door'});
16
17
  const waiting = new Map(); // request id → its answer's resolve
17
18
  const MAX_REQUEST = 8 * 1024 * 1024, MAX_PENDING = 64 * 1024 * 1024;
18
19
  let next = 0, pendingBytes = 0, inputBytes = 0, connections = 0;
19
20
  const refused = message => ({ code: 1, stdout: '', stderr: `supercode workflow: ${message}; no operation was submitted\n` });
20
21
  parentPort.on('message', (frame) => {
21
- if (frame.type === 'health') holdOwnerHealth(frame.boards, frame.round);
22
- else if (frame.type === 'workflow') holdWorkflow(frame.root, frame.entry);
22
+ if (frame.type === 'workflow') holdWorkflow(frame.root, frame.entry);
23
23
  else if (frame.type === 'answer') { const held = waiting.get(frame.id); if (held) { pendingBytes -= held.bytes; waiting.delete(frame.id); held.resolve(frame.answer); } }
24
24
  });
25
25
  await knowLocalMachine(machine);
@@ -1,11 +1,11 @@
1
+ import { observeWorker, stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
1
2
  // The board owner's socket, served on a thread of its own (owner-door-worker.mjs), so a caller is heard while the
2
3
  // owner's loop is held by a round. A listing (`list`, `ls`) is read on that thread, each on a connection of its own in
3
4
  // one SQLite read transaction (store.mjs openBoard, readOnly): WAL serves it beside the owner's writes, and neither
4
5
  // waits on the other. Every other request, and a listing that thread leaves to the owner (listing.mjs byOwner), comes
5
- // here and is answered by the board's one writer exactly as before. The thread holds what a listing says of the owner:
6
- // its dispatcher's health (owner-health.mjs) and the workflow it read (workflow.mjs), sent on each change.
6
+ // here and is answered by the board's one writer exactly as before. The thread holds the workflow it read
7
+ // (workflow.mjs), sent on each change.
7
8
  import { Worker } from 'node:worker_threads';
8
- import { watchOwnerHealth } from './owner-health.mjs';
9
9
  import { watchWorkflows } from './workflow.mjs';
10
10
 
11
11
  const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` });
@@ -14,17 +14,24 @@ const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: $
14
14
  * Serve the owner's socket for the home at `root` (the caller holds its lock and has removed any old socket): resolves
15
15
  * once the socket is bound. `answer(line)` answers a request here; `machine` is this machine's name as the owner learned
16
16
  * it. A door that ends while the owner runs leaves the home with no door: the owner says so and exits, for its keeper to
17
- * start it again. Answers the door's close.
17
+ * start it again. Answers an idempotent close that joins the door worker's exit.
18
18
  */
19
19
  export async function openOwnerDoor(root, { machine, answer, log, readListings = true }) {
20
20
  const worker = new Worker(new URL('./owner-door-worker.mjs', import.meta.url), { name: 'supercode board door', workerData: { root, machine, readListings } });
21
+ observeWorker(worker,'worker');
21
22
  let open = false, closing = false;
22
23
  const send = (frame) => { try { worker.postMessage(frame); } catch (error) { log(`board door: could not hand the door ${frame.type}: ${error.message}`); } };
23
24
  const stops = [
24
- watchOwnerHealth(({ boards, round }) => send({ type: 'health', boards, round })),
25
25
  watchWorkflows((at, { workflow, params, profiles }) => send({ type: 'workflow', root: at, entry: { workflow, params, profiles } })),
26
26
  ];
27
- const close = () => { closing = true; for (const stop of stops) stop(); worker.terminate().catch(() => { /* ended */ }); };
27
+ let closingPromise;
28
+ const close = () => closingPromise ??= (async () => {
29
+ closing = true;
30
+ const errors = [];
31
+ for (const stop of stops) { try { stop(); } catch (error) { errors.push(error); } }
32
+ try { await stopWorkChild(worker, () => worker.terminate(), { exitCode: 1 }); } catch (error) { errors.push(error); }
33
+ if (errors.length) throw new AggregateError(errors, 'board door shutdown failed', { cause: errors[0] });
34
+ })();
28
35
  worker.on('message', (frame) => {
29
36
  if (frame?.type !== 'request') return;
30
37
  // one answer per request, whatever it did: a request that fails is a failed answer, never the owner's end
@@ -34,7 +41,10 @@ export async function openOwnerDoor(root, { machine, answer, log, readListings =
34
41
  await new Promise((resolve, reject) => {
35
42
  const ended = (error) => {
36
43
  if (closing) return;
37
- if (!open) { close(); reject(error); return; }
44
+ if (!open) {
45
+ close().then(() => reject(error), cleanup => reject(new AggregateError([error, cleanup], 'board door startup and cleanup failed', { cause: error })));
46
+ return;
47
+ }
38
48
  log(`board door: ${error.message}; this home has no door now; stopping, for the keeper to start the owner again`);
39
49
  process.exit(1);
40
50
  };
@@ -1,36 +1,33 @@
1
1
  // What the board owner knows of its own dispatcher, held in its memory: each board's dispatching switch (loaded once
2
2
  // when the owner starts, then set by the owner's own switch writes) and its last completed round (set as each round
3
- // completes). A verb's notice is formatted from this; nothing is read per call. Outside the owner nothing is held.
3
+ // completes). Explicit diagnostics read this; nothing is read per ordinary call. Outside the owner nothing is held.
4
4
  const switches = new Map(); // board slug → { on, at, by, reason }
5
5
  let round = null; // { at, errors }
6
6
  let holding = false;
7
- // the owner's read door holds the same (owner-door.mjs), told of each change
8
- const watchers = new Set();
9
- const told = () => { for (const watcher of watchers) watcher({ boards: [...switches], round }); };
10
- /** `watcher` is told what the owner holds now and after each change; answers its stop. */
11
- export function watchOwnerHealth(watcher) { watchers.add(watcher); watcher({ boards: [...switches], round }); return () => watchers.delete(watcher); }
12
-
13
7
  /** The owner starts holding: each board's switch as stored, and the last round its predecessor recorded. */
14
8
  export function holdOwnerHealth(boards, lastRound) {
15
9
  holding = true;
16
10
  for (const [slug, state] of boards) switches.set(slug, state);
17
11
  round = lastRound;
18
- told();
19
12
  }
20
13
  /** The owner wrote a board's switch. */
21
- export function holdSwitch(slug, state) { if (holding) { switches.set(slug, state); told(); } }
14
+ export function holdSwitch(slug, state) { if (holding) switches.set(slug, state); }
22
15
  /** The owner completed a round. */
23
- export function holdRound(at, errors) { if (holding) { round = { at, errors }; told(); } }
16
+ export function holdRound(at, errors) { if (holding) round = { at, errors }; }
24
17
 
25
- /** The notice for a verb on `slug`: dispatching off (a state), or a stalled round and its standing errors (failures). */
26
- export function dispatcherNotice(slug) {
18
+ /** Board diagnostics from held dispatching state and the last completed round, separate from a command's outcome. */
19
+ export function dispatcherDiagnostics(slug) {
27
20
  if (!holding) return [];
28
21
  const state = switches.get(slug);
29
22
  if (!state) return [];
30
- if (!state.on) return [`board: dispatching is off on ${slug}${state.at ? ` since ${String(state.at).slice(0, 16)}Z` : ''}${state.by ? ` by ${state.by}` : ''}${state.reason ? ` (${state.reason})` : ''}; no round runs until \`supercode workflow dispatching on\``];
23
+ if (!state.on) return [{ kind: 'dispatching_off', severity: 'warning',
24
+ title: `dispatching is off on ${slug}${state.at ? ` since ${state.at}` : ''}${state.by ? ` by ${state.by}` : ''}${state.reason ? ` (${state.reason})` : ''}`,
25
+ data: state, actions: ['supercode workflow dispatching on'] }];
31
26
  if (!round) return [];
32
27
  return [
33
- ...(Date.now() - Date.parse(round.at) > 300_000 ? [`board: error: the dispatcher has completed no round since ${String(round.at).slice(0, 16)}Z; nothing starts, moves or is supervised`] : []),
34
- ...(round.errors ?? []).map((error) => `board: error: ${error}`),
28
+ ...(Date.now() - Date.parse(round.at) > 300_000 ? [{ kind: 'dispatcher_stalled', severity: 'error',
29
+ title: `the dispatcher has completed no round since ${round.at}`, data: { at: round.at }, actions: [] }] : []),
30
+ ...(round.errors ?? []).map((error) => ({ kind: 'dispatcher_round_error', severity: 'error',
31
+ title: `dispatcher round at ${round.at}: ${error}`, data: { at: round.at, error }, actions: [] })),
35
32
  ];
36
33
  }
@@ -0,0 +1,6 @@
1
+ // The board package's interface for the board owner's host (generation-owner.mjs, docs/adr/0250-layered-packages.md):
2
+ // the workflows a dispatcher generation reports, held by the owner for its door; this machine's name as the
3
+ // dispatcher knows it; and the command line's parse. The same bindings their modules export; nothing of its own.
4
+ export { holdWorkflow } from './workflow.mjs';
5
+ export { knowLocalMachine, localMachine } from './dispatch.mjs';
6
+ export { parse } from './args.mjs';
@@ -0,0 +1,50 @@
1
+ // Retained publications are already serialized by the admitted writer. Observing them must not open a card
2
+ // writer, migrate its layout, import documents or choose a codec interpretation in the follower process.
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { backingOf, backingPaths } from './store.mjs';
7
+
8
+ export function openPublicationReader(root, slug) {
9
+ const backing = backingOf(root, slug);
10
+ if (!backing) throw new Error(`no board ${slug} in ${root}`);
11
+ const paths = backingPaths(root, slug);
12
+ let db;
13
+ try {
14
+ if (backing === 'files') {
15
+ // The files writer replaces _board.json last. All three publication tables belong to this one atomic
16
+ // file, so their watermark and heads form one committed view without reading any card files.
17
+ const state = JSON.parse(readFileSync(join(paths.files, '_board.json'), 'utf8'));
18
+ if (state.format !== 'supercode-board-files' || state.format_version !== 1)
19
+ throw new Error(`board ${slug} has an unsupported files publication format`);
20
+ db = new DatabaseSync(':memory:');
21
+ const tables = {
22
+ supercode_publication_log: ['id', 'pruned_through'],
23
+ supercode_publications: ['sequence', 'id', 'kind', 'snapshot'],
24
+ supercode_publication_heads: ['resource_id', 'sequence'],
25
+ };
26
+ db.exec(`CREATE TABLE supercode_publication_log (id TEXT, pruned_through INTEGER);
27
+ CREATE TABLE supercode_publications (sequence INTEGER PRIMARY KEY, id TEXT, kind TEXT, snapshot TEXT);
28
+ CREATE TABLE supercode_publication_heads (resource_id TEXT PRIMARY KEY, sequence INTEGER);`);
29
+ for (const [name, columns] of Object.entries(tables)) {
30
+ const rows = state.tables?.[name];
31
+ if (!Array.isArray(rows)) throw new Error(`board ${slug} has no retained ${name}`);
32
+ const insert = db.prepare(`INSERT INTO ${name} (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`);
33
+ for (const row of rows) insert.run(...columns.map(column => row[column]));
34
+ }
35
+ } else {
36
+ const path = backing === 'ztrack' ? `${paths.ztrack}.board.db` : paths.sqlite;
37
+ db = new DatabaseSync(path, { readOnly: true });
38
+ db.exec('PRAGMA busy_timeout=8000');
39
+ }
40
+ return {
41
+ db,
42
+ tx(work) {
43
+ db.exec('BEGIN');
44
+ try { return work(db); }
45
+ finally { db.exec('ROLLBACK'); }
46
+ },
47
+ close: () => db.close(),
48
+ };
49
+ } catch (error) { db?.close(); throw error; }
50
+ }