@volter/supercode-orchestrator 0.5.200 → 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
@@ -42,6 +42,33 @@ Rules the codec keeps (enforced in Rust, observed here):
42
42
  since import (UNI-22); it copies the store byte for byte when they did not.
43
43
  - Files the IR does not model are never touched by `save` and are carried by path on a migration.
44
44
 
45
+ ## Board GitHub REST receipts
46
+
47
+ `supercode workflow github-usage --since 1h --json` reads this user's local
48
+ `github-rest.sqlite` in `SUPERCODE_HOME` (default: `~/.config/supercode`, respecting
49
+ `XDG_CONFIG_HOME`). It needs neither a running board nor a GitHub request. It
50
+ groups retained wire attempts by caller and endpoint, includes their purposes
51
+ and outcomes, and shows the latest 32 response-header receipts. Unknown transport
52
+ completion remains counted. Tokens and response bodies are not retained.
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
+
60
+ Board discovery caches advisory reads while waiting. Review requests, verdicts
61
+ and acceptance refresh the complete source set, including previously draft or
62
+ closed declared sources, and validate its current heads. An unread set defers
63
+ the action. Unread commit dates also defer eligibility and are never cached as
64
+ old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
65
+ reserves its own attempt. All processes and credentials sharing this supercode
66
+ home share a rolling-hour ceiling of 1,200 attempts, including at most 600
67
+ discovery attempts. Endpoint exhaustion/Retry-After also defers subsequent work.
68
+ A deferred source remains unknown, with its reason; it is not an empty success.
69
+ Other homes and external GitHub clients are outside this ledger and ceiling.
70
+ See [F104 evidence and qualification limits](../../docs/evidence/f104-github-2026-10-14.md).
71
+
45
72
  ## Local native session recovery
46
73
 
47
74
  `supercode sessions recover --once --retry-after 300` runs a bounded opt-in
package/board/ci.mjs CHANGED
@@ -4,8 +4,10 @@ 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';
10
+ import { githubFetch } from './github-rest.mjs';
9
11
 
10
12
  // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
11
13
  // waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
@@ -20,26 +22,60 @@ async function githubToken() {
20
22
  }
