@volter/supercode-orchestrator 0.5.201 → 0.5.202

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
@@ -51,6 +51,12 @@ groups retained wire attempts by caller and endpoint, includes their purposes
51
51
  and outcomes, and shows the latest 32 response-header receipts. Unknown transport
52
52
  completion remains counted. Tokens and response bodies are not retained.
53
53
 
54
+ Board actions check their local role, guard and validator rules before GitHub.
55
+ Fresh source observations are reused only within that action, with at most eight
56
+ concurrent REST operations per process. Owner receipts retain the return code and
57
+ redacted refusal even when a failed call is fast. See
58
+ [acceptance latency readings](../../docs/evidence/board-acceptance-latency-2026-10-14.md).
59
+
54
60
  Board discovery caches advisory reads while waiting. Review requests, verdicts
55
61
  and acceptance refresh the complete source set, including previously draft or
56
62
  closed declared sources, and validate its current heads. An unread set defers
package/board/ci.mjs CHANGED
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
4
4
  import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
5
5
  import { dirname, join, relative } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
+ import { AsyncLocalStorage } from 'node:async_hooks';
7
8
  import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
8
9
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
9
10
  import { githubFetch } from './github-rest.mjs';
@@ -21,17 +22,51 @@ async function githubToken() {
21
22
  }
