@volter/supercode-orchestrator 0.5.200 → 0.5.201

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,27 @@ 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 discovery caches advisory reads while waiting. Review requests, verdicts
55
+ and acceptance refresh the complete source set, including previously draft or
56
+ closed declared sources, and validate its current heads. An unread set defers
57
+ the action. Unread commit dates also defer eligibility and are never cached as
58
+ old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
59
+ reserves its own attempt. All processes and credentials sharing this supercode
60
+ home share a rolling-hour ceiling of 1,200 attempts, including at most 600
61
+ discovery attempts. Endpoint exhaustion/Retry-After also defers subsequent work.
62
+ A deferred source remains unknown, with its reason; it is not an empty success.
63
+ Other homes and external GitHub clients are outside this ledger and ceiling.
64
+ See [F104 evidence and qualification limits](../../docs/evidence/f104-github-2026-10-14.md).
65
+
45
66
  ## Local native session recovery
46
67
 
47
68
  `supercode sessions recover --once --retry-after 300` runs a bounded opt-in
package/board/ci.mjs CHANGED
@@ -6,6 +6,7 @@ import { dirname, join, relative } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
8
8
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
9
+ import { githubFetch } from './github-rest.mjs';
9
10
 
10
11
  // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
11
12
  // waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
@@ -20,12 +21,12 @@ async function githubToken() {
20
21
  }
21
22
  /** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
22
23
  * 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) {
24
+ async function github(path, all = false, { purpose = 'validation' } = {}) {
24
25
  const read = async () => {
25
26
  const token = await githubToken();
26
27
  let url = githubBase() + path, out = [];
27
28
  for (let page = 0; url && page < 10; page += 1) {
28
- const r = await fetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) });
29
+ const r = await githubFetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) }, { purpose });
29
30
  if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
30
31
  const body = await r.json();
31
32
  if (!all) return { body };
@@ -36,10 +37,10 @@ async function github(path, all = false) {
36
37
  };
37
38
  const started = performance.now();
38
39
  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) }; } }
40
+ try { result = await read(); } catch (error) { result = { error: String(error.message), cause: error }; }
41
+ 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
42
  noteStep('github read', performance.now() - started);
42
- if (result.error) throw new Error(`GitHub: ${result.error.trim().slice(-500)}`);
43
+ 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
44
  return result.body;
44
45
  }
45
46
  const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
@@ -48,6 +49,22 @@ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
48
49
  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
50
  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
51
  const readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
52
+ // Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
53
+ // still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
54
+ const discoveredPullRequests = new Map();
55
+ export async function discoverPullRequest(url) {
56
+ const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${url}`;
57
+ let held = discoveredPullRequests.get(key);
58
+ if (held && Date.now() < held.until) return held.pending;
59
+ if (discoveredPullRequests.size >= 4096 && !held) discoveredPullRequests.delete(discoveredPullRequests.keys().next().value);
60
+ held = { until: Date.now() + 60_000 };
61
+ held.pending = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: 'discovery' })
62
+ .then(body => { const pr = pullRequest(repositoryOf(url), body); held.until = Date.now() + (pr.state === 'MERGED' ? 3600_000 : 60_000); return pr; });
63
+ // Failed reads stay failed for a minute; no cached empty source or invented successful head.
64
+ void held.pending.catch(() => {});
65
+ discoveredPullRequests.set(key, held);
66
+ return held.pending;
67
+ }
51
68
 
52
69
  /**
53
70
  * 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,7 +80,7 @@ export async function mergeReviewedPullRequest(url, reviewed) {
63
80
  const token = await githubToken();
64
81
  let answer;
65
82
  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) });
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' });
67
84
  const text = await r.text();
68
85
  answer = { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
69
86
  } catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
@@ -165,20 +182,29 @@ async function recreatedWorktree(workspace, resolved) {
165
182
 
166
183
  const pullRequestLists = new Map();
167
184
  const mergedPullRequestLists = new Map();
185
+ // Keep in-flight and failed list reads too, with room for the registered branch set. History changes rarely;
186
+ // ordinary open work is discovered by the repository's open-PR listing.
187
+ async function discoveryList(cache, key, ttl, read) {
188
+ key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
189
+ let held = cache.get(key);
190
+ if (held && Date.now() < held.until) return held.pending;
191
+ if (cache.size >= 4096 && !held) cache.delete(cache.keys().next().value);
192
+ held = { until: Date.now() + ttl };
193
+ held.pending = Promise.resolve().then(read).catch(error => { held.until = Date.now() + 60_000; throw error; });
194
+ void held.pending.catch(() => {}); cache.set(key, held);
195
+ return held.pending;
196
+ }
168
197
 
169
198
  /** Historical work can already be merged when an outage prevented the board's
170
199
  * review request. Read only the arc's declared branch, never all closed PRs. */