21
23
  /** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
22
24
  * read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
23
- async function github(path, all = false) {
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);
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' } = {}) {
24
56
  const read = async () => {
25
57
  const token = await githubToken();
26
58
  let url = githubBase() + path, out = [];
27
59
  for (let page = 0; url && page < 10; page += 1) {
28
- const r = await fetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) });
29
- if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
30
- 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;
31
67
  if (!all) return { body };
32
68
  out = out.concat(body);
33
- url = /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
69
+ url = pageRead.next;
34
70
  }
35
71
  return { body: out };
36
72
  };
37
73
  const started = performance.now();
38
74
  let result;
39
- try { result = await read(); } catch (error) { result = { error: String(error.message) }; }
40
- if (result.status === 401 && !(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) && loginToken) { loginToken = null; try { result = await read(); } catch (error) { result = { error: String(error.message) }; } }
75
+ try { result = await read(); } catch (error) { result = { error: String(error.message), cause: error }; }
76
+ if (result.status === 401 && !(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) && loginToken) { loginToken = null; try { result = await read(); } catch (error) { result = { error: String(error.message), cause: error }; } }
41
77
  noteStep('github read', performance.now() - started);
42
- if (result.error) throw new Error(`GitHub: ${result.error.trim().slice(-500)}`);
78
+ if (result.error) throw Object.assign(new Error(`GitHub: ${result.error.trim().slice(-500)}`, { cause: result.cause }), result.cause?.code ? { code: result.cause.code } : {});
43
79
  return result.body;
44
80
  }
45
81
  const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
@@ -48,6 +84,22 @@ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
48
84
  const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
49
85
  headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
50
86
  const readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
87
+ // Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
88
+ // still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
89
+ const discoveredPullRequests = new Map();
90
+ export async function discoverPullRequest(url) {
91
+ const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${url}`;
92
+ let held = discoveredPullRequests.get(key);
93
+ if (held && Date.now() < held.until) return held.pending;
94
+ if (discoveredPullRequests.size >= 4096 && !held) discoveredPullRequests.delete(discoveredPullRequests.keys().next().value);
95
+ held = { until: Date.now() + 60_000 };
96
+ held.pending = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: 'discovery' })
97
+ .then(body => { const pr = pullRequest(repositoryOf(url), body); held.until = Date.now() + (pr.state === 'MERGED' ? 3600_000 : 60_000); return pr; });
98
+ // Failed reads stay failed for a minute; no cached empty source or invented successful head.
99
+ void held.pending.catch(() => {});
100
+ discoveredPullRequests.set(key, held);
101
+ return held.pending;
102
+ }
51
103
 
52
104
  /**
53
105
  * Merge a reviewed PR at the head its review judged, and only there: GitHub refuses the merge when the PR's head is no
@@ -63,9 +115,11 @@ export async function mergeReviewedPullRequest(url, reviewed) {
63
115
  const token = await githubToken();
64
116
  let answer;
65
117
  try {
66
- const r = await fetch(`${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) });
67
- const text = await r.text();
68
- 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
+ });
69
123
  } catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
70
124
  if (answer.status === 200 && answer.body?.merged) return { merged: true, sha: answer.body.sha };
71
125
  if (answer.status === 409) return { moved: true, head: null, message: answer.body?.message ?? null };
@@ -165,20 +219,29 @@ async function recreatedWorktree(workspace, resolved) {
165
219
 
166
220
  const pullRequestLists = new Map();
167
221
  const mergedPullRequestLists = new Map();
222
+ // Keep in-flight and failed list reads too, with room for the registered branch set. History changes rarely;
223
+ // ordinary open work is discovered by the repository's open-PR listing.
224
+ async function discoveryList(cache, key, ttl, read) {
225
+ key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
226
+ let held = cache.get(key);
227
+ if (held && Date.now() < held.until) return held.pending;
228
+ if (cache.size >= 4096 && !held) cache.delete(cache.keys().next().value);
229
+ held = { until: Date.now() + ttl };
230
+ held.pending = Promise.resolve().then(read).catch(error => { held.until = Date.now() + 60_000; throw error; });
231
+ void held.pending.catch(() => {}); cache.set(key, held);
232
+ return held.pending;
233
+ }
168
234
 
169
235
  /** Historical work can already be merged when an outage prevented the board's
170
236
  * review request. Read only the arc's declared branch, never all closed PRs. */
171
- async function mergedBranchPullRequests(repository, branch) {
237
+ async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
172
238
  const key = `${repository}:${branch}`;
173
- let list = mergedPullRequestLists.get(key);
174
- if (!list || Date.now() - list.at >= 60_000) {
239
+ const read = async () => {
175
240
  const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
176
- const items = (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true))
241
+ return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
177
242
  .filter(p => p.merged_at).map(p => pullRequest(repository, p));
178
- if (mergedPullRequestLists.size >= 64) mergedPullRequestLists.delete(mergedPullRequestLists.keys().next().value);
179
- list = { at: Date.now(), items }; mergedPullRequestLists.set(key, list);
180
- }
181
- return list.items;
243
+ };
244
+ return fresh ? read() : discoveryList(mergedPullRequestLists, key, 900_000, read);
182
245
  }
183
246
 
184
247
  async function branchCheckout(board, id, repository, branch) {
@@ -230,7 +293,7 @@ export function declaredWorkBranches(db, id) {
230
293
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
231
294
  * 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
232
295
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
233
- export async function openReviewPullRequests(board, id) {
296
+ export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
234
297
  const own = await repositoryWorkspace(board, id);
235
298
  const registered = registeredBranches(board, id);
236
299
  if (!own && !registered.length) return null;
@@ -241,20 +304,21 @@ export async function openReviewPullRequests(board, id) {
241
304
  let branch = task?.branch_name ?? (current || null);
242
305
  const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
243
306
  || registered.some((r) => r.repository === repository && r.branch === head);
244
- const prs = [];
245
- for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories())].filter(Boolean))) {
246
- // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
247
- let list = pullRequestLists.get(repository);
248
- if (!list || Date.now() - list.at >= 60_000) {
249
- let items = [], defaultBranch = null;
250
- try {
251
- items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true)).map((p) => pullRequest(repository, p));
252
- defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch ?? (await github(`/repos/${repository}`)).default_branch;
253
- }
254
- catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
255
- list = { at: Date.now(), items, defaultBranch };
256
- if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
257
- pullRequestLists.set(repository, list);
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 = [];
310
+ // Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
311
+ let list;
312
+ try {
313
+ const read = async () => {
314
+ const items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map((p) => pullRequest(repository, p));
315
+ const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch ?? (await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch;
316
+ return { items, defaultBranch };
317
+ };
318
+ list = fresh ? await read() : await discoveryList(pullRequestLists, repository, 300_000, read);
319
+ } catch (error) {
320
+ if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
321
+ return []; // An unread sibling stays unread: do not cache an empty successful result.
258
322
  }
259
323
  // The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
260
324
  if (repository === ownRepository && branch) {
@@ -270,25 +334,26 @@ export async function openReviewPullRequests(board, id) {
270
334
  ...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
271
335
  ...registered.filter(r => r.repository === repository).map(r => r.branch),
272
336
  ])];
