@volter/supercode-orchestrator 0.5.204 → 0.5.206

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 (51) hide show
  1. package/README.md +138 -4
  2. package/activation.mjs +93 -2
  3. package/adapters/rh2.mjs +7 -2
  4. package/agents.mjs +24 -2
  5. package/apply/doors.mjs +25 -2
  6. package/bin/agent-channel.mjs +41 -22
  7. package/bin/connection-runner.mjs +141 -0
  8. package/bin/orchestrator.mjs +11 -2
  9. package/bin/runtime.mjs +16 -9
  10. package/board/args.mjs +3 -2
  11. package/board/attachment-read.mjs +77 -0
  12. package/board/call.mjs +4 -2
  13. package/board/capacity.mjs +46 -9
  14. package/board/ci.mjs +157 -53
  15. package/board/cli.mjs +225 -87
  16. package/board/client.mjs +18 -2
  17. package/board/custody.mjs +109 -0
  18. package/board/dispatch.mjs +297 -75
  19. package/board/doors.mjs +47 -17
  20. package/board/engine.mjs +78 -24
  21. package/board/fleet-listeners.mjs +268 -0
  22. package/board/fleet-observer.mjs +79 -0
  23. package/board/fleet-targets.mjs +82 -0
  24. package/board/incidents.mjs +7 -0
  25. package/board/listing.mjs +5 -0
  26. package/board/machine-fleet.mjs +7 -0
  27. package/board/notice-receipts.mjs +67 -0
  28. package/board/owner-door-worker.mjs +61 -0
  29. package/board/owner-door.mjs +46 -0
  30. package/board/owner-health.mjs +8 -2
  31. package/board/published.mjs +2 -2
  32. package/board/record-view.mjs +2 -0
  33. package/board/store.mjs +55 -3
  34. package/board/tools.mjs +11 -4
  35. package/board/workflow.mjs +91 -28
  36. package/board/ztrack.mjs +87 -21
  37. package/board-input.d.ts +2 -0
  38. package/board-input.mjs +16 -0
  39. package/connection.mjs +153 -0
  40. package/index.d.ts +7 -5
  41. package/index.mjs +1 -1
  42. package/installed-apps.mjs +31 -20
  43. package/loop.mjs +91 -36
  44. package/mcp/bridge.mjs +12 -0
  45. package/mcp/hermes-kanban-tools.json +22 -14
  46. package/mcp/mount.mjs +10 -9
  47. package/ontology.d.ts +37 -0
  48. package/operator.mjs +72 -4
  49. package/orchestration.mjs +7 -2
  50. package/package.json +9 -5
  51. package/reducer.mjs +74 -5
