@volter/supercode-orchestrator 0.5.215 → 0.5.216

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.
@@ -578,10 +578,16 @@ async function supervise(board, fleet, settings, report, entry, effects) {
578
578
  const row = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row;
579
579
  native = { turn: row?.activity?.turn };
580
580
  } catch { /* an unread turn stays unknown */ }
581
- const reading = { alive: alive ?? null, turn: alive === true ? native?.turn ?? null : null, observed_at: t };
581
+ // the session's machine as this round read it, so the card can say why a session reads unknown (executionOf)
582
+ // (a round that read no fleet, on a board with no pane lane, says nothing of any machine)
583
+ const where = session.surface !== 'headless' && session.machine && fleet.errors instanceof Map
584
+ ? fleet.online.has(session.machine) ? { machine_reading: 'online' }
585
+ : { machine_reading: fleet.offline?.has(normalMachine(session.machine)) ? 'offline' : 'unknown', machine_error: String(fleet.errors.get(normalMachine(session.machine)) ?? fleet.error ?? '').slice(0, 300) || null }
586
+ : {};
587
+ const reading = { alive: alive ?? null, turn: alive === true ? native?.turn ?? null : null, observed_at: t, ...where };
582
588
  const prior = session.presence_observation;
583
589
  // Keep state changes immediately and renew unchanged readings once a minute.
584
- if (!prior || prior.alive !== reading.alive || prior.turn !== reading.turn || t - prior.observed_at >= 60) {
590
+ if (!prior || prior.alive !== reading.alive || prior.turn !== reading.turn || prior.machine_reading !== reading.machine_reading || t - prior.observed_at >= 60) {
585
591
  setRunSession(db, runRow.id, { presence_observation: reading });
586
592
  session = { ...session, presence_observation: reading };
587
593
  }
@@ -3592,6 +3598,8 @@ export { boardLabel };
3592
3598
  // board, card start, board message or notice. What it leaves unfiled is not a failure; the next dispatcher files it.
3593
3599
  let stopping = false;
3594
3600
  export function stopDispatching() { stopping = true; }
3601
+ // Only the stable owner's parked generation is resumed; it takes a fresh fencing epoch first.
3602
+ export function resumeDispatching() { stopping = false; }
3595
3603
 
3596
3604
  /** A board file's fingerprint: a corrupt board is skipped until the file changes (Hermes retries it then). */
3597
3605
  const fingerprint = (path) => { try { const st = statSync(path); return `${st.size}:${st.mtimeMs}`; } catch { return 'missing'; } };
@@ -3754,7 +3762,8 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3754
3762
  stalls.push({ id, key: `no_work_branch:${lastReviewEvent(board, id)}`, why: `its review waits: ${payload.waits}` });
3755
3763
  payload = null; continue;
3756
3764
  }