273
- for (const headBranch of historical) {
274
- if (headBranch === list.defaultBranch) continue;
275
- if (list.items.some(pr => pr.headRefName === headBranch)) continue;
276
- for (const pr of await mergedBranchPullRequests(repository, headBranch)) {
277
- if (!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
278
- && pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid)
279
- && !prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
280
- }
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 });
281
345
  }
282
- }
346
+ return prs;
347
+ });
348
+ const prs = groups.flat();
283
349
  // A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
284
350
  // discovery cannot see; it never hides what discovery found. Only repositories the card's workspace or registered
285
351
  // branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
286
352
  // declared one, so a push after the declaration is reviewed instead of failing every tick.
287
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);
288
354
  const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
289
- for (const source of declaration ? JSON.parse(declaration.payload).sources : []) {
290
- if (!repositories.has(repositoryOf(source.url).toLowerCase()) || prs.some((known) => known.url === source.url)) continue;
291
- const pr = await readPullRequest(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))) {
292
357
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
293
358
  if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
294
359
  }
@@ -325,14 +390,16 @@ export async function noCodeChange(board, id) {
325
390
  const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
326
391
  if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
327
392
  const owner = repository.split('/')[0];
393
+ // An empty PR history can authorize a no-code review; keep this validation fresh too.
328
394
  if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
329
395
  return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
330
- } catch { return null; }
396
+ } catch (error) { if (error.code === 'GITHUB_BUDGET') throw error; return null; }
331
397
  }
332
398
 
333
399
  /** The repositories this machine keeps a checkout of. */
334
- async function knownRepositories() {
335
- try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; } catch { return []; }
400
+ async function knownRepositories({ fresh = false } = {}) {
401
+ try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
402
+ catch (error) { if (fresh) throw error; return []; }
336
403
  }
337
404
 
338
405
  /** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
@@ -358,22 +425,28 @@ export async function reviewDue(board, id, prs) {
358
425
  const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
359
426
  if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
360
427
  if (last.verdict === 'review_pass' && unjudged.some((p) => !judged.some((j) => j.pr.url === p.url))) return true;
361
- for (const p of unjudged) if (((await commitTime(p)) ?? 0) > Number(last.verdict_at)) return true;
428
+ for (const p of unjudged) if ((await commitTime(p)) > Number(last.verdict_at)) return true;
362
429
  return false;
363
430
  }
364
431
 
365
432
  const commitTimes = new Map();
366
- /** When a PR's head was committed (seconds), read from GitHub once per head; null when it cannot be read. */
433
+ /** Cache successful immutable commit dates only. An unread date is unknown, never evidence of old work. */
367
434
  async function commitTime({ url, head }) {
368
- if (commitTimes.has(head)) return commitTimes.get(head);
369
- let at = null;
370
- try {
371
- at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit.committer.date) / 1000) || null;
372
- } catch { /* unreadable: not counted as new */ }
373
- commitTimes.set(head, at);
435
+ const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
436
+ if (commitTimes.has(key)) return commitTimes.get(key);
437
+ const at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit?.committer?.date) / 1000);
438
+ if (!Number.isFinite(at)) throw new Error(`GitHub commit date for ${url} at ${head} is unreadable; review eligibility is unknown`);
439
+ commitTimes.set(key, at);
374
440
  return at;
375
441
  }
