@volter/supercode-orchestrator 0.5.209 → 0.5.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,16 @@ stage's own transition, which ends the run in the default workflow. See the
19
19
  [private manual reading](../../docs/evidence/board-manual-claim-2026-10-09.md)
20
20
  for scope and limits; existing inconsistent claims are not repaired automatically.
21
21
 
22
+ Completing a task with `--commit` inherits its arc's workspace when the task is
23
+ scratch. When that workspace belongs to another machine, the board verifies the
24
+ commit against the declared repository's pushed branch head through GitHub,
25
+ without requiring the checkout on the board host. A legacy directory path is
26
+ resolved through its own machine's workspace door to obtain that identity.
27
+ Remote commits take a full SHA or at least seven hexadecimal characters; a
28
+ different head or an unread remote refuses completion. Local checkouts keep
29
+ their existing Git commit resolution. This source check does not replace the
30
+ arc's independent review.
31
+
22
32
  The orchestrator's runtime over the ontology: one typed operational model
23
33
  (`OrchestratorState` = an orchestration value plus a runtime half) whose folder is its serialization.
24
34
  `docs/ORCHESTRATOR-IR.md` is normative. The orchestration value and every codec are the ontology's, in
@@ -125,7 +135,11 @@ redacted refusal even when a failed call is fast. See
125
135
  Board discovery caches advisory reads while waiting. Review requests, verdicts
126
136
  and acceptance refresh the complete source set, including previously draft or
127
137
  closed declared sources, and validate its current heads. An unread set defers
128
- the action. Unread commit dates also defer eligibility and are never cached as
138
+ the action. Before the dispatcher spends those validation reads, it checks the
139
+ advisory candidate against its local worktrees. A mismatched HEAD or uncommitted
140
+ work refuses that attempt locally. Each later round rechecks the checkout, so
141
+ a repair wakes it; a successful preflight still needs the fresh complete-source,
142
+ local and pushed-head checks before review is recorded. Unread commit dates also defer eligibility and are never cached as
129
143
  old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
130
144
  reserves its own attempt. All processes and credentials sharing this supercode
131
145
  home share a rolling-hour ceiling of 1,200 attempts, including at most 600
package/agents.mjs CHANGED
@@ -232,8 +232,17 @@ export function localAgentRecord(root,org,id){
232
232
  /** Rename only identities this card role declared; a reassigned standing agent keeps its own name. */
233
233
  export function renameCardAgent(root, assignment, readableId) {
234
234
  const intent=agentIntent(root,assignment.org,assignment.agent_id);
235
- if(!intent?.declaration_json)return false;
236
- const declaration=JSON.parse(intent.declaration_json);
235
+ if(!intent||assignment.state!=='active')return false;
236
+ let declaration=intent.declaration_json?JSON.parse(intent.declaration_json):null;
237
+ if(!declaration){
238
+ // Older declared intents kept only their stable key. Recover this role's own current identity, never an agent
239
+ // reassigned from another card or a standing profile. The existing Teams identity keeps its lineage and sessions.
240
+ const suffix=`/card/${assignment.target}/${assignment.role}`,base=intent.key.replace(/\/generation\/\d+$/,'');
241
+ if(!base.endsWith(suffix)||declarationHead(root,assignment.org,base)!==assignment.agent_id)return false;
242
+ const parent=declarationHead(root,assignment.org,`${base.slice(0,-suffix.length)}/root`);
243
+ if(!parent)return false;
244
+ declaration={kind:'card',card:assignment.target,role:assignment.role,profile:intent.profile_ref,parent};
245
+ }
237
246
  if(declaration.kind!=='card'||declaration.card!==assignment.target||declaration.role!==assignment.role)return false;
238
247
  const name=`${assignment.role}-${readableId}`;
239
248
  if(declaration.name===name&&declaration.readableId===readableId)return false;
@@ -34,7 +34,7 @@ if (process.argv[2] === 'workflow') {
34
34
  catch(error) {noteCommandError(error);process.stderr.write(`supercode workflow: ${error.message??error}\n${error?.errcode!=null&&error.stack?`${error.stack}\n`:''}`);code=1;}
35
35
  // the board documents' git work this command queued (a ztrack board's commit and push) is finished before it exits
36
36
  try{const{settleDocuments}=await import('../board/git-document.mjs');await settleDocuments();}catch{/* nothing queued */}
37
- await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.write('',done))));
37
+ await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.destroyed?done():stream.write('',done))));
38
38
  process.exit(code);