@@ -0,0 +1,79 @@
1
+ // A board owner's read side over admitted, committed machine records. The machine emits facts; this home supplies
2
+ // role intent and its own card associations. No actor identity or authority is inferred from an observed payload.
3
+ import { listBoards, openBoard, view } from './store.mjs';
4
+
5
+ /** Associations in this home's current runs, observed when a notice is retained. Other homes' cards are unknown. */
6
+ export function fleetCards(root, addresses) {
7
+ const cards = [];
8
+ for (const slug of listBoards(root)) {
9
+ const board = openBoard(root, slug);
10
+ try {
11
+ for (const address of addresses) {
12
+ const [, machine, harness, sessionId] = address.split(':');
13
+ const rows = view(board, db => db.prepare(`SELECT t.id,r.id AS run FROM tasks t JOIN task_runs r ON r.id=t.current_run_id
14
+ WHERE json_extract(r.metadata,'$.supercode.machine')=? AND json_extract(r.metadata,'$.supercode.harness')=?
15
+ AND json_extract(r.metadata,'$.supercode.session_id')=? LIMIT 65`).all(machine, harness, sessionId));
16
+ if (rows.length > 64 || cards.length + rows.length > 256) throw new Error('fleet card associations exceed the retained notice bound');
17
+ cards.push(...rows.map(row => ({ board: slug, card: row.id, run: row.run, address })));
18
+ }
19
+ } finally { board.close(); }
20
+ }
21
+ return cards;
22
+ }
23
+
24
+ export function enrichFleetEvent(root, event) {
25
+ if (event.card_associations) return event;
26
+ const addresses = [...new Set([event.session?.address, event.address, ...(event.sessions ?? []).map(row => row.address)].filter(Boolean))];
27
+ if (!addresses.length) return event;
28
+ let cards = null, unread = null;
29
+ try { cards = fleetCards(root, addresses); } catch (error) { unread = String(error.message).slice(0, 300); }
30
+ return { ...event, card_associations: { observed_at_ms: Date.now(), basis: 'current native session bindings in this owner home', cards, ...(unread ? { unread } : {}), other_homes: 'unknown' } };
31
+ }
32
+
33
+ export function observeRoleFleet(root, router, records, log = () => {}, { waiting = true } = {}) {
34
+ if (!router.enabled) return;
35
+ for (const [machine, held] of Object.entries(records ?? {})) {
36
+ if (!['self', 'open'].includes(held.stream) || !held.record) continue;
37
+ const record = held.record;
38
+ for (const [section, prefix, kinds] of [
39
+ ['fleet', 'machine-fleet', ['session_started', 'session_resumed', 'session_ended', 'duplicate_session_process']],
40
+ ['health_events', 'machine-health', ['machine_health_crit']],
41
+ ]) if (record[section] && !record.unread?.[section]) {
42
+ try {
43
+ const source = record[section];
44
+ if (typeof source.source !== 'string' || !source.source.startsWith(`${prefix}:${machine}:`)) throw new Error('fleet source is not scoped to its admitted machine and grant section');
45
+ if (source.events.some(event => event.machine !== machine || !kinds.includes(event.kind))) throw new Error('fleet event differs from its admitted machine or grant section');
46
+ router.follow(source);
47
+ } catch (error) { log(`fleet listener source ${machine}: ${String(error.message).slice(0, 300)}`); }
48
+ }
49
+ if (!waiting) continue;
50
+ // The existing pane activity grant is still required (operator plus whole-machine view on a peer). A lost index,
51
+ // unread activity or absent record never clears a wait. Waiting on a live native session is not inferred from idle.
52
+ if (record.unread?.panes || record.unread?.sessions_index || record.unread?.sessions_index_parts
53
+ || record.unread?.sessions_activity || record.unread?.claude_registry_watch || record.panes?.activities_omitted || record.panes?.sessions_unread) continue;
54
+ const grouped = new Map();
55
+ for (const { pid, activity } of record.panes?.activities ?? []) {
56
+ if (activity?.presence !== 'running' || !activity.harness || !activity.session_id
57
+ || !(record.panes.panes ?? []).some(pane => pane.pid === pid && !pane.dead)) continue;
58
+ const address = `sc:${machine}:${activity.harness}:${activity.session_id}`;
59
+ const rows = grouped.get(address) ?? []; rows.push(activity); grouped.set(address, rows);
60
+ }
61
+ for (const [address, activities] of grouped) {
62
+ try {
63
+ const asked = activities.find(activity => activity.turn === 'needs_input');
64
+ if (!asked) {
65
+ if (activities.every(activity => ['idle', 'working'].includes(activity.turn))) router.cardWait(address, null);
66
+ continue;
67
+ }
68
+ const cards = fleetCards(root, [address]);
69
+ if (!cards.length) continue;
70
+ const evidence = asked.evidence ?? {};
71
+ router.cardWait(address, { kind: 'card_waiting_on_user', machine,
72
+ observed_at_ms: Number.isSafeInteger(evidence.observed_at_ms) ? evidence.observed_at_ms : record.at,
73
+ address, harness: asked.harness, session_id: asked.session_id, waiting_for: evidence.waiting_for ?? null,
74
+ since_ms: evidence.native_state_since_ms ?? null, native_state: evidence.native_state ?? null, native_source: evidence.source ?? null,
75
+ card_associations: { observed_at_ms: Date.now(), basis: 'current native session bindings in this owner home', cards, other_homes: 'unknown' } });
76
+ } catch (error) { log(`fleet listener card wait ${address}: ${String(error.message).slice(0, 300)}`); }
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,82 @@
1
+ // The machine universe must be complete before a fleet reading can decide presence.
2
+ // Bound candidates before applying live/platform predicates: an all-inactive board still yields.
3
+ import { setImmediate as nextTurn } from 'node:timers/promises';
4
+ import { listBoards, openBoard } from './store.mjs';
5
+ import { LIST_BATCH, TASK_SUMMARY_INDEX, listRevision } from './listing.mjs';
6
+
7
+ const sameRevision = (a, b) => Object.keys(a).every(key => a[key] === b[key]);
8
+ const rowPage = (db, table, fields, ceiling, after) => db.prepare(`SELECT rowid AS candidate_id, ${fields} FROM ${table}
9
+ WHERE rowid <= ?${after === null ? '' : ' AND rowid > ?'} ORDER BY rowid LIMIT ${LIST_BATCH}`)
10
+ .all(ceiling, ...(after === null ? [] : [after]));
11
+
12
+ async function addBoardMachines(board, machines, normalise, unchanged) {
13
+ const db = board.db;
14
+ // Card rowids are indexed even in a TEMP document overlay, where supercode_cards has no task-id index.
15
+ // Tasks have the listing's covering ID index in both main and TEMP projections.
16
+ const ceiling = db.prepare('SELECT MAX(rowid) AS id FROM supercode_cards').get().id;
17
+ const liveCards = new Set();
18
+ let cursor = null;
19
+ while (ceiling !== null && (cursor === null || cursor < ceiling)) {
20
+ if (!unchanged()) return false;
21
+ const rows = rowPage(db, 'supercode_cards', 'task_id, machine, NOT COALESCE(archived, 0) AS visible', ceiling, cursor);
22
+ if (!rows.length) break;
23
+ const active = new Set(db.prepare(`SELECT id FROM tasks INDEXED BY ${TASK_SUMMARY_INDEX}
24
+ WHERE id IN (SELECT value FROM json_each(?)) AND status NOT IN ('done', 'cancelled', 'archived')`)
25
+ .all(JSON.stringify(rows.map(row => row.task_id))).map(row => row.id));
26
+ for (const row of rows) {
27
+ if (!row.visible || !active.has(row.task_id)) continue;
28
+ liveCards.add(row.task_id);
29
+ if (row.machine !== null && row.machine !== '') machines.add(normalise(row.machine));
30
+ }
31
+ cursor = rows.at(-1).candidate_id;
32
+ await nextTurn();
33
+ }
34
+ if (!unchanged()) return false;
35
+ // Subscriptions have an ordinary SQLite rowid in every board backing. Page all rows before filtering,
36
+ // rather than paging matching subscriptions with an unbounded search through nonmatching rows.
37
+ const last = db.prepare('SELECT MAX(rowid) AS id FROM kanban_notify_subs').get().id;
38
+ let after = null;
39
+ while (last !== null && (after === null || after < last)) {
40
+ if (!unchanged()) return false;
41
+ const rows = rowPage(db, 'kanban_notify_subs', "task_id, chat_id, platform = 'supercode' AND chat_id LIKE 'sc:%' AS supercode_address", last, after);
42
+ if (!rows.length) break;
43
+ for (const row of rows) {
44
+ if (liveCards.has(row.task_id) && row.supercode_address) machines.add(normalise(row.chat_id.split(':')[1]));
45
+ }
46
+ after = rows.at(-1).candidate_id;
47
+ await nextTurn();
48
+ }
49
+ return unchanged();
50
+ }
51
+
52
+ /** A stable union across all boards; no read transaction or native cursor survives a yield. */
53
+ export async function homeFleetMachines(root, normalise) {
54
+ if (!root) return new Set();
55
+ try {
56
+ for (let attempt = 0; attempt < 3; attempt++) {
57
+ const slugs = listBoards(root).sort(), opened = [];
58
+ try {
59
+ for (const slug of slugs) {
60
+ const board = openBoard(root, slug);
61
+ const held = { board };
62
+ opened.push(held);
63
+ held.revision = listRevision(board.db);
64
+ }
65
+ const unchanged = () => opened.every(({ board, revision }) => sameRevision(revision, listRevision(board.db)));
66
+ const machines = new Set();
67
+ let stable = true;
68
+ for (const { board } of opened) {
69
+ if (!await addBoardMachines(board, machines, normalise, unchanged)) { stable = false; break; }
70
+ await nextTurn();
71
+ }
72
+ if (stable && unchanged() && JSON.stringify(slugs) === JSON.stringify(listBoards(root).sort())) return machines;
73
+ } finally { for (const { board } of opened) board.close(); }
74
+ await nextTurn();
75
+ }
76
+ throw new Error('The home changed during its fleet-target reading; retry the reading.');
77
+ } catch (error) {
78
+ // Do not pass an incomplete machine set to readMachineFleet, or publish presence from it.
79
+ error.fleetTargetsUnread = true;
80
+ throw error;
81
+ }
82
+ }
@@ -1,5 +1,6 @@
1
1
  // Service supervision retains evidence; only a manager/creator act releases dispatch.
2
2
  import { event, json, now } from './store.mjs';
3
+ import { detachedCard, detachedSession } from './custody.mjs';
3
4
  import { statusOf, creatorOf, requestSessionClose } from './workflow.mjs';
4
5
 
5
6
  export const recoversRuns = entry => entry?.params?.run_failure_policy === 'recover';
@@ -19,6 +20,7 @@ export function retainRecovery(db, id, name, evidence = {}, runId = null) {
19
20
  if (!row || row.ended_at != null) return;
20
21
  if (db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='run_incident'").get(id, runId)) return;
21
22
  const session = json(row.metadata, {})?.supercode ?? {};
23
+ if (detachedCard(db, id) || detachedSession(db, session)) return;
22
24
  const reason = evidence.error ?? `run ${runId} needs continuation (${name})`;
23
25
  const payload = { observation: name, run: runId, reason, policy: 'recover',
24
26
  session: session.session_id ?? null, machine: session.machine ?? null, pane: session.pane ?? null,
@@ -35,6 +37,7 @@ export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId
35
37
  const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(runId);
36
38
  if (!row || row.task_id !== id) return false;
37
39
  const meta = json(row.metadata, {}) ?? {}, session = meta.supercode ?? {};
40
+ if (detachedCard(db, id) || detachedSession(db, session)) return false;
38
41
  const prior = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='run_incident' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
39
42
  const continued = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='session_continued' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
40
43
  if (prior && !(continued?.id > prior.id)) return false;
@@ -69,6 +72,7 @@ export function auditEndedRuns(db, board, entry) {
69
72
  WHERE r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id) AND r.ended_at IS NOT NULL
70
73
  AND t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived')`).all()) {
71
74
  const metadata = json(row.metadata, {}), session = metadata.supercode;
75
+ if (detachedSession(db, session)) continue;
72
76
  if (session?.mode !== 'resume' || session.seen_at || session.resume_failed_before_live) continue;
73
77
  const exit = db.prepare("SELECT 1 FROM task_events WHERE run_id=? AND kind='run_incident' AND json_extract(payload,'$.observation')='exited' AND json_extract(payload,'$.evidence.code')=1").get(row.id);
74
78
  if (!exit) continue;
@@ -80,6 +84,7 @@ export function auditEndedRuns(db, board, entry) {
80
84
  for (const task of db.prepare(`SELECT t.id,c.paused FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
81
85
  WHERE t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived') AND c.paused IS NOT NULL AND NOT COALESCE(c.archived,0)`).all()) {
82
86
  const pause = json(task.paused, null);
87
+ if (detachedCard(db, task.id)) continue;
83
88
  const last = db.prepare("SELECT kind,payload FROM task_events WHERE task_id=? AND kind IN ('closed_by_hand','paused','resumed') ORDER BY id DESC LIMIT 1").get(task.id);
84
89
  const ended = json(last?.payload, {});
85
90
  // The machine may have moved since this run. Match the retained board
@@ -95,6 +100,7 @@ export function auditEndedRuns(db, board, entry) {
95
100
  ON r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id)
96
101
  WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status IN ('running','review')`).all()) {
97
102
  const session = json(task.run_metadata, {})?.supercode;
103
+ if (detachedCard(db, task.id) || detachedSession(db, session)) continue;
98
104
  if (!session?.monitor_held) continue;
99
105
  const state = db.prepare('SELECT paused,blocked_by,archived FROM supercode_cards WHERE task_id=?').get(task.id);
100
106
  if (json(state?.paused, null) || json(state?.blocked_by, [])?.length || state?.archived) continue;
@@ -110,6 +116,7 @@ export function auditEndedRuns(db, board, entry) {
110
116
  for (const task of db.prepare(`SELECT t.*,r.id run_id,r.outcome,r.error,r.metadata run_metadata FROM tasks t JOIN task_runs r ON r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id)
111
117
  WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status NOT IN ('done','archived')`).all()) {
112
118
  const session = json(task.run_metadata, {})?.supercode;
119
+ if (detachedCard(db, task.id) || detachedSession(db, session)) continue;
113
120
  const status = statusOf(db, task);
114
121
  if (session?.status && session.status !== status) continue; // a card transition intentionally advances its work
115
122
  const incident = db.prepare("SELECT id FROM task_events WHERE task_id=? AND run_id=? AND kind='run_incident'").get(task.id, task.run_id);
package/board/listing.mjs CHANGED
@@ -5,6 +5,11 @@ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
5
5
  import { executionOf } from './session-work.mjs';
6
6
 
7
7
  export const LIST_BATCH = 64;
8
+ // A listing on the owner's read door (owner-door.mjs) that can read no committed state without waiting on a writer (a
9
+ // backing with no commit point a reader can take, a store not in WAL, SQLite's own contention) is answered by the
10
+ // owner, as every other verb is.
11
+ export const READ_BY_OWNER = 'board_read_by_owner';
12
+ export const byOwner = (reason) => Object.assign(new Error(`this listing is the board owner's to answer: ${reason}`), { code: READ_BY_OWNER });
8
13
  export const LIST_CONNECTION = Symbol.for('supercode.board.listing.connection');
9
14
  export const LIST_REVISION = Symbol.for('supercode.board.listing.revision');
10
15
  const parse = (text, fallback) => { try { return JSON.parse(text) ?? fallback; } catch { return fallback; } };
@@ -25,11 +25,14 @@ const paneFacts = (held) => {
25
25
  ]);
26
26
  };
27
27
  const paneListeners = new Set();
28
+ const recordListeners = new Set();
29
+ recordView.onUnavailable = () => { for (const listener of recordListeners) { try { listener({}); } catch { /* cursor retained for a current reading */ } } };
28
30
  let factsHeld = new Map();
29
31
  recordView.onApplied = (records) => {
30
32
  const next = new Map(Object.entries(records).map(([machine, held]) => [machine, paneFacts(held)]));
31
33
  const changed = [...next].filter(([machine, facts]) => factsHeld.has(machine) && factsHeld.get(machine) !== facts).map(([machine]) => machine);
32
34
  factsHeld = next;
35
+ for (const listener of recordListeners) { try { listener(records); } catch { /* its retained source cursor is retried */ } }
33
36
  if (changed.length) for (const listener of paneListeners) { try { listener(changed); } catch { /* a listener cannot stop the record's reading */ } }
34
37
  };
35
38
  /**
@@ -38,6 +41,10 @@ recordView.onApplied = (records) => {
38
41
  * opened by a round's first reading and held from then on.
39
42
  */
40
43
  export function onPanesChanged(listener) { paneListeners.add(listener); return () => paneListeners.delete(listener); }
44
+ /** The admitted, committed records, including native turn/health changes which need not change a pane. */
45
+ export function onRecordsChanged(listener) { recordListeners.add(listener); return () => recordListeners.delete(listener); }
46
+ export async function readFleetRecords() { return recordView.read(DEADLINE_MS); }
47
+ export function fleetRecordsCurrent() { return Boolean(recordView.ready && recordView.stream && Date.now() - recordView.heardAt <= 45_000); }
41
48
  const DEADLINE_MS = 10_000;
42
49
  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?.())]);
43
50
  // One machine's reading, from its held record: its panes, what a board launch needs of it, and the session under each
@@ -0,0 +1,67 @@
1
+ // Locate filed/bounced notices outside the writer, without an unscoped JSON payload scan per expiry.
2
+ import { setImmediate as nextTurn } from 'node:timers/promises';
3
+ import { listRevision, LIST_BATCH } from './listing.mjs';
4
+
5
+ const FILED = new Set(['notice_filed', 'session_notice_filed', 'actor_notice_filed']);
6
+ const KINDS = "('notice_filed', 'session_notice_filed', 'actor_notice_filed', 'notice_bounced')";
7
+ const same = (a, b, ownWrites = false) => Object.keys(a).every(key => (ownWrites && key === 'changes') || a[key] === b[key]);
8
+ export const noticeKey = (taskId, messageId) => JSON.stringify([taskId, messageId]);
9
+ const unread = () => Object.assign(new Error('Notice receipts changed during their reading; bounced notices will be retried next round.'), { code: 'board_notice_receipts_unread' });
10
+
11
+ /** Check inside the write before any mutation. After our own synchronous write, only total_changes may differ. */
12
+ export function requireNoticeBasis(board, revision, { ownWrites = false } = {}) {
13
+ const current = listRevision(board.db);
14
+ if (!same(revision, current, ownWrites)) throw unread();
15
+ return current;
16
+ }
17
+
18
+ /** Backing write preparation can clear TEMP bookkeeping or import card fields before the callback.
19
+ * Notice events are immutable log entries: a new filing/bounce must advance their primary-key ceiling.
20
+ * Check that ceiling in the transaction, and revalidate each selected filing by ID before using it. */
21
+ export function noticeWriterBasis(board, revision, ceiling) {
22
+ const basis = listRevision(board.db);
23
+ if (['connection', 'data', 'mainSchema'].some(key => basis[key] !== revision[key])
24
+ || board.db.prepare('SELECT MAX(id) AS id FROM task_events').get().id !== ceiling) throw unread();
25
+ return basis;
26
+ }
27
+
28
+ export async function noticeReceiptsInTurns(board, messageIds) {
29
+ const wanted = new Set(messageIds);
30
+ for (let attempt = 0; attempt < 3; attempt++) {
31
+ const revision = listRevision(board.db);
32
+ const found = await readReceipts(board, wanted, revision);
33
+ if (found !== null && same(revision, listRevision(board.db))) return { ...found, revision };
34
+ await nextTurn();
35
+ }
36
+ throw unread();
37
+ }
38
+
39
+ async function readReceipts(board, wanted, revision) {
40
+ const db = board.db, filed = new Map(), bounced = new Set();
41
+ const ceiling = db.prepare('SELECT MAX(id) AS id FROM task_events').get().id;
42
+ let before = null;
43
+ while (ceiling !== null) {
44
+ if (!same(revision, listRevision(db))) return null;
45
+ // Bound every candidate, including unrelated event kinds. Return bodies only for notice receipts;
46
+ // filtering kinds or JSON in WHERE could scan unbounded history to find one matching page.
47
+ const rows = db.prepare(`SELECT id, task_id, kind, CASE WHEN kind IN ${KINDS} THEN payload END AS payload
48
+ FROM task_events WHERE id <= ?${before === null ? '' : ' AND id < ?'} ORDER BY id DESC LIMIT ${LIST_BATCH}`)
49
+ .all(ceiling, ...(before === null ? [] : [before]));
50
+ for (const row of rows) {
51
+ if (!FILED.has(row.kind) && row.kind !== 'notice_bounced') continue;
52
+ let payload;
53
+ try { payload = JSON.parse(row.payload ?? 'null') ?? {}; }
54
+ catch { throw new Error(`Notice receipt payload unread at event ${row.id}; bounced notices will be retried next round.`); }
55
+ const id = payload.message_id;
56
+ if (!wanted.has(id)) continue;
57
+ if (row.kind === 'notice_bounced') bounced.add(noticeKey(row.task_id, id));
58
+ else if (!filed.has(id)) filed.set(id, row); // descending IDs: preserve the latest filing
59
+ }
60
+ if (rows.length < LIST_BATCH) break;
61
+ // All requested messages have both their latest filing and an existing bounce: no older row can undo them.
62
+ if (filed.size === wanted.size && [...filed].every(([id, row]) => bounced.has(noticeKey(row.task_id, id)))) break;
63
+ before = rows.at(-1).id;
64
+ await nextTurn();
65
+ }
66
+ return { filed, bounced, ceiling };
67
+ }
@@ -0,0 +1,61 @@
1
+ // The thread that serves the board owner's socket (owner-door.mjs). A listing is answered here, read on a connection of
2
+ // its own; every other request is handed to the owner's thread and its answer written back.
3
+ import { parentPort, workerData } from 'node:worker_threads';
4
+ import { createServer } from 'node:net';
5
+ import { chmodSync } from 'node:fs';
6
+ import { boardOwnerSocket } from '@volter/teams/board-owner';
7
+ import { parse } from './args.mjs';
8
+ import { answerOne } from './cli.mjs';
9
+ import { knowLocalMachine } from './dispatch.mjs';
10
+ import { holdOwnerHealth } from './owner-health.mjs';
11
+ import { ownHome } from './owner-files.mjs';
12
+ import { writeOwnerAnswer } from './reply.mjs';
13
+ import { holdWorkflow } from './workflow.mjs';
14
+
15
+ const { root, machine } = workerData;
16
+ const waiting = new Map(); // request id → its answer's resolve
17
+ let next = 0;
18
+ parentPort.on('message', (frame) => {
19
+ if (frame.type === 'health') holdOwnerHealth(frame.boards, frame.round);
20
+ else if (frame.type === 'workflow') holdWorkflow(frame.root, frame.entry);
21
+ else if (frame.type === 'answer') { waiting.get(frame.id)?.(frame.answer); waiting.delete(frame.id); }
22
+ });
23
+ await knowLocalMachine(machine);
24
+ ownHome(root);
25
+
26
+ const byOwner = (line) => new Promise((resolve) => { const id = ++next; waiting.set(id, resolve); parentPort.postMessage({ type: 'request', id, line }); });
27
+ const isListing = (line) => {
28
+ try {
29
+ const request = JSON.parse(line);
30
+ return !request?.tool && Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') && ['list', 'ls'].includes(parse(request.argv)._[0]);
31
+ } catch { return false; } // the owner answers what it cannot read
32
+ };
33
+ const answer = async (line) => {
34
+ if (!isListing(line)) return byOwner(line);
35
+ const read = await answerOne(root, line, { readOnly: true });
36
+ return read.byOwner ? byOwner(line) : read;
37
+ };
38
+
39
+ // allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
40
+ const server = createServer({ allowHalfOpen: true }, (socket) => {
41
+ let input = '';
42
+ socket.setEncoding('utf8');
43
+ socket.on('error', () => { /* the caller went away */ });
44
+ socket.on('data', (chunk) => {
45
+ if (input === null) return;
46
+ input += chunk;
47
+ const end = input.indexOf('\n');
48
+ if (end < 0) return;
49
+ const line = input.slice(0, end);
50
+ input = null;
51
+ // one answer per call, whatever the request did: a request that fails is a failed answer, never the door's end
52
+ answer(line)
53
+ .catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
54
+ .then((reply) => writeOwnerAnswer(socket, reply))
55
+ .catch(() => { socket.destroy(); }); // a disconnected caller leaves no queued reply
56
+ });
57
+ });
58
+ const path = boardOwnerSocket(root);
59
+ await new Promise((done, fail) => { server.once('error', fail); server.listen(path, () => { server.off('error', fail); done(); }); });
60
+ try { chmodSync(path, 0o600); } catch { /* the home is the user's own */ }
61
+ parentPort.postMessage({ type: 'ready' });
@@ -0,0 +1,46 @@
1
+ // The board owner's socket, served on a thread of its own (owner-door-worker.mjs), so a caller is heard while the
2
+ // 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
+ // one SQLite read transaction (store.mjs openBoard, readOnly): WAL serves it beside the owner's writes, and neither
4
+ // 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.
7
+ import { Worker } from 'node:worker_threads';
8
+ import { watchOwnerHealth } from './owner-health.mjs';
9
+ import { watchWorkflows } from './workflow.mjs';
10
+
11
+ const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` });
12
+
13
+ /**
14
+ * Serve the owner's socket for the home at `root` (the caller holds its lock and has removed any old socket): resolves
15
+ * once the socket is bound. `answer(line)` answers a request here; `machine` is this machine's name as the owner learned
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.
18
+ */
19
+ export async function openOwnerDoor(root, { machine, answer, log }) {
20
+ const worker = new Worker(new URL('./owner-door-worker.mjs', import.meta.url), { name: 'supercode board door', workerData: { root, machine } });
21
+ let open = false, closing = false;
22
+ const send = (frame) => { try { worker.postMessage(frame); } catch (error) { log(`board door: could not hand the door ${frame.type}: ${error.message}`); } };
23
+ const stops = [
24
+ watchOwnerHealth(({ boards, round }) => send({ type: 'health', boards, round })),
25
+ watchWorkflows((at, { workflow, params, profiles }) => send({ type: 'workflow', root: at, entry: { workflow, params, profiles } })),
26
+ ];
27
+ const close = () => { closing = true; for (const stop of stops) stop(); worker.terminate().catch(() => { /* ended */ }); };
28
+ worker.on('message', (frame) => {
29
+ if (frame?.type !== 'request') return;
30
+ // one answer per request, whatever it did: a request that fails is a failed answer, never the owner's end
31
+ Promise.resolve().then(() => answer(frame.line)).catch(failed)
32
+ .then((result) => { if (!closing) send({ type: 'answer', id: frame.id, answer: result }); });
33
+ });
34
+ await new Promise((resolve, reject) => {
35
+ const ended = (error) => {
36
+ if (closing) return;
37
+ if (!open) { close(); reject(error); return; }
38
+ log(`board door: ${error.message}; this home has no door now; stopping, for the keeper to start the owner again`);
39
+ process.exit(1);
40
+ };
41
+ worker.on('message', (frame) => { if (frame?.type === 'ready') { open = true; resolve(); } });
42
+ worker.on('error', ended);
43
+ worker.on('exit', (code) => ended(new Error(`the door's thread ended (${code})`)));
44
+ });
45
+ return close;
46
+ }
@@ -4,17 +4,23 @@
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); }
7
12
 