376
442
 
443
+ /** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
444
+ async function requireCurrentReviewSet(board, id, urls) {
445
+ const current = await openReviewPullRequests(board, id, { fresh: true });
446
+ const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
447
+ if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review, recording its verdict or accepting it`);
448
+ }
449
+
377
450
  /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
378
451
  * a review request naming that head, before the next request. A head is reviewed once. */
379
452
  export function judgedHead(board, id, head) {
@@ -389,6 +462,9 @@ export function judgedHead(board, id, head) {
389
462
  }
390
463
 
391
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) {
392
468
  const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
393
469
  try {
394
470
  if (stage === 'completion' || stage === 'acceptance') {
@@ -400,6 +476,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
400
476
  }
401
477
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
402
478
  if (candidate) {
479
+ await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url));
403
480
  const row = board.db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
404
481
  if (!row) throw new Error('The current candidate needs its independent verdict before acceptance.');
405
482
  const voided = board.db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='review_voided' ORDER BY id DESC LIMIT 1").get(id);
@@ -407,22 +484,23 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
407
484
  const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
408
485
  const wanted = candidatePullRequests(candidate);
409
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.');
410
- for (const w of wanted) {
487
+ await readTogether(wanted, async w => {
411
488
  const pr = await readPullRequest(w.pr.url);
412
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.`);
413
- }
490
+ });
414
491
  Object.assign(state, { ...candidate, ready: true, reason: null });
415
492
  }
416
493
  return state;
417
494
  }
418
495
  const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
419
496
  const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
497
+ if (stage === 'request_review' || stage === 'review_verdict') await requireCurrentReviewSet(board, id, urls);
420
498
  if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
421
499
  if (urls.some(url => typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url))) throw new Error('Name each GitHub PR URL with --pr.');
422
500
  // Merged source is immutable and needs no surviving workspace. Resolve an
423
501
  // unmerged candidate's checkout as before; a broken legacy path never
424
502
  // prevents a verdict on already merged commits.
425
- const read = await Promise.all(urls.map(readPullRequest));
503
+ const read = await readTogether(urls, readPullRequest);
426
504
  const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
427
505
  const own = mergedOnly ? null : await repositoryWorkspace(board, id);
428
506
  if (!own && !urls.length) {
@@ -446,7 +524,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
446
524
  : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
447
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.');
448
526
  if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
449
- 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));
450
528
  if (stage === 'request_review') {
451
529
  // A review is asked for only while some PR has a head no verdict has judged.
452
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.');
@@ -459,7 +537,8 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
459
537
  if (bound.length && (bound.length !== requested.length || bound.some((b) => !requested.some((r) => r.pr.url === b.pr.url && r.commit === b.commit)))) throw new Error('This reviewer run was launched for different PR heads.');
460
538
  }
461
539
  Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
462
- } catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message }); }
540
+ } catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message,
541
+ ...(error.code ? { code: error.code, retryable: error.code === 'GITHUB_BUDGET' } : {}) }); }
463
542
  return state;
464
543
  }
465
544
 
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';
@@ -109,6 +110,7 @@ The board's verbs, with their flags:
109
110
  specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
110
111
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
111
112
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
113
+ github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
112
114
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
113
115
  capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
114
116
  dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
@@ -1424,7 +1426,7 @@ function ownCards(db, sessionId) {
1424
1426
  return { worked, held };
1425
1427
  }
1426
1428
 
1427
- const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
1429
+ const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'github-usage', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
1428
1430
 
1429
1431
  /** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
1430
1432
  * daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
@@ -1610,10 +1612,14 @@ async function answerOne(root, line) {
1610
1612
  const began = performance.now();
1611
1613
  const before = slowLog.processCounters?.() ?? null;
1612
1614
  let named = null, by = null;
1613
- 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; }
1614
1617
  finally {
1615
1618
  const ms = performance.now() - began;
1616
- 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 } });
1617
1623
  }
1618
1624
  }
1619
1625
 
package/board/client.mjs CHANGED
@@ -17,6 +17,16 @@ import { processSession } from './caller-session.mjs';
17
17
  export async function runWorkflow(argv) {
18
18
  const args = parse(argv);
19
19
  const [verb] = args._;
20
+ // This home's REST ledger is a local diagnostic, not a card verb. It needs neither a running board owner nor
21
+ // a workflow/identity/network read just to explain which callers already issued requests.
22
+ if (verb === 'github-usage') {
23
+ const value = String(args.since ?? '1h'), relative = /^(\d+)([smhd])$/.exec(value);
24
+ const since = relative ? Date.now() - Number(relative[1]) * { s: 1000, m: 60_000, h: 3600_000, d: 86400_000 }[relative[2]] : Date.parse(value);
25
+ if (!Number.isFinite(since)) throw new Error('github-usage --since takes 30m, 1h or an ISO date');
26
+ const { githubUsage } = await import('./github-rest.mjs');
27
+ process.stdout.write(`${JSON.stringify(githubUsage({ since }), null, 2)}\n`);
28
+ return 0;
29
+ }
20
30
  const help = !verb || verb === 'help' || args.help;
21
31
  const root = help ? null : homeOf(args);
22
32
  if (help || BOARD_OPERATION_VERBS.has(verb)) {
@@ -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
 
@@ -3172,10 +3188,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3172
3188
  report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
3173
3189
  continue;
3174
3190
  }
3175
- 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');
3176
- if (!evidence.ready) throw new Error(evidence.reason);
3177
- // Due in every other respect: only the session's turn may still hold it. The first round that finds it so is
3178
- // recorded on the run (`review_held`), with what it was due on, so a goal turn can be told to have started after.
3191
+ // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
3192
+ // (`review_held`) so a goal turn can be told to have started after that work was first observed.
3179
3193
  const heldRun = currentRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3180
3194
  const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
3181
3195
  const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
@@ -3184,9 +3198,22 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3184
3198
  report.queued.push([id, `review waits: ${inTurn.why}`]);
3185
3199
  continue;
3186
3200
  }
3187
- // a dry run names the review a tick would request and writes nothing
3201
+ if (!dry) await refreshCardInputs(board, id, { commits: true });
3202
+ // Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
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;
3212
+ if (!payload || payload.waits) { report.queued.push([id, `review waits: ${payload?.waits ?? 'the current source set has no review candidate'}`]); payload = null; continue; }
3213
+ const evidence = observed.evidence;
3214
+ if (!evidence.ready) throw new Error(evidence.reason);
3215
+ // A dry run names the complete validated review a tick would request and writes nothing.
3188
3216
  if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
3189
- await refreshCardInputs(board, id, { commits: true });
3190
3217
  const moved = tx(board, db => {
3191
3218
  const task = getTask(db, id);
3192
3219
  if (eligible(db) !== true) return null;
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;
@@ -0,0 +1,119 @@
1
+ // Every REST wire attempt, including fast calls, belongs to a durable caller/endpoint receipt. Admission is shared
2
+ // by all processes using this supercode home; it never pretends to account for another machine or an external gh.
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { chmodSync, existsSync, mkdirSync } from 'node:fs';
6
+ import { homedir, hostname } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { current } from './call.mjs';
9
+
10
+ export const GITHUB_HOURLY_ATTEMPTS = 1200;
11
+ export const GITHUB_DISCOVERY_ATTEMPTS = 600;
12
+ const HOUR = 3600_000;
13
+ const pathOf = () => join(process.env.SUPERCODE_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode'), 'github-rest.sqlite');
14
+ const connections = new Map();
15
+ function connection() {
16
+ const path = pathOf();
17
+ if (connections.has(path)) return connections.get(path);
18
+ mkdirSync(join(path, '..'), { recursive: true, mode: 0o700 });
19
+ const db = new DatabaseSync(path);
20
+ chmodSync(path, 0o600);
21
+ db.exec(`PRAGMA busy_timeout=50; PRAGMA journal_mode=WAL;
22
+ CREATE TABLE IF NOT EXISTS github_attempts (
23
+ id TEXT PRIMARY KEY, credential TEXT NOT NULL, started_at INTEGER NOT NULL, ended_at INTEGER,
24
+ machine TEXT NOT NULL, pid INTEGER NOT NULL, caller TEXT NOT NULL, purpose TEXT NOT NULL,
25
+ method TEXT NOT NULL, endpoint TEXT NOT NULL, status INTEGER, outcome TEXT,
26
+ resource TEXT, used INTEGER, remaining INTEGER, reset_at INTEGER, retry_after INTEGER);
27
+ CREATE INDEX IF NOT EXISTS github_attempts_window ON github_attempts(credential,started_at,purpose);
28
+ CREATE INDEX IF NOT EXISTS github_attempts_home_window ON github_attempts(started_at,purpose);
29
+ CREATE TABLE IF NOT EXISTS github_cooldown (credential TEXT PRIMARY KEY, until_at INTEGER NOT NULL, reason TEXT NOT NULL);`);
30
+ connections.set(path, db);
31
+ return db;
32
+ }
33
+ const refusal = (message) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET' });
34
+ const integer = (value) => value != null && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
35
+ function endpointOf(url) {
36
+ const safe = new URL(url); safe.username = ''; safe.password = '';
37
+ for (const key of [...safe.searchParams.keys()]) if (!['state', 'head', 'per_page', 'page'].includes(key)) safe.searchParams.set(key, '[redacted]');
38
+ return safe.pathname + safe.search;
39
+ }
40
+
41
+ /** A reservation counts even when transport completion is unknown; retries and pagination reserve separately. */
42
+ async function issue(url, options, { purpose }) {
43
+ const db = connection(), at = Date.now(), id = randomUUID();
44
+ const headers = new Headers(options.headers);
45
+ const credential = createHash('sha256').update(new URL(url).origin + '\0' + (headers.get('authorization') ?? '')).digest('hex').slice(0, 24);
46
+ const call = current(), caller = JSON.stringify({ address: call.caller ?? call.facts.SUPERCODE_CALLER ?? null,
47
+ principal: call.principal ?? null, actor: call.actor ?? null, agent: call.facts.SUPERCODE_AGENT_ID ?? null,
48
+ session: call.facts.CLAUDE_CODE_SESSION_ID ? `claude-code:${call.facts.CLAUDE_CODE_SESSION_ID}`
49
+ : call.facts.CODEX_SESSION_ID ? `codex:${call.facts.CODEX_SESSION_ID}` : null,
50
+ command: process.argv[2] ?? null });
51
+ const method = options.method ?? 'GET', endpoint = endpointOf(url);
52
+ db.exec('BEGIN IMMEDIATE');
53
+ try {
54
+ const blocked = db.prepare('SELECT until_at,reason FROM github_cooldown WHERE credential=?').get(credential);
55
+ if (blocked?.until_at > at) throw refusal(`GitHub REST deferred until ${new Date(blocked.until_at).toISOString()}: ${blocked.reason}`);
56
+ const count = db.prepare(`SELECT COUNT(*) total, COALESCE(SUM(purpose='discovery'),0) discovery
57
+ FROM github_attempts WHERE started_at>?`).get(at - HOUR);
58
+ if (count.total >= GITHUB_HOURLY_ATTEMPTS || purpose === 'discovery' && count.discovery >= GITHUB_DISCOVERY_ATTEMPTS) {
59
+ const oldest = db.prepare(`SELECT MIN(started_at) at FROM github_attempts WHERE started_at>?
60
+ AND (?='total' OR purpose='discovery')`).get(at - HOUR, count.total >= GITHUB_HOURLY_ATTEMPTS ? 'total' : 'discovery');
61
+ throw refusal(`GitHub ${purpose} deferred by this home's REST budget (${count.total}/${GITHUB_HOURLY_ATTEMPTS} total, ${count.discovery}/${GITHUB_DISCOVERY_ATTEMPTS} discovery in the last hour); retry after ${new Date(oldest.at + HOUR + 1).toISOString()}`);
62
+ }
63
+ db.prepare('INSERT INTO github_attempts(id,credential,started_at,machine,pid,caller,purpose,method,endpoint) VALUES(?,?,?,?,?,?,?,?,?)')
64
+ .run(id, credential, at, hostname(), process.pid, caller, purpose, method, endpoint);
65
+ db.exec('COMMIT');
66
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
67
+ let response, outcome = 'unknown';
68
+ try { response = await fetch(url, { ...options, redirect: 'manual' }); outcome = response.ok ? 'ok' : `HTTP_${response.status}`; return response; }
69
+ catch (error) { outcome = error.code ?? error.name ?? 'transport_error'; throw error; }
70
+ finally {
71
+ const resource = response?.headers.get('x-ratelimit-resource') ?? null;
72
+ const used = integer(response?.headers.get('x-ratelimit-used'));
73
+ const remaining = integer(response?.headers.get('x-ratelimit-remaining'));
74
+ const reset = integer(response?.headers.get('x-ratelimit-reset'));
75
+ const retry = integer(response?.headers.get('retry-after'));
76
+ db.prepare('UPDATE github_attempts SET ended_at=?,status=?,outcome=?,resource=?,used=?,remaining=?,reset_at=?,retry_after=? WHERE id=?')
77
+ .run(Date.now(), response?.status ?? null, String(outcome), resource, used, remaining, reset == null ? null : reset * 1000, retry, id);
78
+ // Stop at the actual endpoint's headers, never call rate_limit to guess its account's allowance.
79
+ const until = Math.max(remaining === 0 && reset != null ? reset * 1000 : 0,
80
+ (response?.status === 429 || response?.status === 403) && retry != null ? Date.now() + retry * 1000 : 0,
81
+ remaining === 0 && reset == null ? Date.now() + 60_000 : 0);
82
+ if (until > Date.now()) db.prepare(`INSERT INTO github_cooldown(credential,until_at,reason) VALUES(?,?,?)
83
+ ON CONFLICT(credential) DO UPDATE SET until_at=MAX(until_at,excluded.until_at),reason=excluded.reason`)
84
+ .run(credential, until, remaining === 0 ? `${resource ?? 'REST'} remaining=0 on ${endpoint}` : `HTTP ${response.status} Retry-After on ${endpoint}`);
85
+ }
86
+ }
87
+
88
+ export async function githubFetch(url, options, { purpose = 'validation' } = {}) {
89
+ const origin = new URL(url).origin;
90
+ for (let hop = 0; hop < 10; hop++) {
91
+ const response = await issue(url, options, { purpose });
92
+ if (![301, 302, 303, 307, 308].includes(response.status) || !response.headers.get('location')) return response;
93
+ const next = new URL(response.headers.get('location'), url);
94
+ await response.body?.cancel();
95
+ if (next.origin !== origin) throw new Error('GitHub REST redirect leaves the configured API origin; credential forwarding refused');
96
+ // Match fetch's method conversion, but count every redirected wire attempt separately.
97
+ const method = options.method ?? 'GET';
98
+ if (response.status === 303 && !['GET', 'HEAD'].includes(method) || [301, 302].includes(response.status) && method === 'POST') {
99
+ const { body: _body, ...rest } = options; options = { ...rest, method: 'GET' };
100
+ }
101
+ url = next.href;
102
+ }
103
+ throw new Error('GitHub REST redirect chain exceeded 10 attempts');
104
+ }
105
+
106
+ /** Only retained product attempts are counted. No network read and no inference about uninstrumented callers. */
107
+ export function githubUsage({ since = Date.now() - HOUR } = {}) {
108
+ const path = pathOf();
109
+ if (!existsSync(path)) return { path, recorded: false, callers: [], endpoints: [] };
110
+ const db = new DatabaseSync(path, { readOnly: true });
111
+ try {
112
+ return { path, recorded: true, since, limits: { hourly: GITHUB_HOURLY_ATTEMPTS, discovery: GITHUB_DISCOVERY_ATTEMPTS },
113
+ callers: db.prepare(`SELECT credential,machine,pid,caller,purpose,COUNT(*) attempts,SUM(status IS NULL) unanswered
114
+ FROM github_attempts WHERE started_at>=? GROUP BY credential,machine,pid,caller,purpose`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })),
115
+ endpoints: db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,status,COUNT(*) attempts
116
+ FROM github_attempts WHERE started_at>=? GROUP BY credential,machine,pid,caller,purpose,method,endpoint,status`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })),
117
+ recent: db.prepare('SELECT id,credential,started_at,ended_at,machine,pid,caller,purpose,method,endpoint,status,outcome,resource,used,remaining,reset_at,retry_after FROM github_attempts WHERE started_at>=? ORDER BY started_at DESC,rowid DESC LIMIT 32').all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })) };
118
+ } finally { db.close(); }
119
+ }
@@ -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.200",
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"