22
23
  /** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
23
24
  * read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
25
+ const actionReads = new AsyncLocalStorage();
26
+ /** Fresh observations belong to one action only; callers and later actions never share this snapshot. */
27
+ export const withFreshReviewReads = (fn) => actionReads.getStore() ? fn() : actionReads.run(new Map(), fn);
24
28
  async function github(path, all = false, { purpose = 'validation' } = {}) {
29
+ const reads = purpose === 'validation' && actionReads.getStore();
30
+ if (!reads) return githubUncached(path, all, { purpose });
31
+ const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${all}\0${path}`;
32
+ if (!reads.has(key)) reads.set(key, githubUncached(path, all, { purpose }));
33
+ return reads.get(key);
34
+ }
35
+ // Bound actual HTTP/body I/O across all concurrent actions and discovery in this process.
36
+ let activeReads = 0;
37
+ const waitingReads = [];
38
+ async function wireRead(fn) {
39
+ if (activeReads >= 8) await new Promise(resolve => waitingReads.push(resolve)); else activeReads++;
40
+ try { return await fn(); }
41
+ finally { const next = waitingReads.shift(); if (next) next(); else activeReads--; }
42
+ }
43
+ /** Preserve input order; after a failure drain active reads and issue no more work from this batch. */
44
+ async function readTogether(items, read) {
45
+ const out = new Array(items.length); let cursor = 0, failure;
46
+ await Promise.all(Array.from({ length: Math.min(8, items.length) }, async () => {
47
+ while (!failure && cursor < items.length) {
48
+ const i = cursor++;
49
+ try { out[i] = await read(items[i], i); } catch (error) { failure ??= error; }
50
+ }
51
+ }));
52
+ if (failure) throw failure;
53
+ return out;
54
+ }
55
+ async function githubUncached(path, all = false, { purpose = 'validation' } = {}) {
25
56
  const read = async () => {
26
57
  const token = await githubToken();
27
58
  let url = githubBase() + path, out = [];
28
59
  for (let page = 0; url && page < 10; page += 1) {
29
- const r = await githubFetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) }, { purpose });
30
- if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
31
- const body = await r.json();
60
+ const pageRead = await wireRead(async () => {
61
+ const r = await githubFetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) }, { purpose });
62
+ if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
63
+ return { body: await r.json(), next: /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null };
64
+ });
65
+ if (pageRead.error) return pageRead;
66
+ const body = pageRead.body;
32
67
  if (!all) return { body };
33
68
  out = out.concat(body);
34
- url = /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
69
+ url = pageRead.next;
35
70
  }
36
71
  return { body: out };
37
72
  };
@@ -80,9 +115,11 @@ export async function mergeReviewedPullRequest(url, reviewed) {
80
115
  const token = await githubToken();
81
116
  let answer;
82
117
  try {
83
- const r = await githubFetch(`${githubBase()}/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, { method: 'PUT', headers: githubHeaders(token, { 'content-type': 'application/json' }), body: JSON.stringify({ sha: reviewed, merge_method: 'merge' }), signal: AbortSignal.timeout(60_000) }, { purpose: 'mutation' });
84
- const text = await r.text();
85
- answer = { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
118
+ answer = await wireRead(async () => {
119
+ const r = await githubFetch(`${githubBase()}/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, { method: 'PUT', headers: githubHeaders(token, { 'content-type': 'application/json' }), body: JSON.stringify({ sha: reviewed, merge_method: 'merge' }), signal: AbortSignal.timeout(60_000) }, { purpose: 'mutation' });
120
+ const text = await r.text();
121
+ return { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
122
+ });
86
123
  } catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
87
124
  if (answer.status === 200 && answer.body?.merged) return { merged: true, sha: answer.body.sha };
88
125
  if (answer.status === 409) return { moved: true, head: null, message: answer.body?.message ?? null };
@@ -267,8 +304,9 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
267
304
  let branch = task?.branch_name ?? (current || null);
268
305
  const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
269
306
  || registered.some((r) => r.repository === repository && r.branch === head);
270
- const prs = [];
271
- for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))) {
307
+ const repositoriesToRead = [...new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))];
308
+ const groups = await readTogether(repositoriesToRead, async repository => {
309
+ const prs = [];
272
310
  // Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
273
311
  let list;
274
312
  try {
@@ -280,7 +318,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
280
318
  list = fresh ? await read() : await discoveryList(pullRequestLists, repository, 300_000, read);
281
319
  } catch (error) {
282
320
  if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
283
- continue; // An unread sibling stays unread: do not cache an empty successful result.
321
+ return []; // An unread sibling stays unread: do not cache an empty successful result.
284
322
  }
285
323
  // The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
286
324
  if (repository === ownRepository && branch) {
@@ -296,25 +334,26 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
296
334
  ...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
297
335
  ...registered.filter(r => r.repository === repository).map(r => r.branch),
298
336
  ])];
299
- for (const headBranch of historical) {
300
- if (headBranch === list.defaultBranch) continue;
301
- if (list.items.some(pr => pr.headRefName === headBranch)) continue;
302
- for (const pr of await mergedBranchPullRequests(repository, headBranch, { fresh })) {
303
- if (!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
304
- && pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid)
305
- && !prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
306
- }
337
+ const histories = await readTogether(historical, async headBranch => {
338
+ if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
339
+ return (await mergedBranchPullRequests(repository, headBranch, { fresh })).filter(pr =>
340
+ !pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
341
+ && pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid));
342
+ });
343
+ for (const history of histories) for (const pr of history) {
344
+ if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
307
345
  }
308
- }
346
+ return prs;
347
+ });
348
+ const prs = groups.flat();
309
349
  // A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
310
350
  // discovery cannot see; it never hides what discovery found. Only repositories the card's workspace or registered
311
351
  // branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
312
352
  // declared one, so a push after the declaration is reviewed instead of failing every tick.
313
353
  const declaration = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array' ORDER BY id DESC LIMIT 1").get(id);
314
354
  const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
315
- for (const source of declaration ? JSON.parse(declaration.payload).sources : []) {
316
- if (!repositories.has(repositoryOf(source.url).toLowerCase()) || prs.some((known) => known.url === source.url)) continue;
317
- const pr = await (fresh ? readPullRequest(source.url) : discoverPullRequest(source.url));
355
+ const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
356
+ for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
318
357
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
319
358
  if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
320
359
  }
@@ -423,6 +462,9 @@ export function judgedHead(board, id, head) {
423
462
  }
424
463
 
425
464
  export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
465
+ return withFreshReviewReads(() => inspectCurrentCompletionEvidence(board, id, entry, payload, stage));
466
+ }
467
+ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage) {
426
468
  const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
427
469
  try {
428
470
  if (stage === 'completion' || stage === 'acceptance') {
@@ -442,10 +484,10 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
442
484
  const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
443
485
  const wanted = candidatePullRequests(candidate);
444
486
  if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
445
- for (const w of wanted) {
487
+ await readTogether(wanted, async w => {
446
488
  const pr = await readPullRequest(w.pr.url);
447
489
  if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
448
- }
490
+ });
449
491
  Object.assign(state, { ...candidate, ready: true, reason: null });
450
492
  }
451
493
  return state;
@@ -458,7 +500,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
458
500
  // Merged source is immutable and needs no surviving workspace. Resolve an
459
501
  // unmerged candidate's checkout as before; a broken legacy path never
460
502
  // prevents a verdict on already merged commits.
461
- const read = await Promise.all(urls.map(readPullRequest));
503
+ const read = await readTogether(urls, readPullRequest);
462
504
  const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
463
505
  const own = mergedOnly ? null : await repositoryWorkspace(board, id);
464
506
  if (!own && !urls.length) {
@@ -482,7 +524,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
482
524
  : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
483
525
  if (stage === 'request_review' && named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
484
526
  if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
485
- const prs = await Promise.all(urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage)));
527
+ const prs = await readTogether(urls, (url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
486
528
  if (stage === 'request_review') {
487
529
  // A review is asked for only while some PR has a head no verdict has judged.
488
530
  if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('The PRs the last verdict judged have no new head; push a new head before requesting review.');
package/board/cli.mjs CHANGED
@@ -14,6 +14,7 @@ import { taskStatusOf } from './published.mjs';
14
14
  import { executionOf } from './session-work.mjs';
15
15
  import { listCardsInTurns, writeCardList } from './listing.mjs';
16
16
  import { writeOwnerAnswer } from './reply.mjs';
17
+ import { redactText } from '@volter/teams/redact';
17
18
  import { createServer } from 'node:net';
18
19
  import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
19
20
  import { notRuntime, ownHome } from './owner-files.mjs';
@@ -1611,10 +1612,14 @@ async function answerOne(root, line) {
1611
1612
  const began = performance.now();
1612
1613
  const before = slowLog.processCounters?.() ?? null;
1613
1614
  let named = null, by = null;
1614
- try { return await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); }
1615
+ let answer;
1616
+ try { answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); return answer; }
1615
1617
  finally {
1616
1618
  const ms = performance.now() - began;
1617
- if (named && ms > slowLog.SLOW_MS) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, slowest: null, extra: { caller: by, steps: slowLog.stepsWithin?.(began, performance.now()) ?? null, whole_process: before ? slowLog.processUse(before) : null } });
1619
+ if (named) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, force: answer?.code !== 0,
1620
+ outcome: answer ? answer.code === 0 ? 'ok' : `exit ${answer.code}` : 'unknown', slowest: null,
1621
+ extra: { caller: by, response: { code: answer?.code ?? null, stderr: redactText([].concat(answer?.stderr ?? []).join('')).slice(0, 2000) },
1622
+ steps: slowLog.stepsWithin?.(began, performance.now()) ?? null, whole_process: before ? slowLog.processUse(before) : null } });
1618
1623
  }
1619
1624
  }
1620
1625
 
@@ -28,7 +28,8 @@ import { localSessionDelegations } from '../agents.mjs';
28
28
  import { creatorOf, sessionProgress } from './workflow.mjs';
29
29
  import { boardLabel, boardName } from '@volter/teams/board-name';
30
30
  let fleetRoot = null;
31
- import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
31
+ import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
32
+ import { setImmediate as yieldToOwner } from 'node:timers/promises';
32
33
  import { runtimeDoor } from './runtime-door.mjs';
33
34
  import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,handOffAgents, agentRunForSession } from '../agents.mjs';
34
35
  import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
@@ -1936,6 +1937,7 @@ async function synchronizeAgents(board, report) {
1936
1937
  }
1937
1938
  }
1938
1939
  for(const assignment of cardAgentAssignments(board.root)){
1940
+ await yieldToOwner();
1939
1941
  const task=getTaskNow(board,assignment.target);
1940
1942
  if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
1941
1943
  else if(task)recordCardAgentAssignment(board,assignment);
@@ -2351,8 +2353,10 @@ const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_
2351
2353
  * only while that claim is the card's latest.
2352
2354
  */
2353
2355
  export async function completeAdoptions(board, fleet, settings, report, lanes = null) {
2354
- const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at FROM task_events e
2355
- WHERE ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
2356
+ const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at
2357
+ FROM (SELECT DISTINCT task_id FROM task_events) cards CROSS JOIN task_events e ON e.task_id=cards.task_id
2358
+ WHERE e.kind IN ('dispatch_held','adoption_claimed')
2359
+ AND ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
2356
2360
  AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ${ADOPTION_ANSWERS})`).all());
2357
2361
  for (const row of pending) {
2358
2362
  // a request another round has claimed is that round's while its claim holds; a claim whose round died is open again
@@ -2788,6 +2792,7 @@ async function notify(board, fleet, report, entry) {
2788
2792
  await endWorkerSubscriptions(board, fleet, report);
2789
2793
  const record = view(board, (db) => sessionRecord(db));
2790
2794
  for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
2795
+ await yieldToOwner();
2791
2796
  if (stopping) break;
2792
2797
  if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
2793
2798
  const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
@@ -2803,7 +2808,8 @@ async function notify(board, fleet, report, entry) {
2803
2808
  } else if (ended) continue; // no fleet reading: nothing is filed unread to a session recorded as ended
2804
2809
  const kinds = heardBy(entry, role);
2805
2810
  if (!kinds.length) continue;
2806
- 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`)
2811
+ // The retained cursor resumes the next page; one native read never materializes the whole unread history.
2812
+ 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`)
2807
2813
  .all(sub.task_id, sub.last_event_id, ...kinds);
2808
2814
  if (!events.length) continue;
2809
2815
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
@@ -2857,7 +2863,17 @@ async function notify(board, fleet, report, entry) {
2857
2863
  // a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
2858
2864
  // work is over, and its session may never run again), anyone's when it is archived
2859
2865
  const over = task && (task.stored_as === 'archived' || (role === 'worker' && task.stored_as === 'done'));
2860
- if (over && cursor >= events.at(-1).id) tx(board, (db) => db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity));
2866
+ if (over) tx(board, (db) => {
2867
+ const durable = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
2868
+ if (!durable) return;
2869
+ // A page's final event is not the end of the backlog. Check past the persisted cursor in the same
2870
+ // transaction as retirement, so another page (including the card's completion) is never abandoned.
2871
+ const remaining = db.prepare(`SELECT 1 FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) LIMIT 1`)
2872
+ .get(sub.task_id, durable.last_event_id, ...kinds);
2873
+ const current = getTask(db, sub.task_id);
2874
+ if (!remaining && current && (current.status === 'archived' || (role === 'worker' && current.status === 'done')))
2875
+ db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
2876
+ });
2861
2877
  }
2862
2878
  }
2863
2879
 
@@ -3184,10 +3200,17 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3184
3200
  }