3757
- if (!payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
3765
+ // A PR of the arc proposed to a branch other than main is refused below, whatever the others' verdicts.
3766
+ if (!payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
3758
3767
  const why = `every open PR head (${payload.pr.join(', ')}) has a verdict, carries the source tree its verdict judged, or predates it; push a commit that changes the source, or close the tasks that own the last verdict's findings`;
3759
3768
  report.queued.push([id, `review waits: ${why}`]);
3760
3769
  // After a pass the arc's own session merges and asks for done; any other verdict nothing answered waits on an act
@@ -3772,12 +3781,20 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3772
3781
  continue;
3773
3782
  }
3774
3783
  // A stable PR-property or local refusal must not spend a full fresh REST scan every round. Advisory
3775
- // discovery is sufficient to refuse, never to authorize; the scan below still owns that.
3776
- if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
3784
+ // discovery is sufficient to refuse, never to authorize; the scan below still owns that. Nor is it enough to
3785
+ // tell: its PR heads, bases and states come from the shared discovery cache (up to five minutes old), so a push
3786
+ // or retarget since may already have cleared what it refuses. It refuses without the fresh scan only when it is
3787
+ // the wait already told; a new one goes on to the fresh read below, which tells it only if it still stands.
3788
+ if (!payload.no_code) {
3789
+ try { await openReviewPullRequests(board, id, { checkWorkspaces: true }); }
3790
+ catch (error) {
3791
+ if (error?.code !== 'review_refused' || view(board, (db) => toldReviewWait(db, id)) === refusalKey(board, id, error)) throw error;
3792
+ }
3793
+ }
3777
3794
  if (!dry) await refreshCardInputs(board, id, { commits: true });
3778
3795
  // Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
3779
3796
  const observed = await withFreshReviewReads(async () => {
3780
- const candidate = await openReviewPullRequests(board, id, { fresh: true, repositories: payload.review_repositories ?? [] })
3797
+ const candidate = await openReviewPullRequests(board, id, { fresh: true, refuseStacked: true, repositories: payload.review_repositories ?? [] })
3781
3798
  ?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id, { fresh: true }) : null);
3782
3799
  const evidence = !candidate || candidate.waits ? null : candidate.no_code
3783
3800
  ? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
@@ -3787,7 +3804,7 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3787
3804
  payload = observed.candidate;
3788
3805
  if (!payload || payload.waits) { report.queued.push([id, `review waits: ${payload?.waits ?? 'the current source set has no review candidate'}`]); payload = null; continue; }
3789
3806
  const evidence = observed.evidence;
3790
- if (!evidence.ready) throw new Error(evidence.reason);
3807
+ if (!evidence.ready) throw Object.assign(new Error(evidence.reason), { code: evidence.code, refusals: evidence.refusals });
3791
3808
  // A dry run names the complete validated review a tick would request and writes nothing.
3792
3809
  if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
3793
3810
  const moved = tx(board, db => {
@@ -3804,7 +3821,14 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3804
3821
  effects.push(...moved.effects);
3805
3822
  (report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
3806
3823
  }
3807
- } catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
3824
+ } catch (error) {
3825
+ report.queued.push([id, `PR review waits${payload?.pr?.length ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]);
3826
+ // A refusal only the arc's act clears (a PR proposed to a branch other than main, a draft, an unpushed or
3827
+ // uncommitted checkout) is a review wait like any other: every task is closed, so no session of the arc is told
3828
+ // by its tasks, and a refusal only logged left VO-3602 t_918cc86e unreviewed for its whole idle session. Its key
3829
+ // is its refused PRs and kinds alone (refusalKey); the detail is in `why`.
3830
+ if (error?.code === 'review_refused') stalls.push({ id, key: refusalKey(board, id, error), why: `its review waits: ${error.message} Every task is closed, so no session of the arc is due to act on it.` });
3831
+ }
3808
3832
  }
3809
3833
  if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
3810
3834
  }
@@ -3813,6 +3837,18 @@ export async function activatePullRequestReviews(board, entry, report, effects,
3813
3837
  * carries it, so the wait a new verdict or request leaves is a new wait. */
3814
3838
  const lastReviewEvent = (board, id) => board.db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided')").get(id)?.id ?? 0;
3815
3839
 
3840
+ /** A review refusal's wait key: the arc's last review event and the sorted kinds and PRs it refused (ci.mjs refusalOf),
3841
+ * never a count or a path, so the same refusal is the same wait however its detail or read order changes. */
3842
+ const refusalKey = (board, id, error) => `refused:${lastReviewEvent(board, id)}:${(error.refusals ?? []).map((r) => `${r.kind}:${r.url}`).join(' ')}`;
3843
+
3844
+ /** The key of the card's told review wait not cleared since, or null. */
3845
+ function toldReviewWait(db, id) {
3846
+ const last = db.prepare("SELECT id, json_extract(payload, '$.review_wait') AS key FROM task_events WHERE task_id = ? AND kind = 'board_message' AND json_extract(payload, '$.review_wait') IS NOT NULL ORDER BY id DESC LIMIT 1").get(id);
3847
+ if (!last) return null;
3848
+ const cleared = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_wait_cleared'").get(id)?.id ?? 0;
3849
+ return cleared > last.id ? null : last.key;
3850
+ }
3851
+
3816
3852
  /** At most this many review waits are told per round; the rest are told in the rounds after, in candidate order. */
3817
3853
  const REVIEW_WAITS_TOLD_PER_ROUND = 8;
3818
3854
  /**
@@ -3832,13 +3868,7 @@ const REVIEW_WAITS_TOLD_PER_ROUND = 8;
3832
3868
  function surfaceReviewStalls(board, entry, stalls, settled, report) {
3833
3869
  const to = [].concat(entry.params.managers ?? []).find(Boolean);
3834
3870
  if (!to || (!stalls.length && !settled.length)) return;
3835
- // the key of the card's told review wait not cleared since, or null
3836
- const told = (db, id) => {
3837
- const last = db.prepare("SELECT id, json_extract(payload, '$.review_wait') AS key FROM task_events WHERE task_id = ? AND kind = 'board_message' AND json_extract(payload, '$.review_wait') IS NOT NULL ORDER BY id DESC LIMIT 1").get(id);
3838
- if (!last) return null;
3839
- const cleared = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_wait_cleared'").get(id)?.id ?? 0;
3840
- return cleared > last.id ? null : last.key;
3841
- };
3871
+ const told = toldReviewWait;
3842
3872
  // read first: a round with nothing to tell or clear opens no write
3843
3873
  const plan = view(board, (db) => ({
3844
3874
  clear: settled.filter((id) => told(db, id)),
package/board/engine.mjs CHANGED
@@ -10,7 +10,7 @@ import { allocateReadableId } from './readable-id.mjs';
10
10
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
11
11
  import { current } from './call.mjs';
12
12
  import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
13
- import { executionOf } from './session-work.mjs';
13
+ import { executionOf, readingContext } from './session-work.mjs';
14
14
  import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
15
15
  import { spawn, execFile } from 'node:child_process';
16
16
  import { existsSync, rmSync } from 'node:fs';
@@ -927,7 +927,7 @@ export function listCards(db, { status = null, assignee = null, machine = null,
927
927
  const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
928
928
  WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
929
929
  ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
930
- return rows.map((r) => cardState({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }, { nativeHolds }));
930
+ return rows.map((r) => cardState({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT id,metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null, readingContext(db, r.id, r.current_run_id)) }, { nativeHolds }));
931
931
  }
932
932
 
933
933
  export function showCard(db, id, options = {}) {
package/board/fence.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  import { renameSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { boardHome } from '@volter/teams/board-owner';
12
+ import { assertGenerationWriter } from './generation.mjs';
12
13
 
13
14
  let mine = null;
14
15
  const STATE = 'CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)';
@@ -32,6 +33,7 @@ export function takeEpoch(root, boards) {
32
33
 
33
34
  /** At a write transaction's start: in the owner, store its epoch unless a newer one is stored, and exit when it is. */
34
35
  export function fenced(db) {
36
+ assertGenerationWriter();
35
37
  if (mine === null) return;
36
38
  db.exec(STATE);
37
39
  const r = db.prepare(`INSERT INTO supercode_board_state (key, value) VALUES ('owner_epoch', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value
@@ -0,0 +1,293 @@
1
+ // The stable board owner: one OS lock and public door, with replaceable, fenced dispatcher generations behind it.
2
+ // A request already sent to a generation is never replayed. Only unsent requests wait at the handover boundary.
3
+ import { readSourceCandidate } from '@volter/supercode-teams/source-selection';
4
+ import { sourceLockPath } from '@volter/supercode-teams/source-lock';
5
+ import { spawn } from 'node:child_process';
6
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { boardHome, boardOwnerSocket } from '@volter/teams/board-owner';
11
+ import { retainedReceipt } from '@volter/supercode-teams/release-selection';
12
+ import { sourceTransaction } from '@volter/supercode-teams/serving-source-transaction';
13
+ import { holdLock } from './lock-holder.mjs';
14
+ import { openOwnerDoor } from './owner-door.mjs';
15
+ import { holdOwnerHealth } from './owner-health.mjs';
16
+ import { holdWorkflow } from './workflow.mjs';
17
+ import { knowLocalMachine, localMachine } from './dispatch.mjs';
18
+ import { parse } from './args.mjs';
19
+
20
+ const unknown = () => ({ code: 1, unanswered: true, stdout: '', stderr: 'the board generation ended after taking this request; its outcome is unknown\n' });
21
+ const failed = error => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error.message}\n` });
22
+
23
+ export async function serveBoardOwner(root, args) {
24
+ root = boardHome(root);
25
+ mkdirSync(root, { recursive: true });
26
+ const log = line => process.stdout.write(`${new Date().toISOString()} board owner: ${line}\n`);
27
+ let stopping = false, active = null, previous = null, transition = null, routing = null, pending = null, recoveryFailure = null;
28
+ const generations = new Set(), waiting = new Set();
29
+ const recordPath = join(root, 'board-generation.json');
30
+ const record = child => {
31
+ const temporary = `${recordPath}.${randomUUID()}.tmp`;
32
+ writeFileSync(temporary, JSON.stringify({ version: 1, owner: process.pid, active: child?.process.pid ?? null,
33
+ selected: child?.selected ?? null, generation: child?.id ?? null, previous: previous?.process.pid ?? null, at_ms: Date.now() }) + '\n', { mode: 0o600 });
34
+ renameSync(temporary, recordPath);
35
+ };
36
+ const changed = () => { for (const done of waiting) done(); waiting.clear(); };
37
+ const lock = await holdLock(join(root, 'board.lock'), { onLost: error => { log(error.message); process.exit(1); } });
38
+ await knowLocalMachine();
39
+ const entry = resolve(process.env.SUPERCODE_ORCHESTRATOR_ENTRY || fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url)));
40
+ const start = selected => {
41
+ const generationId = randomUUID();
42
+ const env = { ...process.env, SUPERCODE_BOARD_GENERATION_OWNER: String(process.pid), SUPERCODE_BOARD_GENERATION_ID: generationId,
43
+ SUPERCODE_BOARD_GENERATION_MACHINE: localMachine(), SUPERCODE_ORCHESTRATOR_ENTRY: selected.entry, SUPERCODE_BIN: selected.native };
44
+ delete env.SUPERCODE_SOURCE_CANDIDATE; delete env.SUPERCODE_RELEASE_RECEIPT; delete env.SUPERCODE_RELEASE_VERSION;
45
+ if (selected.candidate) env.SUPERCODE_SOURCE_CANDIDATE = selected.candidate;
46
+ if (selected.release) env.SUPERCODE_RELEASE_RECEIPT = selected.release;
47
+ const runtime = selected.candidate ? readSourceCandidate(selected.candidate, sourceLockPath()).node : process.execPath;
48
+ env.SUPERCODE_NODE_BIN = runtime;
49
+ const processChild = spawn(runtime, [selected.entry, 'workflow', 'serve', '--root', root,
50
+ ...(args.interval ? ['--interval', String(args.interval)] : []), ...(args.max ? ['--max', String(args.max)] : []),
51
+ ...(args['failure-limit'] ? ['--failure-limit', String(args['failure-limit'])] : []), ...(args.verbose ? ['--verbose'] : [])], {
52
+ stdio: ['ignore', 'inherit', 'inherit', 'ipc'], serialization: 'advanced',
53
+ env,
54
+ });
55
+ const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false, health: null, workflows: new Map() };
56
+ generation.exited = new Promise(done => { processChild.once('exit', done); processChild.once('error', done); });
57
+ generations.add(generation);
58
+ const tell = frame => { if (processChild.connected) processChild.send(frame); else throw new Error('board generation disconnected'); };
59
+ generation.tell = tell;
60
+ generation.wait = (event, ms = 8000) => new Promise((done, reject) => {
61
+ if (generation.ended) return reject(new Error('board generation ended'));
62
+ if (generation.events.has(event)) return done(generation.events.get(event));
63
+ const waiter = { done, reject, timer: null }, expires = Date.now() + 90000;
64
+ waiter.arm = budget => {
65
+ clearTimeout(waiter.timer);
66
+ waiter.timer = setTimeout(() => { generation.events.delete(event); reject(new Error(`board generation did not report ${event}; last preparation step: ${generation.startupStep ?? 'entry loading'}; predecessor retained`)); }, Math.min(budget, Math.max(1, expires - Date.now())));
67
+ };
68
+ waiter.arm(ms); generation.events.set(event, waiter);
69
+ });
70
+ generation.ask = (line, timeout = null) => new Promise(done => {
71
+ const id = randomUUID();
72
+ let timer;
73
+ const finish = answer => { clearTimeout(timer); generation.calls.delete(id); done(answer); };
74
+ if (timeout) timer = setTimeout(() => finish(unknown()), timeout);
75
+ generation.calls.set(id, finish);
76
+ try { tell({ type: 'request', id, line }); }
77
+ catch { generation.calls.delete(id); done(unknown()); }
78
+ });
79
+ const ended = () => {
80
+ if (generation.ended) return;
81
+ generation.ended = true;
82
+ // IPC loss revokes the child but does not prove its process ended. End that exact owned child before recovery.
83
+ if (processChild.exitCode === null && processChild.signalCode === null) {
84
+ processChild.kill('SIGTERM');
85
+ const bound = setTimeout(() => processChild.kill('SIGKILL'), 2000);
86
+ generation.exited.finally(() => clearTimeout(bound));
87
+ }
88
+ generations.delete(generation);
89
+ for (const done of generation.calls.values()) done(unknown());
90
+ generation.calls.clear();
91
+ for (const value of generation.events.values()) if (value.reject) { clearTimeout(value.timer); value.reject(new Error('board generation ended')); }
92
+ generation.events.clear();
93
+ changed();
94
+ if (!stopping && routing === generation) {
95
+ routing = null;
96
+ // Only a parked predecessor can resume. A failed first generation leaves the public door up and names why.
97
+ if (pending) recoveryFailure = new Error('prepared board generation ended; the coordinating transaction must roll back');
98
+ if (!transition && !pending) {
99
+ transition = (async () => {
100
+ await generation.exited;
101
+ const replacement = start(generation.selected);
102
+ await replacement.wait('prepared');
103
+ return activate(replacement);
104
+ })().catch(error => { recoveryFailure = error; log(`generation recovery refused: ${error.message}`); }).finally(() => { transition = null; changed(); });
105
+ }
106
+ }
107
+ };
108
+ processChild.on('message', frame => {
109
+ if (!frame || typeof frame.type !== 'string') return;
110
+ if (frame.type === 'startup-step') {
111
+ generation.startupStep = frame.step;
112
+ if (Number.isSafeInteger(frame.budget_ms) && frame.budget_ms >= 1 && frame.budget_ms <= 65000)
113
+ generation.events.get(frame.phase === 'serving' ? 'serving' : 'prepared')?.arm?.(frame.budget_ms);
114
+ return;
115
+ }
116
+ if (frame.type === 'answer') { generation.calls.get(frame.id)?.(frame.answer); generation.calls.delete(frame.id); changed(); return; }
117
+ if (frame.type === 'health') { generation.health = frame; if (routing === generation) holdOwnerHealth(frame.boards, frame.round); return; }
118
+ if (frame.type === 'workflow') { generation.workflows.set(frame.root, frame.entry); if (routing === generation) holdWorkflow(frame.root, frame.entry); return; }
119
+ const waiter = generation.events.get(frame.type);
120
+ if (waiter?.done) { clearTimeout(waiter.timer); waiter.done(frame); }
121
+ generation.events.set(frame.type, frame);
122
+ });
123
+ processChild.once('error', ended); processChild.once('exit', ended); processChild.once('disconnect', ended);
124
+ return generation;
125
+ };
126
+ const terminate = async generation => {
127
+ generation.process.kill('SIGTERM');
128
+ const timer = setTimeout(() => generation.process.kill('SIGKILL'), 2000);
129
+ try { await generation.exited; } finally { clearTimeout(timer); }
130
+ };
131
+ const activate = async generation => {
132
+ if (stopping || generation.ended) throw new Error('cannot activate an ended board generation');
133
+ record(generation);
134
+ generation.events.delete('serving');
135
+ generation.tell({ type: 'activate' });
136
+ const serving = await generation.wait('serving');
137
+ const read = async argv => {
138
+ const proof = await generation.ask(JSON.stringify({ v: 1, argv, cwd: root,
139
+ facts: { SUPERCODE_CONTEXT: process.env.SUPERCODE_CONTEXT || 'local' } }), 8000);
140
+ if (proof.code !== 0 || proof.unanswered) throw new Error(`successor ${argv[0]} readback failed: ${Array.isArray(proof.stderr) ? proof.stderr.join('') : proof.stderr || 'answer unknown'}; predecessor retained`);
141
+ return JSON.parse(Array.isArray(proof.stdout) ? proof.stdout.join('') : proof.stdout);
142
+ };
143
+ // A newly rendered home can have no initialized board, or several named boards. Read the actual inventory
144
+ // before asking each store for stats; an assumed default would refuse an otherwise serving empty home.
145
+ const boards = serving.boards;
146
+ if (!Array.isArray(boards)) throw new Error('successor board inventory was unreadable; predecessor retained');
147
+ await read(['declaration', '--json']);
148
+ for (const slug of boards) {
149
+ if (typeof slug !== 'string') throw new Error('successor board inventory has no slug; predecessor retained');
150
+ await read(['stats', '--json', '--board', slug]);
151
+ }
152
+ active = generation; routing = generation; recoveryFailure = null;
153
+ if (generation.health) holdOwnerHealth(generation.health.boards, generation.health.round);
154
+ for (const [at, value] of generation.workflows) holdWorkflow(at, value);
155
+ changed();
156
+ log(`generation ${generation.id} serves (pid ${generation.process.pid})`);
157
+ return generation;
158
+ };
159
+ const status = () => ({ version: 1, protocol: 1, owner: process.pid, state: routing ? 'serving' : 'transitioning',
160
+ generation: active?.id, pid: active?.process.pid, selected: active?.selected, previous: previous?.selected ?? null,
161
+ transaction: pending?.id ?? null, phase: pending?.phase ?? null, failure: recoveryFailure?.message ?? null });
162
+ const prepare = async (id, selected) => {
163
+ if (pending) {
164
+ if (pending.id === id) {
165
+ if (JSON.stringify(pending.candidate.selected) !== JSON.stringify(selected)) throw new Error('board transaction already names different inputs');
166
+ return status();
167
+ }
168
+ throw new Error('another board generation is prepared');
169
+ }
170
+ if (!active || active.ended) throw new Error('no serving predecessor can hand over this board');
171
+ sourceTransaction(id, selected, active.selected, 'prepare', process.env, 'board');
172
+ const candidate = start(selected);
173
+ try {
174
+ const proof = await candidate.wait('prepared'), held = active.events.get('prepared');
175
+ if (!proof.storage || proof.storage !== held?.storage) throw new Error('board storage contract differs; predecessor retained without opening a writer');
176
+ pending = { id, candidate, predecessor: active, older: previous, phase: 'prepared' };
177
+ return status();
178
+ } catch (error) { await terminate(candidate); throw error; }
179
+ };
180
+ const rollback = async () => {
181
+ const held = pending;
182
+ if (!held) return status();
183
+ await terminate(held.candidate);
184
+ previous = held.older;
185
+ if (held.phase !== 'prepared') {
186
+ routing = null;
187
+ // A timed-out park may still be executing. End that exact child before a fresh predecessor takes an epoch;
188
+ // an activate sent to a child still parking can otherwise race its final writer revocation.
189
+ await terminate(held.predecessor);
190
+ const replacement = start(held.predecessor.selected);
191
+ try { await replacement.wait('prepared'); await activate(replacement); }
192
+ catch (error) { await terminate(replacement); throw error; }
193
+ } else if (!held.predecessor.ended) { active = routing = held.predecessor; changed(); }
194
+ pending = null; record(active); return status();
195
+ };
196
+ const switchPrepared = async () => {
197
+ const held = pending;
198
+ if (held.phase === 'activated' && routing === held.candidate) return status();
199
+ if (held.phase !== 'prepared' || active !== held.predecessor) throw new Error('board preparation is no longer current');
200
+ try {
201
+ const requireCandidate = () => {
202
+ if (held.candidate.ended || !held.candidate.process.connected)
203
+ throw new Error('prepared board candidate ended before activation; predecessor retained');
204
+ };
205
+ requireCandidate();
206
+ routing = null;
207
+ let drainTimer;
208
+ try { await Promise.race([new Promise(done => {
209
+ const inspect = () => { if (!held.predecessor.calls.size || held.predecessor.ended) done(); else waiting.add(inspect); }; inspect();
210
+ }), new Promise((_, reject) => { drainTimer = setTimeout(() => reject(new Error('board requests did not drain; predecessor retained')), 8000); })]); }
211
+ finally { clearTimeout(drainTimer); }
212
+ requireCandidate();
213
+ held.phase = 'parking';
214
+ held.predecessor.events.delete('parked'); held.predecessor.tell({ type: 'park' });
215
+ await held.predecessor.wait('parked'); previous = held.predecessor;
216
+ await activate(held.candidate); held.phase = 'activated'; return status();
217
+ } catch (error) { await rollback(); throw error; }
218
+ };
219
+ const control = async parsed => {
220
+ const action = parsed.action ?? 'replace', id = parsed.transaction;
221
+ if (action === 'status') return status();
222
+ if (typeof id !== 'string' || !id) throw new Error('board handover needs --transaction');
223
+ if (action === 'prepare' || action === 'replace') {
224
+ if (!parsed.entry || !parsed.supercode) throw new Error('handover needs --entry and --supercode from one prepared machine release');
225
+ await prepare(id, { entry: resolve(parsed.entry), native: resolve(parsed.supercode), candidate: parsed.candidate ? resolve(parsed.candidate) : null, release: parsed.release ? resolve(parsed.release) : null });
226
+ } else if (pending?.id !== id) throw new Error('this board holds no matching handover transaction');
227
+ if (pending) sourceTransaction(id, pending.candidate.selected, pending.predecessor.selected, action === 'replace' ? 'activate' : action, process.env, 'board');
228
+ if (action === 'activate' || action === 'replace') await switchPrepared();
229
+ if (action === 'rollback' || action === 'abort') return rollback();
230
+ if (action === 'finish' || action === 'replace') {
231
+ sourceTransaction(id, pending.candidate.selected, pending.predecessor.selected, 'finish', process.env, 'board');
232
+ if (pending.phase !== 'activated') throw new Error('board successor was never activated');
233
+ if (pending.candidate.ended) {
234
+ await terminate(pending.candidate);
235
+ const replacement = start(pending.candidate.selected);
236
+ try { await replacement.wait('prepared'); await activate(replacement); pending.candidate = replacement; }
237
+ catch (error) { await terminate(replacement); throw error; }
238
+ }
239
+ if (routing !== pending.candidate || active.ended) throw new Error('board successor is not serving; handover cannot finish');
240
+ for (const retained of generations) if (retained !== active && retained !== previous) await terminate(retained);
241
+ pending = null; record(active);
242
+ } else if (!['prepare', 'activate'].includes(action)) throw new Error('unknown board handover action');
243
+ return status();
244
+ };
245
+ const answer = async line => {
246
+ let request;
247
+ try { request = JSON.parse(line); } catch { /* generation answers malformed input */ }
248
+ if (request?.v === 1 && Array.isArray(request.argv) && request.argv[0] === 'handover') {
249
+ const parsed = parse(request.argv);
250
+ if (parsed.action === 'status') return { code: 0, stdout: JSON.stringify(status()) + '\n', stderr: '' };
251
+ if (transition) return failed(new Error('a board handover is already in progress'));
252
+ transition = control(parsed);
253
+ try { return { code: 0, stdout: JSON.stringify(await transition) + '\n', stderr: '' }; }
254
+ catch (error) { return failed(error); }
255
+ finally { transition = null; changed(); }
256
+ }
257
+ while (!routing && !stopping) {
258
+ if (recoveryFailure && !transition) return failed(recoveryFailure);
259
+ if (active?.ended && (!previous || previous.ended) && !transition) return failed(new Error('board generation ended; no rollback generation is available'));
260
+ await new Promise(done => waiting.add(done));
261
+ }
262
+ return stopping ? failed(new Error('the board owner is stopping')) : routing.ask(line);
263
+ };
264
+ let closeDoor;
265
+ const stop = () => {
266
+ if (stopping) return;
267
+ stopping = true; changed();
268
+ for (const generation of generations) if (!generation.ended) generation.process.kill('SIGTERM');
269
+ };
270
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);
271
+ try {
272
+ // Only this lock's holder publishes/removes the public socket, for its entire lifetime.
273
+ rmSync(boardOwnerSocket(root), { force: true });
274
+ closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer, log, readListings: false });
275
+ const first = start({ entry, native: process.env.SUPERCODE_BIN, candidate: process.env.SUPERCODE_SOURCE_CANDIDATE || null, release: process.env.SUPERCODE_SOURCE_CANDIDATE ? null : retainedReceipt(entry) });
276
+ await first.wait('prepared');
277
+ await activate(first);
278
+ if (typeof process.send === 'function' && Number(process.env.SUPERCODE_BOARD_START_OWNER) === process.ppid)
279
+ process.send({ type: 'board-owner-ready', owner: process.pid });
280
+ if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
281
+ while (!stopping) await new Promise(done => waiting.add(done));
282
+ } finally {
283
+ stop();
284
+ await Promise.all([...generations].map(terminate));
285
+ closeDoor?.();
286
+ rmSync(boardOwnerSocket(root), { force: true });
287
+ try { if (JSON.parse(readFileSync(recordPath, 'utf8')).owner === process.pid) rmSync(recordPath); } catch { /* absent */ }
288
+ lock.release();
289
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, stop);
290
+ if (args.pidfile) rmSync(args.pidfile, { force: true });
291
+ }
292
+ return 0;
293
+ }
@@ -0,0 +1,49 @@
1
+ // A board generation's cooperative writer boundary. The stable owner holds board.lock and its public socket;
2
+ // only its active child may enter work. Parking drains admitted work before acknowledging loss of authority.
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+
5
+ export const inBoardGeneration = typeof process.send === 'function'
6
+ && Number(process.env.SUPERCODE_BOARD_GENERATION_OWNER) === process.ppid;
7
+ const nested = new AsyncLocalStorage();
8
+ let admitting = !inBoardGeneration, writing = !inBoardGeneration, running = 0, ended = false;
9
+ let waiters = [], drained = [];
10
+
11
+ export async function generationWork(work) {
12
+ if (!inBoardGeneration) return work();
13
+ while (!admitting && !nested.getStore()?.active) {
14
+ if (ended) throw new Error('the board generation stopped before this work began');
15
+ await new Promise((done, reject) => waiters.push({ done, reject }));
16
+ }
17
+ running++;
18
+ const scope = { active: true };
19
+ try { return await nested.run(scope, work); }
20
+ finally { scope.active = false; if (--running === 0) { for (const done of drained.splice(0)) done(); } }
21
+ }
22
+
23
+ export function assertGenerationWriter() {
24
+ if (inBoardGeneration && !writing) throw new Error('this board generation is parked; no write was made');
25
+ }
26
+
27
+ export function admitGeneration() {
28
+ writing = true; admitting = true;
29
+ for (const { done } of waiters.splice(0)) done();
30
+ }
31
+
32
+ export function grantGenerationWriter() { writing = true; }
33
+ export function stopGeneration() {
34
+ ended = true; admitting = false;
35
+ for (const { reject } of waiters.splice(0)) reject(new Error('the board generation is stopping'));
36
+ }
37
+
38
+ export async function parkGeneration(settle) {
39
+ admitting = false;
40
+ if (running) await new Promise(done => drained.push(done));
41
+ // Document settlement can enter ownCall itself. It is admitted as the final writer scope, never queued behind
42
+ // the admission boundary it is settling. Detached children of that scope must drain before authority is revoked.
43
+ const scope = { active: true };
44
+ try { await nested.run(scope, settle); } finally { scope.active = false; }
45
+ if (running) await new Promise(done => drained.push(done));
46
+ writing = false;
47
+ }
48
+
49
+ export const tellOwner = frame => { if (inBoardGeneration && process.connected) process.send(frame); };
package/board/listing.mjs CHANGED
@@ -54,8 +54,19 @@ export const TASK_SUMMARY_INDEX_SQL = `CREATE INDEX IF NOT EXISTS ${TASK_SUMMARY
54
54
  const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('supercode', json_object(
55
55
  'address',json_extract(r.metadata,'$.supercode.address'),'session_id',json_extract(r.metadata,'$.supercode.session_id'),
56
56
  'close_confirmed_at',json_extract(r.metadata,'$.supercode.close_confirmed_at'),'released',json_extract(r.metadata,'$.supercode.released'),
57
- 'presence_observation',json_extract(r.metadata,'$.supercode.presence_observation'),'idle_observation',json_extract(r.metadata,'$.supercode.idle_observation')
58
- )) END`;
57
+ 'presence_observation',json_extract(r.metadata,'$.supercode.presence_observation'),'idle_observation',json_extract(r.metadata,'$.supercode.idle_observation'),
58
+ 'machine',json_extract(r.metadata,'$.supercode.machine'),'offline_since',json_extract(r.metadata,'$.supercode.offline_since'),
59
+ 'idle_since',json_extract(r.metadata,'$.supercode.idle_since'),'unknown_since',json_extract(r.metadata,'$.supercode.unknown_since'),
60
+ 'question',json_extract(r.metadata,'$.supercode.question.question')
61
+ ), 'last_events', json_object('heartbeat', json_extract(r.metadata,'$.last_events.heartbeat'))) END`;
62
+ // What a working card's session reading needs beside its current run (session-work.mjs executionOf, readingContext reads
63
+ // 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.
65
+ const WORKING_LANE = "COALESCE(c.stage,t.status) IN ('running','review')";
66
+ 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
+ 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
+ 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`;
59
70
  export const LIST_CANDIDATES_SQL = `SELECT MAX(id) AS last, json_group_array(id) AS ids FROM (
60
71
  SELECT id FROM tasks WHERE id > ? AND id <= ? ORDER BY id LIMIT ${LIST_BATCH})`;
61
72
 
@@ -71,7 +82,8 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
71
82
  export function listPageSql(detailed, tagCount = 0, options = {}) {
72
83
  return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
73
84
  n.number,c.machine,c.stage,c.paused,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
74
- 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
85
+ 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
+ ${READING_CONTEXT}
75
87
  ${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,
76
88
  (${LAST_BLOCK_SQL}) AS block_payload` : ''}
77
89
  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
@@ -108,10 +120,10 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
108
120
  status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
109
121
  tenant, tenant, session, session, workflowTemplateId, workflowTemplateId, stepKey, stepKey, ...tags.flatMap(tag => [tag, tag, tag])));
110
122
  measured('row projection', () => {
111
- for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, parents_json, block_payload, ...task } of rows) {
123
+ 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) {
112
124
  const metadata = run_id ? parse(run_metadata, {}) : null;
113
125
  const run = run_id ? { id: run_id, status: run_status, started_at: run_started_at, ended_at: run_ended_at, metadata } : null;
114
- task.execution = executionOf(run);
126
+ task.execution = executionOf(run, { heartbeat: parse(heartbeat_event, null), last: parse(last_run, null) });
115
127
  if (detailed) {
116
128
  task.skills = parse(task.skills, []);
117
129
  task.workflow_status = task.stage ?? task.status;
@@ -12,18 +12,24 @@ import { ownHome } from './owner-files.mjs';
12
12
  import { writeOwnerAnswer } from './reply.mjs';
13
13
  import { holdWorkflow } from './workflow.mjs';
14
14
 
15
- const { root, machine } = workerData;
15
+ const { root, machine, readListings = true } = workerData;
16
16
  const waiting = new Map(); // request id → its answer's resolve
17
- let next = 0;
17
+ const MAX_REQUEST = 8 * 1024 * 1024, MAX_PENDING = 64 * 1024 * 1024;
18
+ let next = 0, pendingBytes = 0, inputBytes = 0, connections = 0;
19
+ const refused = message => ({ code: 1, stdout: '', stderr: `supercode workflow: ${message}; no operation was submitted\n` });
18
20
  parentPort.on('message', (frame) => {
19
21
  if (frame.type === 'health') holdOwnerHealth(frame.boards, frame.round);
20
22
  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); }
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); } }
22
24
  });