171
- async function mergedBranchPullRequests(repository, branch) {
200
+ async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
172
201
  const key = `${repository}:${branch}`;
173
- let list = mergedPullRequestLists.get(key);
174
- if (!list || Date.now() - list.at >= 60_000) {
202
+ const read = async () => {
175
203
  const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
176
- const items = (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true))
204
+ return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
177
205
  .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;
206
+ };
207
+ return fresh ? read() : discoveryList(mergedPullRequestLists, key, 900_000, read);
182
208
  }
183
209
 
184
210
  async function branchCheckout(board, id, repository, branch) {
@@ -230,7 +256,7 @@ export function declaredWorkBranches(db, id) {
230
256
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
231
257
  * 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
258
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
233
- export async function openReviewPullRequests(board, id) {
259
+ export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
234
260
  const own = await repositoryWorkspace(board, id);
235
261
  const registered = registeredBranches(board, id);
236
262
  if (!own && !registered.length) return null;
@@ -242,19 +268,19 @@ export async function openReviewPullRequests(board, id) {
242
268
  const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
243
269
  || registered.some((r) => r.repository === repository && r.branch === head);
244
270
  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);
271
+ for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))) {
272
+ // Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
273
+ let list;
274
+ try {
275
+ const read = async () => {
276
+ const items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map((p) => pullRequest(repository, p));
277
+ const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch ?? (await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch;
278
+ return { items, defaultBranch };
279
+ };
280
+ list = fresh ? await read() : await discoveryList(pullRequestLists, repository, 300_000, read);
281
+ } catch (error) {
282
+ 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.
258
284
  }
259
285
  // The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
260
286
  if (repository === ownRepository && branch) {
@@ -273,7 +299,7 @@ export async function openReviewPullRequests(board, id) {
273
299
  for (const headBranch of historical) {
274
300
  if (headBranch === list.defaultBranch) continue;
275
301
  if (list.items.some(pr => pr.headRefName === headBranch)) continue;
276
- for (const pr of await mergedBranchPullRequests(repository, headBranch)) {
302
+ for (const pr of await mergedBranchPullRequests(repository, headBranch, { fresh })) {
277
303
  if (!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
278
304
  && pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid)
279
305
  && !prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
@@ -288,7 +314,7 @@ export async function openReviewPullRequests(board, id) {
288
314
  const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
289
315
  for (const source of declaration ? JSON.parse(declaration.payload).sources : []) {
290
316
  if (!repositories.has(repositoryOf(source.url).toLowerCase()) || prs.some((known) => known.url === source.url)) continue;
291
- const pr = await readPullRequest(source.url);
317
+ const pr = await (fresh ? readPullRequest(source.url) : discoverPullRequest(source.url));
292
318
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
293
319
  if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
294
320
  }
@@ -325,14 +351,16 @@ export async function noCodeChange(board, id) {
325
351
  const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
326
352
  if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
327
353
  const owner = repository.split('/')[0];
354
+ // An empty PR history can authorize a no-code review; keep this validation fresh too.
328
355
  if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
329
356
  return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
330
- } catch { return null; }
357
+ } catch (error) { if (error.code === 'GITHUB_BUDGET') throw error; return null; }
331
358
  }
332
359
 
333
360
  /** 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 []; }
361
+ async function knownRepositories({ fresh = false } = {}) {
362
+ try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
363
+ catch (error) { if (fresh) throw error; return []; }
336
364
  }
337
365
 
338
366
  /** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
@@ -358,22 +386,28 @@ export async function reviewDue(board, id, prs) {
358
386
  const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
359
387
  if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
360
388
  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;
389
+ for (const p of unjudged) if ((await commitTime(p)) > Number(last.verdict_at)) return true;
362
390
  return false;
363
391
  }
364
392
 
365
393
  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. */
394
+ /** Cache successful immutable commit dates only. An unread date is unknown, never evidence of old work. */
367
395
  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);
396
+ const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
397
+ if (commitTimes.has(key)) return commitTimes.get(key);
398
+ const at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit?.committer?.date) / 1000);
399
+ if (!Number.isFinite(at)) throw new Error(`GitHub commit date for ${url} at ${head} is unreadable; review eligibility is unknown`);
400
+ commitTimes.set(key, at);
374
401
  return at;
375
402
  }
376
403
 
404
+ /** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
405
+ async function requireCurrentReviewSet(board, id, urls) {
406
+ const current = await openReviewPullRequests(board, id, { fresh: true });
407
+ const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
408
+ 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`);
409
+ }
410
+
377
411
  /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
378
412
  * a review request naming that head, before the next request. A head is reviewed once. */
379
413
  export function judgedHead(board, id, head) {
@@ -400,6 +434,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
400
434
  }
401
435
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
402
436
  if (candidate) {
437
+ await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url));
403
438
  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
439
  if (!row) throw new Error('The current candidate needs its independent verdict before acceptance.');
405
440
  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);