3185
3201
  if (!dry) await refreshCardInputs(board, id, { commits: true });
3186
3202
  // Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
3187
- payload = await openReviewPullRequests(board, id, { fresh: true });
3188
- if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
3203
+ const observed = await withFreshReviewReads(async () => {
3204
+ const candidate = await openReviewPullRequests(board, id, { fresh: true })
3205
+ ?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id) : null);
3206
+ const evidence = !candidate || candidate.waits ? null : candidate.no_code
3207
+ ? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
3208
+ : await inspectCompletionEvidence(board, id, entry, candidate, 'request_review');
3209
+ return { candidate, evidence };
3210
+ });
3211
+ payload = observed.candidate;
3189
3212
  if (!payload || payload.waits) { report.queued.push([id, `review waits: ${payload?.waits ?? 'the current source set has no review candidate'}`]); payload = null; continue; }
3190
- const evidence = payload.no_code ? { required: false, ready: true, reason: null, phase: 'source', no_code: payload.no_code } : await inspectCompletionEvidence(board, id, entry, payload, 'request_review');
3213
+ const evidence = observed.evidence;
3191
3214
  if (!evidence.ready) throw new Error(evidence.reason);
3192
3215
  // A dry run names the complete validated review a tick would request and writes nothing.
3193
3216
  if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