23
25
  await knowLocalMachine(machine);
24
26
  ownHome(root);
25
27
 
26
- const byOwner = (line) => new Promise((resolve) => { const id = ++next; waiting.set(id, resolve); parentPort.postMessage({ type: 'request', id, line }); });
28
+ const byOwner = line => {
29
+ const bytes = Buffer.byteLength(line);
30
+ if (waiting.size >= 256 || pendingBytes + inputBytes + bytes > MAX_PENDING) return Promise.resolve(refused('the owner request queue reached its bound'));
31
+ return new Promise(resolve => { const id = ++next; pendingBytes += bytes; waiting.set(id, { resolve, bytes }); parentPort.postMessage({ type: 'request', id, line }); });
32
+ };
27
33
  const isListing = (line) => {
28
34
  try {
29
35
  const request = JSON.parse(line);
@@ -31,23 +37,32 @@ const isListing = (line) => {
31
37
  } catch { return false; } // the owner answers what it cannot read
32
38
  };
33
39
  const answer = async (line) => {
34
- if (!isListing(line)) return byOwner(line);
40
+ if (!readListings || !isListing(line)) return byOwner(line);
35
41
  const read = await answerOne(root, line, { readOnly: true });
36
42
  return read.byOwner ? byOwner(line) : read;
37
43
  };
38
44
 
39
45
  // allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
40
46
  const server = createServer({ allowHalfOpen: true }, (socket) => {
41
- let input = '';
47
+ if (connections >= 256) { socket.destroy(); return; }
48
+ connections++;
49
+ let input = '', heldBytes = 0;
50
+ const release = () => { inputBytes -= heldBytes; heldBytes = 0; };
51
+ socket.once('close', () => { release(); connections--; });
42
52
  socket.setEncoding('utf8');
43
53
  socket.on('error', () => { /* the caller went away */ });
44
54
  socket.on('data', (chunk) => {
45
55
  if (input === null) return;
56
+ const bytes = Buffer.byteLength(chunk);
57
+ if (heldBytes + bytes > MAX_REQUEST || pendingBytes + inputBytes + bytes > MAX_PENDING) {
58
+ release(); input = null; void writeOwnerAnswer(socket, refused('the request exceeded its byte bound')).catch(() => socket.destroy()); return;
59
+ }
60
+ heldBytes += bytes; inputBytes += bytes;
46
61
  input += chunk;
47
62
  const end = input.indexOf('\n');
48
63
  if (end < 0) return;
49
64
  const line = input.slice(0, end);
50
- input = null;
65
+ input = null; release();
51
66
  // one answer per call, whatever the request did: a request that fails is a failed answer, never the door's end
52
67
  answer(line)
53
68
  .catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
@@ -16,8 +16,8 @@ const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: $
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
17
  * start it again. Answers the door's close.
18
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 } });
19
+ export async function openOwnerDoor(root, { machine, answer, log, readListings = true }) {
20
+ const worker = new Worker(new URL('./owner-door-worker.mjs', import.meta.url), { name: 'supercode board door', workerData: { root, machine, readListings } });
21
21
  let open = false, closing = false;
22
22
  const send = (frame) => { try { worker.postMessage(frame); } catch (error) { log(`board door: could not hand the door ${frame.type}: ${error.message}`); } };
23
23
  const stops = [