8
13
  /** The owner starts holding: each board's switch as stored, and the last round its predecessor recorded. */
9
14
  export function holdOwnerHealth(boards, lastRound) {
10
15
  holding = true;
11
16
  for (const [slug, state] of boards) switches.set(slug, state);
12
17
  round = lastRound;
18
+ told();
13
19
  }
14
20
  /** The owner wrote a board's switch. */
15
- export function holdSwitch(slug, state) { if (holding) switches.set(slug, state); }
21
+ export function holdSwitch(slug, state) { if (holding) { switches.set(slug, state); told(); } }
16
22
  /** The owner completed a round. */
17
- export function holdRound(at, errors) { if (holding) round = { at, errors }; }
23
+ export function holdRound(at, errors) { if (holding) { round = { at, errors }; told(); } }
18
24
 
19
25
  /** The notice for a verb on `slug`: dispatching off (a state), or a stalled round and its standing errors (failures). */
20
26
  export function dispatcherNotice(slug) {
@@ -177,7 +177,7 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
177
177
  const runs=rows.attempts(row.id);
178
178
  const attempts=runs.map(run=>{
179
179
  const native=parse(run.metadata,{})?.supercode??null;
180
- const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
180
+ const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,detached_at:native.detached_at??null,detached_to:native.detached_to??null,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
181
181
  const metadata=parse(run.metadata,{});delete metadata.supercode;
182
182
  return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:executionOf(run),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
183
183
  });
@@ -186,7 +186,7 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
186
186
  execution:executionOf(runs.find(run=>run.id===row.current_run_id)),
187
187
  priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
188
188
  tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),sponsor:own.sponsor??null,sponsor_evidence:parse(own.sponsor_evidence),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived:!!own.archived,