@@ -417,6 +452,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
417
452
  }
418
453
  const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
419
454
  const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
455
+ if (stage === 'request_review' || stage === 'review_verdict') await requireCurrentReviewSet(board, id, urls);
420
456
  if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
421
457
  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
458
  // Merged source is immutable and needs no surviving workspace. Resolve an
@@ -459,7 +495,8 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
459
495
  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
496
  }
461
497
  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 }); }
498
+ } catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message,
499
+ ...(error.code ? { code: error.code, retryable: error.code === 'GITHUB_BUDGET' } : {}) }); }
463
500
  return state;
464
501
  }
465
502
 
package/board/cli.mjs CHANGED
@@ -109,6 +109,7 @@ The board's verbs, with their flags:
109
109
  specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
110
110
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
111
111
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
112
+ github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
112
113
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
113
114
  capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
114
115
  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 +1425,7 @@ function ownCards(db, sessionId) {
1424
1425
  return { worked, held };
1425
1426
  }
1426
1427
 
1427
- const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
1428
+ 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
1429
 
1429
1430
  /** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
1430
1431
  * daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
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)) {
@@ -3172,10 +3172,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3172
3172
  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
3173
  continue;
3174
3174
  }
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.
3175
+ // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
3176
+ // (`review_held`) so a goal turn can be told to have started after that work was first observed.
3179
3177
  const heldRun = currentRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3180
3178
  const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
3181
3179
  const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
@@ -3184,9 +3182,15 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3184
3182
  report.queued.push([id, `review waits: ${inTurn.why}`]);
3185
3183
  continue;
3186
3184
  }
3187
- // a dry run names the review a tick would request and writes nothing
3185
+ if (!dry) await refreshCardInputs(board, id, { commits: true });
3186
+ // 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);
3189
+ 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');
3191
+ if (!evidence.ready) throw new Error(evidence.reason);
3192
+ // A dry run names the complete validated review a tick would request and writes nothing.
3188
3193
  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
3194
  const moved = tx(board, db => {
3191
3195
  const task = getTask(db, id);
3192
3196
  if (eligible(db) !== true) return null;
@@ -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
+ }
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.201",
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": {