39
39
  } else if (process.argv[2] === 'connect') {
40
40
  const {parseArgs}=await import('node:util');
@@ -1,7 +1,7 @@
1
1
  // Consume the card-creation intent after the board transaction; retries reuse the daemon-minted id. The card's agent is
2
2
  // minted and recorded here, and its declaration handed to this machine's daemon, which declares it in Teams: whoever ran
3
3
  // the effect (the creating command, or a delivery pass retrying it) makes no Teams call (D137).
4
- import { agentScope, localCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
4
+ import { agentScope, localCardAgent, renameCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
5
5
  import { cardHandleOf, readableIdOf, event, json, tx, view } from './store.mjs';
6
6
  import { workflowFor } from './workflow.mjs';
7
7
  import { cardAgentFacts, cardAgentReceiptRecorded, syncCardAgentScope } from './published.mjs';
@@ -47,6 +47,23 @@ export async function declareCardAgentEffect(board,effect) {
47
47
  }
48
48
  }
49
49
 
50
+ /** A readable-id edit carries every live role's retained declaration through the existing machine identity door.
51
+ * No assignment is created or revived here; an ended or reassigned agent keeps its own identity and history. */
52
+ export async function renameCardAgentsEffect(board,effect) {
53
+ const task=view(board,db=>db.prepare('SELECT t.readable_id,t.status,c.subtask_of,c.archived FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
54
+ if(!task?.readable_id||task.subtask_of||task.archived||['done','cancelled','archived'].includes(task.status))return true;
55
+ const agents=new Set();
56
+ for(const assignment of cardAgentAssignments(board.root,effect.id)) {
57
+ if(assignment.state!=='active'||!admitsScope(board,effect.id,assignment.org))continue;
58
+ renameCardAgent(board.root,assignment,task.readable_id);
59
+ agents.add(assignment.agent_id);
60
+ }
61
+ if(!agents.size)return true;
62
+ const pending=(await handOffAgents(board.root)).filter(item=>item.agent==='*'||agents.has(item.agent));
63
+ if(pending.length)throw new Error(pending.map(item=>item.error).join('; '));
64
+ return true;
65
+ }
66
+
50
67
  /** A failed board write leaves the owner's receipt pending. A failed acknowledgement repeats no board event. */
51
68
  export function recordCardAgentChanges(board,target){
52
69
  const changes=pendingCardAgentChanges(board.root,target);
package/board/call.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  // a connection of its own (store.mjs openBoard), and nothing is written
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { AsyncLocalStorage } from 'node:async_hooks';
18
+ import { withCredentialGuard } from './credential-guard.mjs';
18
19
 
19
20
  const calls = new AsyncLocalStorage();
20
21
  // what a call learns as it runs, and the workflow it read (pinned: a reload while it runs does not change its basis)
@@ -34,7 +35,7 @@ export function within({ caller = null, facts = {}, cwd, stdin = null, out, err,
34
35
  /** Run `fn` as a unit of the process's own work (a round, a delivery pass): the process's identity and output, with
35
36
  * what it learns and the workflow it read kept to itself. */
36
37
  export function ownCall(fn) {
37
- return calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn);
38
+ return withCredentialGuard(() => calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn));
38
39
  }
39
40
 
40
41
  /** The caller as its address names it, or null. */
@@ -89,50 +89,63 @@ export async function holdings(board, options) {
89
89
  */
90
90
  async function readHoldings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null, runLaneOf = null }, unchanged) {
91
91
  const out = [];
92
- const rows = board.db.prepare("SELECT t.id, t.status, t.assignee, t.current_run_id, t.model_override, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived' AND c.detached IS NULL").all();
93
- for (const row of rows) {
92
+ let cursor = '';
93
+ const high = board.db.prepare('SELECT MAX(id) AS id FROM tasks').get()?.id ?? '';
94
+ for (;;) {
94
95
  await yieldToOwner();
95
96
  if (!unchanged()) return null;
96
- const lane = lanes[row.assignee] ?? {};
97
- const card = {
98
- card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
99
- // what the board stores for an arc is read through its workflow's stored_as rules now, never the column a write has
100
- // not yet brought up to date (a paused card counted as running until its row caught up)
101
- stored: !row.subtask_of && storedOf ? storedOf(row.id) : row.status,
102
- // a card that names no machine runs on this home's: a cap on this machine counts it
103
- machine: row.machine ? machineOf(row.machine) : home,
104
- model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
105
- };
106
- // a session's model: what its transcript reports, else what its launch resolved, else the card's
107
- const sessionOf = (s, slot) => {
108
- const configured = s.model ?? card.model;
109
- return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
110
- dispatch: dispatched(s), run: s.run ?? null,
111
- machine: s.surface === 'headless' ? home : s.machine || s.surface ? machineOf(s.machine ?? row.machine ?? home)
112
- : executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
113
- model: (s.session_id ? modelOf(s) : null) ?? configured };
114
- };
115
- const sessions = fleetOk ? await sessionsOf(board.db, row.id, unchanged) : new Map();
116
- if (sessions === null) return null;
117
- const held = [];
118
- let current = null;
119
- if (row.current_run_id) {
120
- current = { ...json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode, run: row.current_run_id };
121
- out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
122
- }
123
- for (const [slot, s] of sessions) {
124
- if (!alive(s)) continue;
125
- const session = sessionOf(s, slot);
126
- held.push(session);
127
- if (IN_TURN.has(liveRecord(s)?.status)) out.push({ ...session, counts: 'turns' });
97
+ // Limit candidates before filtering or joining: even a page containing only archived/detached cards is bounded.
98
+ const candidates = board.db.prepare('SELECT id FROM tasks WHERE id>? AND id<=? ORDER BY id LIMIT 32').all(cursor, high);
99
+ if (!candidates.length) break;
100
+ cursor = candidates.at(-1).id;
101
+ const rows = board.db.prepare(`SELECT t.id,t.status,t.assignee,t.current_run_id,t.model_override,c.machine,c.subtask_of
102
+ FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
103
+ WHERE t.id IN (SELECT value FROM json_each(?)) AND t.status!='archived' AND c.detached IS NULL`)
104
+ .all(JSON.stringify(candidates.map(row => row.id)));
105
+ for (const row of rows) {
106
+ await yieldToOwner();
107
+ if (!unchanged()) return null;
108
+ const lane = lanes[row.assignee] ?? {};
109
+ const card = {
110
+ card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
111
+ // what the board stores for an arc is read through its workflow's stored_as rules now, never the column a write has
112
+ // not yet brought up to date (a paused card counted as running until its row caught up)
113
+ stored: !row.subtask_of && storedOf ? storedOf(row.id) : row.status,
114
+ // a card that names no machine runs on this home's: a cap on this machine counts it
115
+ machine: row.machine ? machineOf(row.machine) : home,
116
+ model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
117
+ };
118
+ // a session's model: what its transcript reports, else what its launch resolved, else the card's
119
+ const sessionOf = (s, slot) => {
120
+ const configured = s.model ?? card.model;
121
+ return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
122
+ dispatch: dispatched(s), run: s.run ?? null,
123
+ machine: s.surface === 'headless' ? home : s.machine || s.surface ? machineOf(s.machine ?? row.machine ?? home)
124
+ : executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
125
+ model: (s.session_id ? modelOf(s) : null) ?? configured };
126
+ };
127
+ const sessions = fleetOk ? await sessionsOf(board.db, row.id, unchanged) : new Map();
128
+ if (sessions === null) return null;
129
+ const held = [];
130
+ let current = null;
131
+ if (row.current_run_id) {
132
+ current = { ...json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode, run: row.current_run_id };
133
+ out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
134
+ }
135
+ for (const [slot, s] of sessions) {
136
+ if (!alive(s)) continue;
137
+ const session = sessionOf(s, slot);
138
+ held.push(session);
139
+ if (IN_TURN.has(liveRecord(s)?.status)) out.push({ ...session, counts: 'turns' });
140
+ }
141
+ // a live run whose session the fleet does not list yet (launching) holds a session's place already
142
+ if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
143
+ out.push(...held);
144
+ // the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
145
+ // until it is done); its model and harness are its run's session's, else its newest live session's, else its own
146
+ // a subtask is a task of its arc: it never runs or holds a slot of its own
147
+ if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
128
148
  }
129
- // a live run whose session the fleet does not list yet (launching) holds a session's place already
130
- if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
131
- out.push(...held);
132
- // the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
133
- // until it is done); its model and harness are its run's session's, else its newest live session's, else its own
134
- // a subtask is a task of its arc: it never runs or holds a slot of its own
135
- if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
136
149
  }
137
150
  return out;
138
151
  }
package/board/ci.mjs CHANGED
@@ -291,13 +291,15 @@ async function repositoryWorkspace(board, id) {
291
291
  // the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
292
292
  const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
293
293
  const here = isLocalWorkspace(workspace);
294
+ if (!here) {
295
+ const identity = workspace.workspace_anchor ?? workspace.workspace_path;
296
+ if (!identity) return null;
297
+ // A portable remote declaration supplies the source identity. Legacy paths
298
+ // are resolved by their own machine, never read as paths on the board host.
299
+ const resolved = plainWorkspace(workspace.workspace_kind, identity, workspace.branch_name) ?? await resolveWorkspace(workspace, false);
300
+ return { ...resolved, branch: workspace.branch_name ?? resolved.branch, here: false };
301
+ }
294
302
  if (workspace.workspace_anchor) {
295
- // A portable remote declaration supplies the source identity. Reviewing
296
- // its pushed head does not require that machine to keep the old checkout.
297
- if (!here) {
298
- const declared = plainWorkspace(workspace.workspace_kind, workspace.workspace_anchor, workspace.branch_name);
299
- if (declared) return { ...declared, here: false };
300
- }
301
303
  const resolved = await resolveWorkspace(workspace, false);
302
304
  // Resolution without create names a solo worktree even before it exists on
303
305
  // this machine. Its portable repository and branch still identify the
@@ -305,7 +307,24 @@ async function repositoryWorkspace(board, id) {
305
307
  if (here && !checkoutPresent(resolved.path)) return { ...resolved, here: await recreatedWorktree(workspace, resolved) };
306
308
  return { ...resolved, here };
307
309
  }
308
- return workspace.workspace_path ? { path: workspace.workspace_path, here: here && checkoutPresent(workspace.workspace_path) } : null;
310
+ return workspace.workspace_path ? { path: workspace.workspace_path, here: checkoutPresent(workspace.workspace_path) } : null;
311
+ }
312
+
313
+ /** A supplied completion commit belongs to the workspace's machine. Local Git
314
+ * keeps its existing semantics; a remote declaration is read at its pushed head. */
315
+ async function completionSource(workspace, supplied) {
316
+ if (workspace?.here && workspace.path) {
317
+ return { repository: await originOf(workspace.path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], workspace.path) };
318
+ }
319
+ if (!workspace?.repository || !workspace.branch) throw new Error('The named commit needs a declared repository and branch, resolved through the workspace machine when only a path is recorded.');
320
+ if (typeof supplied !== 'string' || !/^[a-f0-9]{7,40}$/i.test(supplied)) throw new Error('Name the remote completion commit with its full SHA or an abbreviation of at least seven hexadecimal characters.');
321
+ // Read the declared branch, not an arbitrary commit that merely exists in
322
+ // the repository. No checkout or daemon upgrade on the board host is needed.
323
+ const pushed = await github(`/repos/${workspace.repository}/git/ref/heads/${encodeURIComponent(workspace.branch)}`);
324
+ const head = pushed?.object?.type === 'commit' ? pushed.object.sha : null;
325
+ if (typeof head !== 'string' || !/^[a-f0-9]{40}$/.test(head)) throw new Error(`The declared remote ${workspace.repository}:${workspace.branch} answered no commit head.`);
326
+ if (!head.startsWith(supplied.toLowerCase())) throw new Error(`The named commit ${supplied} must be the pushed head of ${workspace.repository}:${workspace.branch}, currently ${head}.`);
327
+ return { repository: workspace.repository, branch: workspace.branch, commit: head, source_sha: head };
309
328
  }
310
329
 
311
330
  /** A card's declared solo worktree that is missing here (deleted, or never made on this machine) while its branch is on
@@ -383,7 +402,7 @@ function registeredBranches(board, id) {
383
402
  * declaration (engine.comment), the arc's complete set. A multi-repository arc carries them on the arc card, and its
384
403
  * review discovers and accepts PRs from them with no subtask registered in that repository (VO-2858, VO-2860). */
385
404
  export function arcSources(db, id) {
386
- const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
405
+ const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
387
406
  let declared = [];
388
407
  try { declared = row ? JSON.parse(row.payload).branches : []; } catch { /* an unreadable declaration names none */ }
389
408
  return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
@@ -396,8 +415,9 @@ export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/
396
415
  /** The branch names an arc declares as its work: its own and its subtasks' `--branch`, the branches it declares as its
397
416
  * sources (arcSources), and each branch its latest review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
398
417
  export function declaredWorkBranches(db, id) {
399
- const declared = db.prepare(`SELECT t.branch_name FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
400
- WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
418
+ const declared = db.prepare(`SELECT branch_name FROM tasks WHERE id=? AND status!='cancelled' AND branch_name IS NOT NULL
419
+ UNION SELECT t.branch_name FROM supercode_cards c CROSS JOIN tasks t ON t.id=c.task_id
420
+ WHERE c.subtask_of=? AND t.status!='cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
401
421
  declared.push(...arcSources(db, id).map((b) => b.branch));
402
422
  const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
403
423
  let reviewed = [];
@@ -410,7 +430,7 @@ export function declaredWorkBranches(db, id) {
410
430
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
411
431
  * checked out (or no branch), is not a work branch: such a card's PRs are only its `wt/<card>` and registered ones, and with none it
412
432
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
413
- export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
433
+ export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false } = {}) {
414
434
  const own = await repositoryWorkspace(board, id);
415
435
  const registered = registeredBranches(board, id);
416
436
  if (!own && !registered.length) return null;
@@ -450,7 +470,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
450
470
  if (branch === list.defaultBranch) branch = null;
451
471
  }
452
472
  // a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
453
- for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
473
+ for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
454
474
  // A manual review and merge during a dispatcher outage may predate any
455
475
  // board request. The source-review door already accepts merged heads; make
456
476
  // them discoverable so the ordinary independent verdict can be retained.
@@ -467,7 +487,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
467
487
  && pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
468
488
  });
469
489
  for (const history of histories) for (const pr of history) {
470
- if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
490
+ if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
471
491
  }
472
492
  return prs;
473
493
  });
@@ -481,9 +501,13 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
481
501
  const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
482
502
  for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
483
503
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
484
- if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
504
+ if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
485
505
  }
486
506
  if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
507
+ // Advisory facts can refuse an attempt before its authoritative REST scan. Recheck the local checkout each
508
+ // round, so a repaired HEAD or clean worktree wakes it without caching the refusal. This is never evidence
509
+ // for a transition: verifyPullRequest repeats the checks against fresh metadata and the pushed head below.
510
+ if (checkWorkspaces) await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
487
511
  return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
488
512
  }
489
513
 
@@ -636,9 +660,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
636
660
  if (stage === 'completion' || stage === 'acceptance') {
637
661
  const supplied = payload.commit ?? payload.metadata?.commit;
638
662
  if (supplied) {
639
- const path = (await repositoryWorkspace(board, id))?.path;
640
- if (!path) throw new Error('The named commit needs a resolved repository workspace.');
641
- Object.assign(state, { repository: await originOf(path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
663
+ Object.assign(state, await completionSource(await repositoryWorkspace(board, id), supplied));
642
664
  }
643
665
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
644
666
  if (candidate) {
@@ -712,14 +734,28 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
712
734
  return state;
713
735
  }
714
736
 
715
- /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
716
- async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
717
- if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
718
- const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
737
+ /** Local review readiness, also usable with advisory metadata to refuse before spending REST admission. */
738
+ async function requireReviewWorkspaceHead(board, id, own, observed, head, { pushed = false } = {}) {
739
+ const url = observed.url, repository = repositoryOf(url), branch = observed.headRefName;
719
740
  // An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
720
741
  // card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
721
742
  // with nothing uncommitted. A card on another machine is reviewed at its pushed head: its checkout is not read from here.
722
743
  const path = observed.state === 'MERGED' || (own && !own.here) ? null : !own ? await branchCheckout(board, id, repository, observed.headRefName) : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
744
+ if (!path) return;
745
+ await run('git', ['check-ref-format', '--branch', branch], path);
746
+ if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
747
+ const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
748
+ if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
749
+ if (pushed) {
750
+ const remote = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
751
+ if (remote !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
752
+ }
753
+ }
754
+
755
+ /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
756
+ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
757
+ if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
758
+ const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
723
759
  // A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
724
760
  // verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
725
761
  const merged = observed.state === 'MERGED';
@@ -732,14 +768,7 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, ob
732
768
  const mainBranch = observed.defaultBranch ?? 'main';
733
769
  // A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
734
770
  if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
735
- if (stage === 'request_review' && path && !merged) {
736
- await run('git', ['check-ref-format', '--branch', branch], path);
737
- if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
738
- const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
739
- if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
740
- const pushed = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
741
- if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
742
- }
771
+ if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
743
772
  return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
744
773
  }
745
774
 
package/board/cli.mjs CHANGED
@@ -16,7 +16,7 @@ import { taskStatusOf } from './published.mjs';
16
16
  import { executionOf } from './session-work.mjs';
17
17
  import { attachmentRecord, readAttachment } from './attachment-read.mjs';
18
18
  import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
19
- import { redactText } from '@volter/teams/redact';
19
+ import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
20
20
  import { openOwnerDoor } from './owner-door.mjs';
21
21
  import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
22
22
  import { notRuntime, ownHome } from './owner-files.mjs';
@@ -1749,8 +1749,11 @@ async function answerVerbs(root, log) {
1749
1749
  * absence from a slow-only log cannot prove those calls met the time bound. */
1750
1750
  export async function answerOne(root, line, { readOnly = false } = {}) {
1751
1751
  const scoped = typeof slowLog.withStepScope === 'function';
1752
+ // The owner's receipt includes asynchronous custody preparation too: it is part of the caller's wait.
1753
+ const began = performance.now(), before = slowLog.processCounters?.() ?? null;
1754
+ let prepared = false;
1752
1755
  const run = async () => {
1753
- const began = performance.now(), before = slowLog.processCounters?.() ?? null;
1756
+ prepared = true;
1754
1757
  let named = null, by = null, answer = null, outcome = 'error';
1755
1758
  try {
1756
1759
  answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
@@ -1769,7 +1772,23 @@ export async function answerOne(root, line, { readOnly = false } = {}) {
1769
1772
  }
1770
1773
  }
1771
1774
  };
1772
- return scoped ? slowLog.withStepScope(run) : run();
1775
+ const guarded = async () => {
1776
+ try { return await withCredentialGuard(run); }
1777
+ catch (error) {
1778
+ if (!prepared) {
1779
+ const ended = performance.now(), ms = ended - began;
1780
+ const code = error?.code === 'custody_unreadable' ? 'custody_unreadable' : 'custody_preparation_failed';
1781
+ // No request, caller payload or error text is inspected before custody is available. Fixed metadata needs
1782
+ // no redaction read and still records the failed preparation in this request's own step scope.
1783
+ recordSlow({ kind: 'call', name: 'owner verb custody preparation', args: [], ms, outcome: code,
1784
+ slowest: null, force: true, includeFast: true,
1785
+ extra: { failure: { code, phase: 'custody preparation' }, step_scope: scoped ? 'request' : 'process_interval',
1786
+ steps: scoped ? takeSteps() : slowLog.stepsWithin?.(began, ended) ?? null } });
1787
+ }
1788
+ throw error;
1789
+ }
1790
+ };
1791
+ return scoped ? slowLog.withStepScope(guarded) : guarded();
1773
1792
  }
1774
1793
 
1775
1794
  /** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
@@ -1949,7 +1968,20 @@ async function serve(root, args = {}) {
1949
1968
  let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
1950
1969
  const listenerErrors = new Set();
1951
1970
  const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
1952
- const scanListeners = ({ waiting = true } = {}) => listenerBootstrapped && fleetRecordsCurrent() && ownCall(() => observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting }));
1971
+ const scanListeners = async ({ waiting = true } = {}) => {
1972
+ if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
1973
+ const revision = listenerRevision;
1974
+ try {
1975
+ await ownCall(() => {
1976
+ if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
1977
+ observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
1978
+ });
1979
+ } catch {
1980
+ // Every caller may schedule this without awaiting it. Preparation refusal leaves source cursors untouched;
1981
+ // the next admitted scan retries the retained observations, while the owner's public door stays available.
1982
+ listenerLog('fleet listener scan refused; retained observations wait for a prepared owner call');
1983
+ }
1984
+ };
1953
1985
  const readListenerRecords = () => {
1954
1986
  if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
1955
1987
  const revision = listenerRevision;
@@ -1964,7 +1996,9 @@ async function serve(root, args = {}) {
1964
1996
  };
1965
1997
  fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
1966
1998
  file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
1967
- enrich: event => ownCall(() => enrichFleetEvent(root, event)),
1999
+ // The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
2000
+ enrich: event => enrichFleetEvent(root, event),
2001
+ work: ownCall,
1968
2002
  retry: () => scanListeners({ waiting: false }),
1969
2003
  });
1970
2004
  const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
@@ -3,7 +3,20 @@
3
3
  // gitleaks' rule set and this machine's custody values), plus the shape of an environment listing: several NAME=value
4
4
  // lines in a row. A shell that expanded `$(env)` or a pasted .env into a comment once put live keys on a card, where
5
5
  // every reader, every launch prompt and the board's own remote received them.
6
- import { redactText } from '@volter/teams/redact';
6
+ import { redactText, redactionSnapshotAsync, withRedactionSnapshot } from '@volter/teams/redact';
7
+ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
8
+
9
+ /** One fresh, fail-closed custody observation per call/round, read before its synchronous guards. No role decision
10
+ * is kept here, and a concurrent caller cannot replace this call's redaction basis. */
11
+ export async function withCredentialGuard(fn) {
12
+ const began = performance.now();
13
+ let snapshot;
14
+ try { snapshot = await redactionSnapshotAsync(process.env, { fresh: true }); }
15
+ finally { noteStep('custody observation', performance.now() - began); }
16
+ return withRedactionSnapshot(process.env, snapshot, fn);
17
+ }
18
+
19
+ export const redactGuardText = text => redactText(text);
7
20
 
8
21
  const ENV_LINE = /^\s*(?:export\s+|declare\s+-x\s+)?[A-Z][A-Z0-9_]{2,}=\S/;
9
22
  const ENV_LINES = 5;
@@ -13,7 +26,7 @@ export function credentialDumpIn(text) {
13
26
  if (typeof text !== 'string' || text.length < 8) return null;
14
27
  const lines = text.split('\n').filter((line) => ENV_LINE.test(line)).length;
15
28
  if (lines >= ENV_LINES) return `${lines} NAME=value lines, the shape of an environment listing`;
16
- const redacted = redactText(text);
29
+ const redacted = redactGuardText(text);
17
30
  if (redacted === text) return null;
18
31
  const before = new Set(text.match(/\[redacted:[^\]]+\]/g) ?? []);
19
32
  const found = [...new Set((redacted.match(/\[redacted:[^\]]+\]/g) ?? []).filter((marker) => !before.has(marker)))]
@@ -32,6 +32,7 @@ import { boardLabel, boardName } from '@volter/teams/board-name';
32
32
  let fleetRoot = null;
33
33
  import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
34
34
  import { setImmediate as yieldToOwner } from 'node:timers/promises';
35
+ import { ACTIVE_RUN_IDS, latestEvent, readRunPages } from './queries.mjs';
35
36
  import { runtimeDoor } from './runtime-door.mjs';
36
37
  import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
37
38
  import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
@@ -50,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
50
51
  import { basename, dirname, join } from 'node:path';
51
52
  import { createHash, randomUUID } from 'node:crypto';
52
53
  import { fileURLToPath } from 'node:url';
53
- import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
54
+ import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
54
55
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
55
56
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
56
57
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
@@ -1398,8 +1399,7 @@ function recordInputClose(board, row, session, reading) {
1398
1399
  /** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
1399
1400
  const closesUnderway = new Map();
1400
1401
  export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
1401
- const rows = view(board, (db) => db.prepare(`SELECT id, task_id, profile, ended_at, metadata FROM task_runs
1402
- WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
1402
+ const rows = await readRunPages(board.db, 'id, task_id, profile, ended_at, metadata', { task: taskId, where: 'metadata IS NOT NULL' });
1403
1403
  // Repair pre-transfer history once: only identities with an older unended reference need a write.
1404
1404
  const latest = new Map(), transfers = new Map();
1405
1405
  for (const row of [...rows].sort((a,b) => b.id-a.id)) {
@@ -1413,10 +1413,15 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1413
1413
  }
1414
1414
  }
1415
1415
  if (transfers.size) tx(board, (db) => {
1416
- for (const row of transfers.values()) retireSupersededRuns(db, row.task_id, row.id, json(row.metadata, {})?.supercode);
1416
+ for (const row of transfers.values()) {
1417
+ // The paged scan yielded. Transfer only from the successor's current retained identity.
1418
+ const current = db.prepare('SELECT task_id,metadata FROM task_runs WHERE id=?').get(row.id);
1419
+ if (current) retireSupersededRuns(db, current.task_id, row.id, json(current.metadata, {})?.supercode);
1420
+ }
1417
1421
  });
1418
1422
  const panes = new Map();
1419
1423
  for (const row of rows) {
1424
+ await yieldToOwner();
1420
1425
  let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1421
1426
  if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
1422
1427
  // A keyed launch is reconciled by its machine's own daemon (D137), never by a door the close waits on. A session
@@ -1474,12 +1479,12 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1474
1479
  const observed = r.code === 0 ? json(r.stdout.trim(), null) : null;
1475
1480
  if (observed?.pane !== session.pane) continue;
1476
1481
  if (observed.present === true && observed.yours === true) {
1477
- if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
1482
+ if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
1478
1483
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); })) continue;
1479
1484
  finalFailure = false;
1480
1485
  } else if (observed.present !== false) continue;
1481
1486
  }
1482
- const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all());
1487
+ const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
1483
1488
  // Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
1484
1489
  // same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
1485
1490
  // only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
@@ -1531,7 +1536,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1531
1536
  const observed = panes.get(machine);
1532
1537
  if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
1533
1538
  if (observed.live.has(session.pane)) {
1534
- const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
1539
+ const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
1535
1540
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
1536
1541
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
1537
1542
  // An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
@@ -1583,7 +1588,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1583
1588
  // Removing only the card's own left every subtask notifying a closed session, each attempt a failed delivery,
1584
1589
  // every round. A later run that resumes it by id subscribes it again at its launch (and each round while it is
1585
1590
  // the card's current run).
1586
- const subscribed = db.prepare('SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)').all()
1591
+ const subscribed = db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all()
1587
1592
  .some((r) => addressOf(json(r.metadata, {})?.supercode) === address && r.id !== row.id);
1588
1593
  const unsubscribed = subscribed ? 0 : db.prepare("DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id = ? AND (task_id = ? OR json_extract(delivery_metadata, '$.role') = 'worker')").run(address, row.task_id).changes;
1589
1594
  event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine, unsubscribed,
@@ -1722,7 +1727,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1722
1727
  const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
1723
1728
  if (session.held_notice?.key === key) return;
1724
1729
  // every run a card works under: an ended run can still be its card's current one
1725
- const told = db.prepare('SELECT r.metadata FROM task_runs r WHERE r.id != ? AND (r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id))').all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
1730
+ const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
1726
1731
  setRunSession(db, runRow.id, { held_notice: { key, at: observedAt } });
1727
1732
  if (told) return;
1728
1733
  const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null });
@@ -1752,7 +1757,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1752
1757
  if (reading.quiet_seconds < settings.idleQuiet) { retain('turn_unknown'); return; }
1753
1758
  if (session.unknown_notice) return;
1754
1759
  const key = [address, live?.turn_since_ms ?? 'unknown'].join('|');
1755
- const told = db.prepare('SELECT r.metadata FROM task_runs r WHERE r.id != ? AND (r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id))').all(runRow.id)
1760
+ const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
1756
1761
  .some(r => json(r.metadata, {})?.supercode?.unknown_notice?.key === key);
1757
1762
  setRunSession(db, runRow.id, { unknown_notice: { key, at: observedAt, since } });
1758
1763
  const observation = retain('turn_unknown_reported', { waiting_for: live?.waiting_for ?? null, turn_source: live?.turn_source ?? null, since_ms: live?.turn_since_ms ?? null });
@@ -2115,9 +2120,12 @@ async function readRunUsage(root,org,row,endedAt){
2115
2120
  async function synchronizeAgents(board, report) {
2116
2121
  // A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
2117
2122
  // A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
2118
- const agentRuns=[...new Map(view(board,db=>db.prepare("SELECT id,task_id,metadata,ended_at FROM task_runs WHERE json_extract(metadata,'$.supercode.agent_id') IS NOT NULL ORDER BY id").all())
2123
+ const agentRuns=[...new Map((await readRunPages(board.db, 'id,task_id,metadata,ended_at', { where: "json_extract(metadata,'$.supercode.agent_id') IS NOT NULL" }))
2119
2124
  .map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
2120
- for(const row of agentRuns){
2125
+ for(const scanned of agentRuns){
2126
+ await yieldToOwner();
2127
+ const row=view(board,db=>db.prepare('SELECT id,task_id,metadata,ended_at FROM task_runs WHERE id=?').get(scanned.id));
2128
+ if(!row)continue;
2121
2129
  const session=json(row.metadata,{})?.supercode;
2122
2130
  if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
2123
2131
  const ref=`${session.harness}:${session.session_id}`;
@@ -2444,7 +2452,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2444
2452
  } else if (prev.resumable !== false) {
2445
2453
  const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
2446
2454
  const released = view(board,db=>{
2447
- const last = db.prepare("SELECT id,kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','restart_authorization_consumed') ORDER BY id DESC LIMIT 1").get(task.id);
2455
+ const last = latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'restart_authorization_consumed'], 'id,kind');
2448
2456
  return last?.kind === 'dispatch_released';
2449
2457
  });
2450
2458
  // Only a session whose run ended in a monitored incident waits for its manager or creator; one a workflow
@@ -2956,8 +2964,11 @@ async function fileMail(board, to, body, key, { ask = false, wake = true, subjec
2956
2964
  }
2957
2965
 
2958
2966
  /** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
2959
- export function fileFleetNotice(root, to, body, key) {
2960
- return fileMail({ root, label: boardLabel(root, 'default') }, to, body, key);
2967
+ export async function fileFleetNotice(root, to, body, key) {
2968
+ const board = openBoard(root, 'default');
2969
+ board.label = boardLabel(root, 'default');
2970
+ try { return await fileMail(board, to, body, key); }
2971
+ finally { board.close(); }
2961
2972
  }
2962
2973
 
2963
2974
  /**
@@ -3102,9 +3113,13 @@ function noticeWait(board, sub, report, reason = null) {
3102
3113
  * the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
3103
3114
  * holds it again.
3104
3115
  */
3105
- function sessionRecord(db) {
3116
+ function sessionRecord(db, address) {
3106
3117
  const held = new Set(), latest = new Map();
3107
- for (const r of db.prepare('SELECT r.id, r.task_id, r.ended_at, r.metadata, t.id AS current FROM task_runs r LEFT JOIN tasks t ON t.current_run_id = r.id ORDER BY r.id').all()) {
3118
+ const named = sessionAt(address);
3119
+ if (!named) return { held, latest };
3120
+ // Both arms seek one native identity; the join's current_run_id has its own index. Never read unrelated sessions.
3121
+ for (const r of db.prepare(`SELECT r.id,r.task_id,r.ended_at,r.metadata,t.id AS current FROM task_runs r
3122
+ LEFT JOIN tasks t ON t.current_run_id=r.id WHERE r.id IN (${NOTICE_RUN_IDS}) ORDER BY r.id`).all(named.session_id, address)) {
3108
3123
  const session = json(r.metadata, {})?.supercode, address = addressOf(session);
3109
3124
  if (!address) continue;
3110
3125
  if (!r.ended_at || r.current) held.add(address);
@@ -3152,10 +3167,10 @@ async function endWorkerSubscriptions(board, fleet, report) {
3152
3167
  if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
3153
3168
  }
3154
3169
  tx(board, (db) => {
3155
- const { held, latest } = sessionRecord(db);
3156
3170
  const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
3157
3171
  const ended = new Map();
3158
3172
  for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
3173
+ const { held, latest } = sessionRecord(db, row.chat_id);
3159
3174
  const address = row.chat_id, record = latest.get(address);
3160
3175
  // a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
3161
3176
  if (!record || record.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
@@ -3185,7 +3200,6 @@ async function endWorkerSubscriptions(board, fleet, report) {
3185
3200
  */
3186
3201
  async function notify(board, fleet, report, entry) {
3187
3202
  await endWorkerSubscriptions(board, fleet, report);
3188
- const record = view(board, (db) => sessionRecord(db));
3189
3203
  for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
3190
3204
  await yieldToOwner();
3191
3205
  if (stopping) break;
@@ -3198,6 +3212,7 @@ async function notify(board, fleet, report, entry) {
3198
3212
  const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
3199
3213
  .all(sub.task_id, sub.last_event_id, ...kinds);
3200
3214
  if (!events.length) { noticeWait(board, sub, report); continue; }
3215
+ const record = view(board, db => sessionRecord(db, sub.chat_id));
3201
3216
  // Events name the acting native session; subscriptions may name its agent.
3202
3217
  // Use the daemon's retained binding, including former mains, for both
3203
3218
  // coalescing and delivery. An unread binding proves no additional alias.
@@ -3636,6 +3651,9 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3636
3651
  report.queued.push([id, `review waits: ${inTurn.why}`]);
3637
3652
  continue;
3638
3653
  }
3654
+ // A stable local refusal (wrong HEAD or uncommitted work) must not spend a full fresh REST scan every
3655
+ // round. Advisory discovery is sufficient to refuse, never to authorize; the scan below still owns that.
3656
+ if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
3639
3657
  if (!dry) await refreshCardInputs(board, id, { commits: true });
3640
3658
  // Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
3641
3659
  const observed = await withFreshReviewReads(async () => {
package/board/engine.mjs CHANGED
@@ -468,8 +468,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
468
468
  // Authorization and the comment row share one transaction: a refusal stores nothing.
469
469
  if (name === 'comment') r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
470
470
  // the cards that wait on it, and its subtasks, see it change
471
- const related = all(db, 'SELECT task_id, blocked_by FROM supercode_cards').filter((c) => json(c.blocked_by, []).some((b) => b.id === id)).map((c) => c.task_id);
472
- const around = [...related, ...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
471
+ // Card blockers are mirrored to task_links by relation writes and document imports. Its parent key is the reverse
472
+ // lookup; reading every card's blocked_by JSON on each public verb only rediscovers the same children.
473
+ const around = [...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
473
474
  for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
474
475
  return r;
475
476
  });
@@ -532,7 +533,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
532
533
  // it keeps trying at the capped wait, and at EFFECT_ATTEMPTS failed attempts its card records `effect_stalled` and the
533
534
  // managers are mailed once, so a passed card whose merge keeps failing is seen, and still heals when the cause clears.
534
535
  export const EFFECT_ATTEMPTS = 6;
535
- const OWNED = new Set(['merge_reviewed', 'cleanup_workspace']);
536
+ const OWNED = new Set(['merge_reviewed', 'cleanup_workspace', 'rename_card_agents']);
536
537
  const effectWait = (attempts) => Math.min(1800, 30 * 2 ** Math.max(0, attempts - 1));
537
538
  // A delivery pass claims a row by compare-and-set on its next_at (set ahead only while the row is due) before trying it,
538
539
  // so two passes (serve's, the hosted loop's, a person's `workflow deliver`, a verb's own) never try one effect at once.
@@ -620,6 +621,7 @@ async function runEffect(board, e) {
620
621
  if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
621
622
  if (e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id); return true; }
622
623
  if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
624
+ if (e.kind === 'rename_card_agents') { const { renameCardAgentsEffect } = await import('./agent-declarations.mjs'); return await renameCardAgentsEffect(board, e); }
623
625
  // a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
624
626
  if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); await fileBoardMail(board, { undelivered: [] }, e.id); return true; }
625
627
  if (e.kind === 'merge_reviewed') { const { mergeReviewed } = await import('./dispatch.mjs'); return await mergeReviewed(board, e) !== false; }
@@ -3,6 +3,7 @@
3
3
  import { watch, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
4
  import { dirname, basename, join, resolve } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
+ import { once } from 'node:events';
6
7
  import { listBoards, openBoard, backingPaths } from './store.mjs';
7
8
 
8
9
  const identity = root => createHash('sha256').update(realpathSync(root)).digest('hex');
@@ -117,14 +118,47 @@ export async function* followBoardPublications({root,after,signal}) {
117
118
 
118
119
  export async function streamBoardPublications(options) {
119
120
  if(options.once&&options.after)throw new Error('--once requires a fresh snapshot; omit --after');
121
+ const parent=process.ppid, reader=Number(process.env.SUPERCODE_WORKFLOW_READER_PID??parent);
122
+ if(!Number.isSafeInteger(reader)||reader<1||reader>2147483647)throw new Error('Invalid SUPERCODE_WORKFLOW_READER_PID');
120
123
  const abort=new AbortController();const stop=()=>abort.abort();
124
+ let outputError=null;
125
+ const failed=error=>{outputError=error;stop();};
121
126
  process.once('SIGINT',stop);process.once('SIGTERM',stop);
127
+ process.stdout.on('error',failed);process.stdout.once('close',stop);
128
+ // Quiet streams never meet EPIPE. The connector names the real reader across native/npm/home hand-offs; direct
129
+ // callers use their parent. Unix also detects reparenting (including death before this command finished starting).
130
+ // On Windows ppid does not change, but probing the named reader still detects its exit.
131
+ const checkReader=()=>{
132
+ if(process.platform!=='win32'&&(parent===1||process.ppid!==parent)){stop();return;}
133
+ try{process.kill(reader,0);}catch(error){if(error.code==='ESRCH')stop();}
134
+ };
135
+ const lifetime=setInterval(checkReader,1000);lifetime.unref();checkReader();
136
+ const write=async frame=>{
137
+ if(abort.signal.aborted)return;
138
+ try{
139
+ if(!process.stdout.write(`${JSON.stringify(frame)}\n`))await once(process.stdout,'drain',{signal:abort.signal});
140
+ }catch(error){
141
+ if(error.name==='AbortError'&&abort.signal.aborted)return;
142
+ if(error.code==='EPIPE'){stop();return;}
143
+ throw error;
144
+ }
145
+ };
146
+ let code=0;
122
147
  try {
123
148
  for await(const frame of followBoardPublications({...options,signal:abort.signal})){
124
- if(!process.stdout.write(`${JSON.stringify(frame)}\n`))await new Promise(done=>process.stdout.once('drain',done));
149
+ if(abort.signal.aborted)break;
150
+ await write(frame);
151
+ if(abort.signal.aborted)break;
125
152
  if(options.once&&frame.type==='snapshot_end')break;
126
153
  }
127
- }catch(error){if(error.code==='resync_required'){process.stdout.write(`${JSON.stringify({type:'resync_required',reason:error.message})}\n`);return 1;}throw error;}
128
- finally{process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);}
129
- return 0;
154
+ }catch(error){if(error.code==='resync_required'){await write({type:'resync_required',reason:error.message});code=1;}else throw error;}
155
+ finally{
156
+ clearInterval(lifetime);process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);
157
+ // No final flush can drain into a dead or stalled reader. Returning the iterator above closes its board and
158
+ // watchers first; discard pending bytes on cancellation so the public entry can finish too.
159
+ if(abort.signal.aborted)process.stdout.destroy();
160
+ process.stdout.removeListener('error',failed);process.stdout.removeListener('close',stop);
161
+ }
162
+ if(outputError&&outputError.code!=='EPIPE')throw outputError;
163
+ return code;
130
164
  }
@@ -15,9 +15,9 @@ const refused = message => Object.assign(new Error(message), { code: 'fleet_list
15
15
  /** A retained mail outbox. `file` is the board's existing admitted mailbox filing door, never a transport or
16
16
  * provider selected from the observed payload. No runtime database is opened until the home opts into listeners. */
17
17
  export class FleetListenerRouter {
18
- constructor({ root, file, enrich = event => event, retry = () => {}, log = () => {} }) {
18
+ constructor({ root, file, enrich = event => event, work = fn => fn(), retry = () => {}, log = () => {} }) {
19
19
  if (typeof file !== 'function') throw refused('fleet listener router needs the admitted mailbox filing door');
20
- this.root = root; this.file = file; this.enrich = enrich; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
20
+ this.root = root; this.file = file; this.enrich = enrich; this.work = work; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
21
21
  }
22
22
 
23
23
  /** The workflow's decoded declarations. An invalid replacement refuses without discarding the prior outbox. */
@@ -37,7 +37,15 @@ export class FleetListenerRouter {
37
37
  this.suspended = false;
38
38
  if (Object.keys(next).length) {
39
39
  this.store();
40
- if (!this.timer) { this.timer = setInterval(() => { if (!this.suspended) { try { this.retry(); } catch (error) { this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`); } } this.wake(); }, 1000); this.timer.unref?.(); }
40
+ if (!this.timer) {
41
+ this.timer = setInterval(() => {
42
+ void Promise.resolve().then(() => {
43
+ if (!this.closed && !this.suspended) return this.retry();
44
+ }).catch(error => this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`))
45
+ .finally(() => this.wake());
46
+ }, 1000);
47
+ this.timer.unref?.();
48
+ }
41
49
  this.wake();
42
50
  } else if (this.db) {
43
51
  this.db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,1) ON CONFLICT(id) DO UPDATE SET needs_baseline=1').run();
@@ -212,7 +220,10 @@ export class FleetListenerRouter {
212
220
  flush() {
213
221
  if (this.flushing) return this.flushing;
214
222
  if (!this.db || this.closed || this.suspended) return Promise.resolve();
215
- this.flushing = Promise.resolve().then(async () => {
223
+ // Prepare owner work before replaying retained waits too. Enrichment and enqueue remain synchronous, so the
224
+ // source cursor cannot advance until its complete event body is committed to the outbox.
225
+ this.flushing = Promise.resolve().then(() => this.work(async () => {
226
+ if (this.closed || this.suspended) return;
216
227
  // Even a prompt that has since cleared keeps an earlier refused observation. Do not block already queued mail
217
228
  // on a full outbox: file it below to make room, then the retained prompt retries at the next wake.
218
229
  for (const wait of this.db.prepare('SELECT address,event FROM fleet_listener_waits WHERE routed=0 LIMIT 16').all()) {
@@ -238,7 +249,7 @@ export class FleetListenerRouter {
238
249
  this.db.prepare('UPDATE fleet_listener_mail SET attempts=?,next_at=?,error=? WHERE id=?').run(attempts, Date.now() + delay, String(error.message).slice(0, 500), row.id);
239
250
  }
240
251
  }
241
- }).finally(() => { this.flushing = null; });
252
+ })).finally(() => { this.flushing = null; });
242
253
  return this.flushing;
243
254
  }
244
255
 
@@ -38,7 +38,7 @@ CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_pub
38
38
  // Columns the publication tables gained after their first layout; store.mjs layOut adds each one a store lacks. A head's
39
39
  // fingerprint is the cheap reading of its card's rows the head was last compared at (fingerprintOf); the log's prune_from
40
40
  // is where a prune under way resumes, null when none is (prunePublications).
41
- export const PUBLICATION_COLUMNS = { supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
41
+ export const PUBLICATION_COLUMNS = { supercode_publications: { snapshot_bytes: 'INTEGER' }, supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
42
42
 
43
43
  // The snapshot's shape: 1 names the card as a person does (`ref`, VO-856).
44
44
  const SNAPSHOT_SHAPE = 4;
@@ -56,7 +56,7 @@ const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comm
56
56
  export function publicationTriggers(db) {
57
57
  const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
58
58
  const first = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_publish_tasks_insert'").get();
59
- const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST].map((kind) => `'${kind}'`).join(',')})`;
59
+ const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
60
60
  const agentMigration = tables.has('task_events') && !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'agent_assignment_adopted'");
61
61
  const scopeMigration = !db.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_supercode_card_agent_scopes_insert'").get();
62
62
  // A snapshot names its card's number: a store that numbers its cards republishes every card once, when that is watched.
@@ -64,11 +64,12 @@ export function publicationTriggers(db) {
64
64
  // A snapshot of a newer shape (SNAPSHOT_SHAPE) than the heads were published in: every card is published once again.
65
65
  const shapeMigration = Number(db.prepare('SELECT max(version) AS version FROM supercode_publication_shape').get()?.version ?? 0) < SNAPSHOT_SHAPE;
66
66
  const contentMigration = !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_comments_update'").get()?.sql.includes('fingerprint=NULL');
67
+ const archiveMigration = !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'archived'");
67
68
  db.exec('SAVEPOINT supercode_publication_marking');
68
69
  try {
69
70
  // Existing event triggers only watched reviews. Replace that filter once, preserving the savepoint rollback.
70
71
  if(contentMigration)for(const table of ['task_comments','task_events'])for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_${table}_${verb}`);
71
- if(agentMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
72
+ if(agentMigration||archiveMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
72
73
  for (const [table, columns] of MARKED) {
73
74
  if (!tables.has(table)) continue;
74
75
  // Reviews and agent assignment events change a snapshot: an update marks when the
@@ -80,7 +81,7 @@ export function publicationTriggers(db) {
80
81
  CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_update AFTER UPDATE ON ${table}${when(['OLD', 'NEW'])} BEGIN ${mark('NEW')} ${mark('OLD')} END;
81
82
  CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_delete AFTER DELETE ON ${table}${when(['OLD'])} BEGIN ${mark('OLD')} END;`);
82
83
  }
83
- if ((first || contentMigration || agentMigration || scopeMigration || numberMigration || shapeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
84
+ if ((first || contentMigration || archiveMigration || agentMigration || scopeMigration || numberMigration || shapeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
84
85
  SELECT id FROM tasks UNION SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed'
85
86
  EXCEPT SELECT task_id FROM supercode_publication_dirty`);
86
87
  if (shapeMigration) { db.exec('DELETE FROM supercode_publication_shape'); db.prepare('INSERT INTO supercode_publication_shape (version) VALUES (?)').run(SNAPSHOT_SHAPE); }
@@ -100,8 +101,8 @@ export function markRemovedHeads(db) {
100
101
  const RETAINED_CHANGES = 1024;
101
102
  // The changes one prune transaction deletes: a change's snapshot averaged about 190 KB and reached 3.7 MB, and deleting
102
103
  // every pruned change in one statement held the owner's event loop for 291 s (2026-10-21).
103
- const PRUNE_BATCH = 200;
104
- const PRUNE_BYTES = 8 * 1024 * 1024;
104
+ const PRUNE_BATCH = 16;
105
+ const PRUNE_BYTES = 1024 * 1024;
105
106
  const hash = value => createHash('sha256').update(value).digest('hex');
106
107
  const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
107
108
  const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
@@ -206,13 +207,10 @@ const perCard=db=>{
206
207
  };
207
208
  // The columns of a task row that change while its card runs and that no snapshot reads (cardSnapshot).
208
209
  const UNREAD_TASK_COLUMNS = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at']);
209
- // The events a snapshot reads: its reviews, its agents and its archive reason.
210
- const SNAPSHOT_EVENT_KINDS = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
211
210
  const fingerprintReads=db=>({
212
- // comments and events are only ever added or removed by supercode's writers; their bytes catch a document edit that
213
- // rewrites one in place; SQLite answers octet_length from a row's header, without reading its text
214
- comments:db.prepare('SELECT count(*) AS n,max(id) AS last,total(octet_length(body)) AS bytes FROM task_comments WHERE task_id=?'),
215
- events:db.prepare(`SELECT count(*) AS n,max(id) AS last,total(octet_length(payload)) AS bytes FROM task_events WHERE task_id=? AND kind IN ${SNAPSHOT_EVENT_KINDS}`),
211
+ // Comment/event inserts, updates and deletes invalidate the head's fingerprint in their native transaction.
212
+ // Even a same-length in-place edit forces a snapshot comparison; rereading every history row's header here adds
213
+ // no information and held the owner's loop for 1.9 s on one card's comment history.
216
214
  facts:db.prepare('SELECT facts_json,receipt_sequence,declared_event_id FROM supercode_card_agent_facts WHERE task_id=?'),
217
215
  scope:db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?'),
218
216
  // a run's metadata changes in place (its session's observations), so every run's snapshot columns are read whole
@@ -227,7 +225,7 @@ const fingerprintReads=db=>({
227
225
  function fingerprintOf(reads,rows,row,key,options){
228
226
  const task={};for(const [column,value] of Object.entries(row))if(!UNREAD_TASK_COLUMNS.has(column))task[column]=value;
229
227
  return hash(JSON.stringify([SNAPSHOT_SHAPE,SNAPSHOT_RELEASE,key,Boolean(options.nativeHolds),task,rows.own(row.id)??null,rows.number(row.id),rows.parents(row.id),rows.children(row.id),
230
- reads.comments.get(row.id),reads.events.get(row.id),reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
228
+ reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
231
229
  }
232
230
  /** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
233
231
  * (`VO-856`, store.mjs cardRef), null for a card with no number. */
@@ -270,7 +268,7 @@ export function recordCardPublications(db,actor,root,key) {
270
268
  db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
271
269
  const rows=perCard(db),reads=fingerprintReads(db),task=db.prepare('SELECT * FROM tasks WHERE id=?'),head=db.prepare('SELECT resource_id,digest,last_event_id,fingerprint FROM supercode_publication_heads WHERE resource_id=?');
272
270
  const lastEvent=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1');
273
- const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)');
271
+ const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at,snapshot_bytes) VALUES (?,?,?,?,?,?,?)');
274
272
  const upsert=db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id,fingerprint) VALUES (?,?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id,fingerprint=excluded.fingerprint');
275
273
  const compared=db.prepare('UPDATE supercode_publication_heads SET fingerprint=? WHERE resource_id=?');
276
274
  let changed=0;
@@ -285,10 +283,11 @@ export function recordCardPublications(db,actor,root,key) {
285
283
  if(previous?.digest===digest){compared.run(`${fingerprint}:${digest}`,id);continue;}
286
284
  const last=lastEvent.get(id);
287
285
  const kind=`task.${previous&&previous.digest!=='removed'?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
288
- const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now());
286
+ const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
289
287
  upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null,`${fingerprint}:${digest}`);changed++;
290
288
  }else if(previous&&previous.digest!=='removed'){
291
- const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',JSON.stringify({id,removed:true}),actor,Date.now());
289
+ const snapshot=JSON.stringify({id,removed:true});
290
+ const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
292
291
  db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=?,fingerprint=NULL WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),id);changed++;
293
292
  }
294
293
  }
@@ -330,7 +329,9 @@ export function prunePublications(db) {
330
329
  from=0;
331
330
  }
332
331
  const floor=Number(db.prepare('SELECT pruned_through FROM supercode_publication_log').get().pruned_through);
333
- const candidates=db.prepare(`SELECT sequence,octet_length(snapshot) AS bytes FROM supercode_publications p WHERE sequence>? AND sequence<=? AND ${OLDER} ORDER BY sequence LIMIT ?`).all(from,floor,PRUNE_BATCH);
332
+ // New rows carry their byte count. Legacy rows fall back one small page at a time; never backfill old snapshots
333
+ // during an ordinary verb or scan hundreds of snapshot headers before the byte budget can be applied.
334
+ const candidates=db.prepare(`SELECT sequence,COALESCE(snapshot_bytes,octet_length(snapshot)) AS bytes FROM supercode_publications p WHERE sequence>? AND sequence<=? AND ${OLDER} ORDER BY sequence LIMIT ?`).all(from,floor,PRUNE_BATCH);
334
335
  const batch=[];let bytes=0;
335
336
  for(const row of candidates){
336
337
  // A single oversized row still makes progress, in a transaction of its own.
@@ -0,0 +1,30 @@
1
+ // Local reads whose cost must follow the matching keys, not the board's accumulated history.
2
+ import { setImmediate as yieldToOwner } from 'node:timers/promises';
3
+
4
+ /** One reverse index seek per kind, then one payload read. ORDER BY id over an IN-kind filter can instead choose
5
+ * the task/id index and read unrelated event payloads to reject their kinds. Kinds and columns are code constants. */
6
+ export function latestEvent(db, task, kinds, columns = 'payload') {
7
+ const seeks = kinds.map(kind => `SELECT (SELECT id FROM task_events WHERE task_id=? AND kind='${kind}' ORDER BY id DESC LIMIT 1) AS id`);
8
+ return db.prepare(`SELECT ${columns} FROM task_events WHERE id=(SELECT MAX(id) FROM (${seeks.join(' UNION ALL ')}))`).get(...kinds.map(() => task));
9
+ }
10
+
11
+ /** Read run history in finite, indexed pages, yielding before each page. The candidate page precedes any JSON
12
+ * predicate, so a page that matches nothing still reads at most 32 rows. Mutations must re-read their target. */
13
+ export async function readRunPages(db, columns, { task = null, where = '1' } = {}) {
14
+ const high = Number(db.prepare('SELECT MAX(id) AS id FROM task_runs').get()?.id ?? 0);
15
+ const out = [];
16
+ let cursor = 0;
17
+ while (cursor < high) {
18
+ await yieldToOwner();
19
+ const ids = db.prepare(`SELECT id FROM task_runs WHERE id>? AND id<=?${task ? ' AND task_id=?' : ''} ORDER BY id LIMIT 32`)
20
+ .all(cursor, high, ...(task ? [task] : []));
21
+ if (!ids.length) break;
22
+ cursor = ids.at(-1).id;
23
+ out.push(...db.prepare(`SELECT ${columns} FROM task_runs WHERE id IN (SELECT value FROM json_each(?)) AND (${where}) ORDER BY id`)
24
+ .all(JSON.stringify(ids.map(row => row.id))));
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** Unended runs plus ended runs a card still names current. Both arms start from small indexed sets. */
30
+ export const ACTIVE_RUN_IDS = 'SELECT id FROM task_runs WHERE ended_at IS NULL UNION SELECT current_run_id FROM tasks WHERE current_run_id IS NOT NULL';
package/board/store.mjs CHANGED
@@ -221,6 +221,11 @@ export function listBoards(root) {
221
221
  // SQLite uses an expression index only for a statement that writes the expression the same way.
222
222
  const RUN_SESSION = "json_extract(metadata, '$.supercode.session_id')";
223
223
  const RUN_ADDRESS = "json_extract(metadata, '$.supercode.address')";
224
+ // Notification records historically skipped malformed metadata through the JS parser. Keep that behavior in both
225
+ // the lookup and its indexes, including a legacy store that cannot create the older unguarded JSON indexes.
226
+ const NOTICE_SESSION = `CASE WHEN json_valid(metadata,6) THEN ${RUN_SESSION} END`;
227
+ const NOTICE_ADDRESS = `CASE WHEN json_valid(metadata,6) THEN ${RUN_ADDRESS} END`;
228
+ export const NOTICE_RUN_IDS = `SELECT id FROM task_runs WHERE ${NOTICE_SESSION}=? UNION SELECT id FROM task_runs WHERE ${NOTICE_ADDRESS}=?`;
224
229
  const RUN_LAUNCH_PENDING = "json_extract(metadata, '$.supercode.launch_pending')";
225
230
  export const CALLER_RUN_ADDRESS = `SELECT ${RUN_ADDRESS} AS address FROM task_runs WHERE ${RUN_SESSION} = ? ORDER BY id DESC LIMIT 1`;
226
231
  // Launch/adoption commits the native address and its immutable role-agent together.
@@ -242,6 +247,10 @@ const RUN_LAUNCH_PENDING_INDEX = 'idx_runs_supercode_launch_pending';
242
247
  const RUN_INDEXES = [
243
248
  [RUN_SESSION_INDEX, `(${RUN_SESSION}, id, ${RUN_ADDRESS})`],
244
249
  [RUN_LAUNCH_PENDING_INDEX, `(id) WHERE ${RUN_LAUNCH_PENDING} = 1`],
250
+ ['idx_runs_notice_session', `(${NOTICE_SESSION}, id)`],
251
+ ['idx_runs_notice_address', `(${NOTICE_ADDRESS}, id)`],
252
+ ['idx_runs_open_machine_session', `(json_extract(metadata, '$.supercode.machine'), ${RUN_SESSION}, id) WHERE ended_at IS NULL`],
253
+ ['idx_runs_open_machine_pane', `(json_extract(metadata, '$.supercode.machine'), json_extract(metadata, '$.supercode.pane'), id) WHERE ended_at IS NULL`],
245
254
  ];
246
255
 
247
256
  // What every verb a session runs asks about its caller (cli.mjs ownCards, dispatch.mjs reconcileCallerLaunch). Each
@@ -395,6 +404,13 @@ export function layOut(db, { fresh }) {
395
404
  // each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
396
405
  // any statement is prepared; no statement names it, so a store without it is only slower.
397
406
  db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
407
+ // Current-run joins and unended-run reconciliation must never search all cards/runs for each historical run.
408
+ db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_current_run ON tasks(current_run_id) WHERE current_run_id IS NOT NULL');
409
+ db.exec('CREATE INDEX IF NOT EXISTS idx_runs_unended ON task_runs(id) WHERE ended_at IS NULL');
410
+ // Ordinary comments vastly outnumber explicit source declarations. Keep their JSON discriminator in a small index.
411
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_arc_sources ON task_events(task_id,id) WHERE kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array'");
412
+ // Capacity pages need these columns without visiting task body/result overflow pages.
413
+ db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_capacity ON tasks(id,status,assignee,current_run_id,model_override)');
398
414
  layOutNumbers(db);
399
415
  layOutEventIndex(db);
400
416
  layOutHistoryIndexes(db);
@@ -20,6 +20,7 @@ import { evaluate, holds, render, needsSources } from './expr.mjs';
20
20
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
21
21
  import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
22
22
  import { cardNumber, identifyNotice, numbered } from './notices.mjs';
23
+ import { latestEvent } from './queries.mjs';
23
24
 
24
25
  const loaded = new Map();
25
26
  // the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
@@ -189,7 +190,7 @@ export function completionOwnerOf(db, id) {
189
190
 
190
191
  /** A card's explicitly named review profile, retained in its own history. */
191
192
  export function reviewerOf(db, id) {
192
- return json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind IN ('created','reviewer_set') ORDER BY id DESC LIMIT 1", id)?.payload, {})?.reviewer ?? null;
193
+ return json(latestEvent(db, id, ['created', 'reviewer_set'])?.payload, {})?.reviewer ?? null;
193
194
  }
194
195
 
195
196
  export function latestReview(db, id) {
@@ -469,7 +470,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
469
470
  get latest_review() { return latestReview(db, task.id) ?? {}; },
470
471
  get review_candidate() { return reviewCandidate(board, task.id) ?? {}; },
471
472
  ci: completionEvidence(board, task.id),
472
- get dispatch_hold() { return one(db, "SELECT kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1", task.id)?.kind === 'dispatch_held'; },
473
+ get dispatch_hold() { return latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'kind')?.kind === 'dispatch_held'; },
473
474
  status,
474
475
  blocked_by: blocks,
475
476
  paused: json(state.paused, null),
@@ -1140,6 +1141,13 @@ const ACTIONS = {
1140
1141
  const task = cardOf(ctx.db, ctx.id);
1141
1142
  const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
1142
1143
  run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
1144
+ if (readable !== task.readable_id) {
1145
+ // Keep the obligation with the edit: a stopped dispatcher or interrupted caller must not lose the rename.
1146
+ // Delivery reads the current name, so an older edit can never restore an obsolete name.
1147
+ const effect = { kind: 'rename_card_agents', id: ctx.id };
1148
+ const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
1149
+ ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
1150
+ }
1143
1151
  }
1144
1152
  if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
1145
1153
  if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
@@ -1231,11 +1239,12 @@ const ACTIONS = {
1231
1239
  /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
1232
1240
  export function retireSupersededRuns(db, id, runId, session) {
1233
1241
  if (!session?.session_id && !session?.pane) return;
1234
- for (const old of all(db, `SELECT id, task_id, metadata FROM task_runs WHERE id < ? AND ended_at IS NULL
1235
- AND json_extract(metadata, '$.supercode.machine') IS ?
1236
- AND ((? IS NOT NULL AND json_extract(metadata, '$.supercode.session_id') = ?)
1237
- OR (? IS NOT NULL AND json_extract(metadata, '$.supercode.pane') = ?))`,
1238
- runId, session.machine ?? null, session.session_id ?? null, session.session_id ?? null, session.pane ?? null, session.pane ?? null)) {
1242
+ for (const old of all(db, `SELECT id,task_id,metadata FROM task_runs WHERE id IN (
1243
+ SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1244
+ AND json_extract(metadata, '$.supercode.session_id')=?
1245
+ UNION SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1246
+ AND json_extract(metadata, '$.supercode.pane')=?)`,
1247
+ runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
1239
1248
  const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
1240
1249
  if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
1241
1250
  if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.209",
3
+ "version": "0.5.211",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {
@@ -32,10 +32,10 @@
32
32
  "check-types": "node scripts/generate-types.mjs --check"
33
33
  },
34
34
  "dependencies": {
35
- "@volter/supercode-teams": "^0.3.344",
35
+ "@volter/supercode-teams": "^0.3.346",
36
36
  "@volter/supercode-terminal": "^0.3.10",
37
37
  "@volter/supercode-harness-sdk": "^0.3.104",
38
- "@volter/teams": "0.2.98",
38
+ "@volter/teams": "0.2.99",
39
39
  "@volter/ztrack": "^2.4.23",
40
40
  "yaml": "^2.9.0"
41
41
  },
@@ -61,5 +61,6 @@
61
61
  "board",
62
62
  "apply",
63
63
  "README.md"
64
- ]
64
+ ],
65
+ "gitHead": "38f4a38369c31949d4d81fd3520a6aa678582ead"
65
66
  }