189
- blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),every:own.every??null,
189
+ blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),detached:parse(own.detached),every:own.every??null,
190
190
  workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
191
191
  parents:rows.parents(row.id),
192
192
  agents:rows.agents(row.id),reviews,children:rows.children(row.id),comments,attempts};
@@ -62,9 +62,11 @@ export class RecordView {
62
62
  this.progressTimer.unref?.();
63
63
  }
64
64
  fail(error) {
65
+ this.ready = false;
65
66
  this.failure = error;
66
67
  for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(error); }
67
68
  this.waiters.clear();
69
+ try { this.onUnavailable?.(error); } catch { /* unavailable never becomes a fresh observation */ }
68
70
  }
69
71
  own(key) { return key.role === 'producer' ? key.machine === this.self : key.role === 'holder' && key.machine !== this.self; }
70
72
  apply(frame) {
package/board/store.mjs CHANGED
@@ -19,7 +19,7 @@ import { hostname } from 'node:os';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { DatabaseSync } from 'node:sqlite';
21
21
  import { openZtrackBoard } from './ztrack.mjs';
22
- import { LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
22
+ import { byOwner, LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
23
23
  import { isFilesBoard, openFilesBoard } from './files.mjs';
24
24
  import { signalBoardWrite } from './wake.mjs';
25
25
  import { PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
@@ -76,14 +76,22 @@ CREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id);
76
76
  // supercode's part of a card, beside Hermes's row: where its session runs, and the arc a subtask belongs to (a
77
77
  // subtask is worked by its arc's session and never started on its own).
78
78
  const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT);
79
- CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
79
+ CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);
80
+ CREATE TABLE IF NOT EXISTS supercode_session_custody (
81
+ address TEXT PRIMARY KEY, task_id TEXT NOT NULL, person TEXT NOT NULL,
82
+ detached_at INTEGER NOT NULL, detached_by TEXT NOT NULL, run_id INTEGER NOT NULL
83
+ );
84
+ CREATE TABLE IF NOT EXISTS supercode_session_operations (
85
+ token TEXT PRIMARY KEY, card_ref TEXT, address TEXT, kind TEXT NOT NULL,
86
+ owner TEXT NOT NULL, started_at INTEGER NOT NULL
87
+ );`;
80
88
  // `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
81
89
  // `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
82
90
  // `tags`: the card's ordered tags (Hermes's `tenant` holds the first); `sponsor`, `sponsor_evidence`, `requested_by_unverified`: C4
83
91
  // An effect's attempts: how many were made, when the next may be, and, once its attempts are spent, when it failed and
84
92
  // why (engine.mjs runEffects). A failed effect is kept as the record of its terminal state and never tried again.
85
93
  const EFFECT_COLUMNS = { attempts: 'INTEGER NOT NULL DEFAULT 0', next_at: 'INTEGER NOT NULL DEFAULT 0', failed_at: 'INTEGER', last_error: 'TEXT' };
86
- const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', sponsor: 'TEXT', sponsor_evidence: 'TEXT', requested_by_unverified: 'TEXT' };
94
+ const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', detached: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', sponsor: 'TEXT', sponsor_evidence: 'TEXT', requested_by_unverified: 'TEXT' };
87
95
 