package/board/engine.mjs CHANGED
@@ -16,7 +16,7 @@ import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspac
16
16
  import { DatabaseSync } from 'node:sqlite';
17
17
  import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
18
18
  import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
19
- import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
19
+ import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
20
20
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
21
21
  import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
22
22
 
@@ -432,6 +432,8 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
432
432
  if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
433
433
  refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
434
434
  await refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
435
+ if (['complete', 'request_review', 'request_state'].includes(name)) view(board, db => validateEvent(db, board, entry, id, name,
436
+ { roles: roles ?? callerRoles(db, id), payload, extra, localOnly: true }));
435
437
  const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
436
438
  if (evidencePhase) payload = { ...payload, ci: await observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
437
439
  if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
@@ -459,6 +461,7 @@ export async function requestStateMany(board, ids, payload = {}) {
459
461
  const prepared = new Map(await Promise.all(targets.map(async id => {
460
462
  const roles = callerRoles(board.db, id);
461
463
  const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
464
+ view(board, db => validateEvent(db, board, entry, id, 'request_state', { roles, payload, localOnly: true }));
462
465
  const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
463
466
  // An ordinary request mails a decision to the manager, not a completion.
464
467
  // Keep a failed source observation in that mail. A reviewer recording an
package/board/expr.mjs CHANGED
@@ -129,6 +129,45 @@ function compiled(text) {
129
129
 
130
130
  const own = (o, k) => o !== null && o !== undefined && (typeof o === 'object' || typeof o === 'function') && Object.prototype.hasOwnProperty.call(o, k);
131
131
 
132
+ /** Whether an expression may read observed sources, including through derived card fields. Unknown access waits. */
133
+ export function needsSources(text, derived = {}) {
134
+ const visiting = new Set();
135
+ const field = (root, key) => {
136
+ if (key === 'ci') return true;
137
+ if (root !== 'card' || !own(derived, key)) return false;
138
+ if (visiting.has(key)) return true;
139
+ visiting.add(key);
140
+ const dependent = expression(derived[key]);
141
+ visiting.delete(key);
142
+ return dependent;
143
+ };
144
+ const root = e => e.k === 'name' && ['card', 'event'].includes(e.v);
145
+ const visit = e => {
146
+ switch (e.k) {
147
+ case 'lit': return false;
148
+ case 'name': return root(e); // passing the whole object can expose its sources
149
+ case 'get': return root(e.on)
150
+ ? e.key.k !== 'lit' || field(e.on.v, e.key.v)
151
+ : visit(e.on) || visit(e.key);
152
+ case 'method': return (root(e.on)
153
+ ? e.m === 'size' || field(e.on.v, e.m)
154
+ : visit(e.on)) || e.args.some(visit);
155
+ case 'list': return e.items.some(visit);
156
+ case 'call': return e.args.some(visit);
157
+ case 'macro': return visit(e.on) || visit(e.body);
158
+ case 'not': case 'neg': return visit(e.e);
159
+ case 'bin': return visit(e.l) || visit(e.r);
160
+ case 'if': return visit(e.c) || visit(e.a) || visit(e.b);
161
+ default: return true;
162
+ }
163
+ };
164
+ const expression = value => {
165
+ if (value === null || value === undefined || String(value).trim() === '') return false;
166
+ try { return visit(compiled(String(value))); } catch { return true; }
167
+ };
168
+ return expression(text);
169
+ }
170
+
132
171
  function run(e, scope) {
133
172
  switch (e.k) {
134
173
  case 'lit': return e.v;
@@ -14,7 +14,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
14
14
  import { randomBytes } from 'node:crypto';
15
15
  import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
16
16
  import { releaseAllSideSessions } from './side-sessions.mjs';
17
- import { evaluate, holds, render } from './expr.mjs';
17
+ import { evaluate, holds, render, needsSources } from './expr.mjs';
18
18
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
19
19
  import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
20
20
  import { identifyNotice } from './notices.mjs';
@@ -383,7 +383,7 @@ function timeOf(db, task) {
383
383
  function askedOf(id) { return mailboxAnswers.get(id)?.askedOf ?? null; }
384
384
 
385
385
  /** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
386
- export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy = false } = {}) {
386
+ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy = false, deferSources = false } = {}) {
387
387
  // Only read-only guards opt into demand reads. Mutation scopes materialize before any write,
388
388
  // keeping `before` and run-history fields pinned to their original snapshot.
389
389
  const once = (read) => { let done = false, value; return () => { if (!done) { value = read(); done = true; } return value; }; };
@@ -486,8 +486,10 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
486
486
  Object.defineProperty(card, key, { get: read, enumerable: true, configurable: true });
487
487
  if (!lazy) read();
488
488
  }
489
- for (const [key, expr] of Object.entries(loaded.get(board.root)?.workflow?.derived ?? {})) {
490
- const value = evaluate(expr, { card, event, params, now: now() });
489
+ const derived = loaded.get(board.root)?.workflow?.derived ?? {};
490
+ for (const [key, expr] of Object.entries(derived)) {
491
+ // Preflight neither evaluates nor exposes a derived source value from a stale/missing observation.
492
+ const value = deferSources && needsSources(expr, derived) ? undefined : evaluate(expr, { card, event, params, now: now() });
491
493
  Object.defineProperty(card, key, { value, writable: true, enumerable: true, configurable: true });
492
494
  }
493
495
  return {
@@ -594,7 +596,7 @@ function declaredStatus(db, board, entry, task) {
594
596
  * takes only a sender in one of its roles; `platform` for the dispatcher's own observations). Answers
595
597
  * `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
596
598
  */
597
- export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
599
+ export function validateEvent(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, localOnly = false } = {}) {
598
600
  // A card ends with its reason: a cancel or archive that says nothing why is refused, whoever sends it, the manager
599
601
  // included. The reason is all the next reader of an ended card has (an empty-reason cancel once ended an arc that had
600
602
  // shipped half, and nothing on the card said so).
@@ -603,14 +605,14 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
603
605
  // archive: that ends it the way the document already has, and is the one way a manager clears it.
604
606
  if (db.boardErrors?.has(id) && !(/cannot change card removal/.test(db.boardErrors.get(id)) && ['cancel', 'archive'].includes(name) && roles.includes('manager'))) throw new Error(`${id}: ${db.boardErrors.get(id)}`);
605
607
  const { workflow, params } = entry;
606
- if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return fireRun(db, board, entry, id, name, { roles, payload, extra });
608
+ if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return null;
607
609
  const task = cardOf(db, id);
608
- const beforeRun = noticeRun(db, task);
609
- const from = declaredStatus(db, board, entry, task);
610
+ const from = localOnly ? statusOf(db, task) : declaredStatus(db, board, entry, task);
611
+ if (localOnly && !workflow.statuses[from]) return null; // only fire adopts an imported status
610
612
  const candidates = transitionsFor(workflow, from, name);
611
613
  if (!candidates.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there`);
612
614
  // `sender`: the roles the event was sent with (a validator can ask who is asking)
613
- const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
615
+ const scope = scopeOf(db, board, task, { params, event: payload, lazy: localOnly, deferSources: localOnly, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
614
616
  const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
615
617
  if (!allowed.length) {
616
618
  const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
@@ -618,6 +620,10 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
618
620
  ? ` Ask the manager with workflow request-state ${id} --state ${state} --reason <why> --evidence <verdict-and-pointers>.` : '';
619
621
  throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}${request}`);
620
622
  }
623
+ // A source-dependent branch is undecidable before observation; preflight must not choose its fallback.
624
+ const needsSource = expr => needsSources(expr, workflow.derived);
625
+ // Other roles' guards also determine the refusal below; none may decide it using stale sources.
626
+ if (localOnly && candidates.some(t => needsSource(t.guard))) return null;
621
627
  const taken = allowed.find((t) => holds(t.guard ?? null, scope));
622
628
  // none of the sender's rules holds, but another role's does (one verb with a rule per case, as `set` has one per
623
629
  // field): it is that role's to send
@@ -633,8 +639,17 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
633
639
  none.noTransition = true; throw none;
634
640
  }
635
641
  for (const v of taken.validate ?? []) {
642
+ if (localOnly && needsSource(v.expr)) continue;
636
643
  if (!holds(v.expr, scope)) { const reason = render(v.message, scope); const refusal = new Error(reason); refusal.refusal = reason; throw refusal; }
637
644
  }
645
+ return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
646
+ }
647
+
648
+ export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
649
+ const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
650
+ if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
651
+ const { task, from, taken, scope, beforeRun } = checked;
652
+ const { params } = entry;
638
653
  const effects = [];
639
654
  const to = take(db, board, entry, task, from, taken, scope, effects);
640
655
  settle(db, board, entry, id, effects);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.201",
3
+ "version": "0.5.202",
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": {
@@ -28,9 +28,9 @@
28
28
  "check-types": "node scripts/generate-types.mjs --check"
29
29
  },
30
30
  "dependencies": {
31
- "@volter/supercode-teams": "^0.3.333",
31
+ "@volter/supercode-teams": "^0.3.334",
32
32
  "@volter/supercode-terminal": "^0.3.8",
33
- "@volter/supercode-harness-sdk": "^0.3.101",
33
+ "@volter/supercode-harness-sdk": "^0.3.102",
34
34
  "@volter/teams": "0.2.93",
35
35
  "@volter/ztrack": "^2.4.23",
36
36
  "yaml": "^2.9.0"