88
96
  // ------------------------------------------------------------------ backings
89
97
  //
@@ -151,6 +159,18 @@ const RUN_SESSION = "json_extract(metadata, '$.supercode.session_id')";
151
159
  const RUN_ADDRESS = "json_extract(metadata, '$.supercode.address')";
152
160
  const RUN_LAUNCH_PENDING = "json_extract(metadata, '$.supercode.launch_pending')";
153
161
  export const CALLER_RUN_ADDRESS = `SELECT ${RUN_ADDRESS} AS address FROM task_runs WHERE ${RUN_SESSION} = ? ORDER BY id DESC LIMIT 1`;
162
+ // Launch/adoption commits the native address and its immutable role-agent together.
163
+ // Pending/unissued launches are not an ownership receipt. Read only that identity,
164
+ // never the current assignee, a profile hint, or mutable manager/admission state.
165
+ export const CALLER_RUN_AGENT = `SELECT DISTINCT
166
+ json_extract(metadata, '$.supercode.agent_id') AS agent_id,
167
+ json_extract(metadata, '$.supercode.agent_org') AS org
168
+ FROM task_runs WHERE ${RUN_SESSION} = ? AND ${RUN_ADDRESS} = ?
169
+ AND COALESCE(${RUN_LAUNCH_PENDING}, 0) = 0
170
+ AND COALESCE(json_extract(metadata, '$.supercode.launch_deferred'), 0) = 0
171
+ AND json_extract(metadata, '$.supercode.mode') IN ('fresh', 'resume', 'adopt')
172
+ AND json_extract(metadata, '$.supercode.agent_id') IS NOT NULL
173
+ AND json_extract(metadata, '$.supercode.agent_org') IS NOT NULL`;
154
174
  const RUN_SESSION_INDEX = 'idx_runs_supercode_session';
155
175
  const RUN_LAUNCH_PENDING_INDEX = 'idx_runs_supercode_launch_pending';
156
176
  // The indexes on a run's metadata. The second holds only the runs whose launch is pending (7 of 1,387 on the store
@@ -371,6 +391,8 @@ export function heldConfigWatched(slug, source, watched) {
371
391
  }
372
392
 
373
393
  export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
394
+ // a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
395
+ if (current().readOnly) return openReadOnly(root, slug);
374
396
  if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
375
397
  let entry = heldBoards.get(slug);
376
398
  if (!entry) {
@@ -400,6 +422,33 @@ function heldHandle(entry, slug) {
400
422
  close() { if (closed) return; closed = true; entry.uses -= 1; if (entry.retired && !entry.uses) try { entry.board.close(); } catch { /* closed */ } } };
401
423
  }
402
424
 
425
+ /**
426
+ * Board `slug` as one committed state, for a listing on the owner's read door: a connection of its own in one SQLite
427
+ * read transaction (WAL's snapshot, beside any writer, with no busy timeout), kept until `close`. It makes, lays out,
428
+ * recovers and writes nothing; a board it cannot read so (a `files` board, whose card files have no commit point a
429
+ * reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
430
+ * (listing.mjs byOwner).
431
+ */
432
+ function openReadOnly(root, slug) {
433
+ const use = backingOf(root, slug);
434
+ if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
435
+ const path = backingPaths(root, slug)[use];
436
+ if (use === 'ztrack') return { ...openZtrackBoard(path, { init: layOut, root, slug, readOnly: true }), root, slug, path, backing: use };
437
+ if (use === 'files') throw byOwner(`board ${slug} is kept in files`);
438
+ let db;
439
+ try {
440
+ db = new DatabaseSync(path, { readOnly: true });
441
+ if (String(db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() !== 'wal') throw byOwner(`${path} is not in WAL mode`);
442
+ db.exec('BEGIN');
443
+ // the transaction's first read begins its snapshot
444
+ if (!indexed(db, TASK_SUMMARY_INDEX)) throw byOwner(`${path} is not laid out yet`);
445
+ } catch (error) {
446
+ db?.close();
447
+ throw unreadable(error) ? byOwner(error.message) : error;
448
+ }
449
+ return { db, root, slug, path, backing: use, tx: () => { throw new Error('a listing reads the board and writes nothing'); }, close: () => db.close() };
450
+ }
451
+
403
452
  function openBoardNow(root, slug, { create = false, backing = null, document = null, declared = null, held = false } = {}) {
404
453
  noteOrigin(root);
405
454
  const kept = backingOf(root, slug);
@@ -477,6 +526,9 @@ export function publishMarked(root) {
477
526
  export const contention = (error) => [5, 6].includes(Number(error?.errcode) & 255)
478
527
  || ['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(error?.code)
479
528
  || /^database (?:table |schema )?is locked$/i.test(error?.message ?? '');
529
+ // What keeps a read-only connection from beginning its read where the owner's own would not: contention, or a store it
530
+ // may not open or read as it stands (SQLite CANTOPEN, READONLY). The listing is then the owner's (openReadOnly).
531
+ export const unreadable = (error) => contention(error) || [8, 14].includes(Number(error?.errcode) & 255);
480
532
  // The store's volume refusing a write (SQLite IOERR or FULL and their extended codes; ENOSPC or EIO from a file write):
481
533
  // a fault of the machine's storage at that minute, never of the card or the operation that met it. A transaction it
482
534
  // rolled back is tried again a bounded number of times (STORAGE_TRIES); one still refused fails cleanly, and its caller