@volter/supercode-orchestrator 0.5.221 → 0.5.223

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/board/ci.mjs CHANGED
@@ -7,7 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import { AsyncLocalStorage } from 'node:async_hooks';
8
8
  import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace } from './workspace.mjs';
9
9
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
10
- import { githubFetch, githubDiscovery } from './github-rest.mjs';
10
+ import { githubFetch, githubDiscovery, githubDiscoveryHeld, githubDiscoveryUsed, GITHUB_DISCOVERY_ATTEMPTS } from './github-rest.mjs';
11
11
  import { cardHandleOf } from './store.mjs';
12
12
 
13
13
  // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
@@ -202,12 +202,22 @@ const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, sta
202
202
  const discoveryIdentity = async key => `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
203
203
  const discoveryRead = async (key, ttl, read, options) => githubDiscovery(await discoveryIdentity(key), ttl, read, options);
204
204
  const readPullRequest = async (url) => discoverPullRequest(url, { fresh: true });
205
- // Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
206
- // still uses readPullRequest and the fresh source checks in inspectCompletionEvidence, which bypass this cache and reads its current head.
205
+ /** The advisory observation discovery already holds under `key`, or null (no wire attempt). */
206
+ const heldRead = async (key) => githubDiscoveryHeld(await discoveryIdentity(key));
207
+ const pullRequestKey = (url) => `pr:${repositoryOf(url).toLowerCase()}:${numberOf(url)}`;
208
+ // Discovery is advisory. A merged source is final (its head, base and state never change again) and is retained for a
209
+ // week; a closed one for six hours (a reopened PR is in its repository's open list, which discovery reads first); an
210
+ // open one for a minute. Every transition still uses readPullRequest and the fresh source checks in
211
+ // inspectCompletionEvidence, which bypass this cache and read its current head.
212
+ const MERGED_TTL = 7 * 86400_000, CLOSED_TTL = 6 * 3600_000;
213
+ /** A long retention lengthened by up to a quarter, fixed for each key: entries one scan filled together (a cold strict
214
+ * scan reads an arc's every declared PR and earlier branch at once) end over that quarter, not in the same round. */
215
+ const spread = (ttl, key) => ttl + Number.parseInt(createHash('sha256').update(key).digest('hex').slice(0, 8), 16) % Math.floor(ttl / 4);
207
216
  export async function discoverPullRequest(url, { fresh = false } = {}) {
208
- return discoveryRead(`pr:${repositoryOf(url).toLowerCase()}:${numberOf(url)}`, 60_000, async () => pullRequest(repositoryOf(url),
217
+ const key = pullRequestKey(url);
218
+ return discoveryRead(key, 60_000, async () => pullRequest(repositoryOf(url),
209
219
  await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: fresh ? 'validation' : 'discovery' })),
210
- { fresh, ttlOf: pr => pr.state === 'MERGED' ? 3600_000 : 60_000 });
220
+ { fresh, ttlOf: pr => pr.state === 'MERGED' ? spread(MERGED_TTL, key) : pr.state === 'CLOSED' ? spread(CLOSED_TTL, key) : 60_000 });
211
221
  }
212
222
 
213
223
  /**
@@ -362,14 +372,16 @@ async function recreatedWorktree(workspace, resolved) {
362
372
 
363
373
  /** Historical work can already be merged when an outage prevented the board's
364
374
  * review request. Read only the arc's declared branch, never all closed PRs. */
365
- async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
375
+ async function mergedBranchPullRequests(repository, branch, { fresh = false, ttl = 900_000 } = {}) {
376
+ // `ttl` is how long an answer is kept; a failed read is kept only the usual fifteen minutes, never the long lifetime
377
+ // an earlier branch's answer gets (a cached failure is re-thrown to every round until it ends).
366
378
  const key = `${repository.toLowerCase()}:${branch}`;
367
379
  const read = async () => {
368
380
  const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
369
381
  return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
370
382
  .filter(p => p.merged_at).map(p => pullRequest(repository, p));
371
383
  };
372
- return discoveryRead(`history:${key}`, 900_000, read, { fresh });
384
+ return discoveryRead(`history:${key}`, 900_000, read, { fresh, ttlOf: () => ttl });
373
385
  }
374
386
 
375
387
  async function branchCheckout(board, id, repository, branch) {
@@ -399,24 +411,79 @@ function registeredBranches(board, id, sources = arcSources(board.db, id)) {
399
411
  if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
400
412
  out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
401
413
  }
402
- // and each repository and branch the arc declared as its own (arcSources, or the declaration about to replace it)
414
+ // and each repository and branch the arc declared as its own (arcSources, or the set with a declaration about to be added)
403
415
  for (const declared of sources) if (!out.some((r) => r.repository.toLowerCase() === declared.repository.toLowerCase() && r.branch === declared.branch)) out.push(declared);
404
416
  return out;
405
417
  }
406
418
 
407
- /** The repositories and branches the arc itself declares it changes: its latest `comment --source owner/repo:branch`
408
- * declaration (engine.comment), the arc's complete set. A multi-repository arc carries them on the arc card, and its
419
+ /** The arc's declarations of one kind, oldest first, as `{ id, items }`: `sources` (its `comment --pr` PRs) or
420
+ * `branches` (its `comment --source` repositories and branches). Each declaration adds to the ones before it, from the
421
+ * latest that declared none (`--pr none`, `--source none`): an arc declares its PRs and branches as it opens them, on
422
+ * any run, and every one stays the arc's (t_918cc86e's review requests e73756 and e73845 named only the PR of the
423
+ * latest declaration, dropping runhuman-2 #552, #553, #555, #556, volter-ai/volter #135 and open-autonomy #933). */
424
+ function declarationsOf(db, id, field, adding = null) {
425
+ const rows = field === 'branches'
426
+ ? db.prepare("SELECT id, payload FROM task_events WHERE task_id=? AND kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array' ORDER BY id").all(id)
427
+ : db.prepare("SELECT id, payload FROM task_events WHERE task_id=? AND kind='commented' AND json_valid(payload) AND json_type(payload,'$.sources')='array' ORDER BY id").all(id);
428
+ const declarations = rows.map((row) => ({ id: row.id, items: JSON.parse(row.payload)[field] }));
429
+ if (adding) declarations.push({ id: Infinity, items: adding });
430
+ let from = 0;
431
+ declarations.forEach((declaration, i) => { if (!declaration.items.length) from = i + 1; });
432
+ return declarations.slice(from);
433
+ }
434
+
435
+ /** Each repository and branch the arc declared (`branches`), at its latest declaration (`at`, that declaration's id). */
436
+ function declaredBranches(db, id, adding = null) {
437
+ const out = [];
438
+ for (const declaration of declarationsOf(db, id, 'branches', adding)) for (const b of declaration.items) {
439
+ if (typeof b?.repository !== 'string' || typeof b?.branch !== 'string') continue;
440
+ const known = out.find((r) => r.repository.toLowerCase() === b.repository.toLowerCase() && r.branch === b.branch);
441
+ if (known) known.at = declaration.id;
442
+ else out.push({ repository: b.repository, branch: b.branch, at: declaration.id });
443
+ }
444
+ return out;
445
+ }
446
+
447
+ /** The repositories and branches the arc itself declares it changes: every `comment --source owner/repo:branch`
448
+ * declaration (engine.comment) since the latest `--source none`, each adding to the ones before it; with `adding`, the
449
+ * set once that declaration (an empty one clears) is added. A multi-repository arc carries them on the arc card, and its
409
450
  * review discovers and accepts PRs from them with no subtask registered in that repository (VO-2858, VO-2860). */
410
- export function arcSources(db, id) {
411
- const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
412
- let declared = [];
413
- try { declared = row ? JSON.parse(row.payload).branches : []; } catch { /* an unreadable declaration names none */ }
414
- return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
451
+ export function arcSources(db, id, adding = null) {
452
+ return declaredBranches(db, id, adding).map(({ repository, branch }) => ({ repository, branch }));
453
+ }
454
+
455
+ /** How long the merged history of an earlier-declared branch is kept, how many reads an advisory discovery starts for
456
+ * one arc that it does not already hold (declared PRs and earlier branches' histories; the rest are read on later
457
+ * rounds), and the share of the home's discovery budget (GITHUB_DISCOVERY_ATTEMPTS an hour, every arc's) past which it
458
+ * starts none: an arc's declarations add up for as long as it runs, and several arcs filling their caches at once
459
+ * (three at four reads a round would start 720 an hour) leave the rest of that budget to every arc's routine reads. */
460
+ const EARLIER_BRANCH_TTL = 6 * 3600_000, NEW_READS_PER_ROUND = 4, FILL_SHARE = 0.7;
461
+
462
+ /** The declared branches (`repository:branch`, the repository lower-cased) the arc's latest `--source` declaration does
463
+ * not name: work it declared earlier. Their merged history is read on a long cache (EARLIER_BRANCH_TTL), never skipped. */
464
+ function earlierDeclaredBranches(db, id) {
465
+ const declared = declaredBranches(db, id);
466
+ const last = Math.max(0, ...declared.map((b) => b.at));
467
+ return new Set(declared.filter((b) => b.at !== last).map((b) => `${b.repository.toLowerCase()}:${b.branch}`));
468
+ }
469
+
470
+ /** The PRs the arc declared (`comment --pr URL --pr-head SHA`), each at its latest declaration: every declaration since
471
+ * the latest `--pr none` adds to the ones before it, in any of the arc's repositories, on any run. Discovery reads each
472
+ * at its current head and leaves out only a closed one, an open draft, and a merged one whose merged head a passed
473
+ * review judged (discoverReviewSources). */
474
+ export function declaredPullRequests(db, id) {
475
+ const latest = new Map();
476
+ for (const declaration of declarationsOf(db, id, 'sources')) for (const source of declaration.items) {
477
+ if (typeof source?.url !== 'string') continue;
478
+ latest.delete(source.url);
479
+ latest.set(source.url, { url: source.url, head: source.head });
480
+ }
481
+ return [...latest.values()];
415
482
  }
416
483
 
417
484
  /** The arc's repositories, the only ones its `--pr` declarations may name (ADR 0003): the card's own workspace's and each
418
- * registered branch's (its tasks' and its `--source` declaration; `sources`, a declaration about to replace that one,
419
- * stands in for it). Answers them lower-cased, with the workspace and registered branches they were read from. */
485
+ * registered branch's (its tasks' and its `--source` declarations; `sources`, the set with a declaration about to be
486
+ * added, stands in for them). Answers them lower-cased, with the workspace and registered branches they were read from. */
420
487
  export async function arcRepositories(board, id, sources = arcSources(board.db, id)) {
421
488
  const own = await repositoryWorkspace(board, id);
422
489
  const registered = registeredBranches(board, id, sources).map(r => ({ ...r, repository: r.repository.toLowerCase() }));
@@ -477,12 +544,12 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
477
544
  // workspace, its declared sources, its tasks' registered branches) and, here, each repository where the board finds the
478
545
  // arc's own work branch (a wt/<card> PR in a sibling repository, discovered or declared). engine.comment refuses a PR
479
546
  // outside the first three when it is declared; a declaration still outside all of them (stored before that refusal,
480
- // or left outside later by a --source change, a cancelled or retargeted task, or a changed workspace) is refused here,
547
+ // or left outside later by --source none, a cancelled or retargeted task, or a changed workspace) is refused here,
481
548
  // by every review request, verdict and acceptance, as `undeclared`, naming the PR and its repository, never dropped
482
549
  // (volter-ai/volter#134 left four t_ef2aa6ce review requests with no word). A PR the review already includes is never refused.
483
- 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);
484
- const declared = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => typeof source?.url === 'string');
485
- const undeclared = (outside) => refusalOf(outside.map(({ url, repository }) => refused('undeclared', url, `${url} is declared as a source of ${id}, but ${repository} is not one of the arc's repositories (its workspace, its declared sources, its tasks', or one holding its wt/${id} branch), so a declaration cannot name it: declare the repository on the arc: comment ${id} "<why>" --source ${repository}:<branch> (with every other source the arc changes), or declare the arc's PRs again without it (--pr none declares none).`)));
550
+ // Every PR the arc declared, on any run and in any repository, is one of them (declaredPullRequests), not only its latest declaration's.
551
+ const declared = declaredPullRequests(board.db, id);
552
+ const undeclared = (outside) => refusalOf(outside.map(({ url, repository }) => refused('undeclared', url, `${url} is declared as a source of ${id}, but ${repository} is not one of the arc's repositories (its workspace, its declared sources, its tasks', or one holding its wt/${id} branch), so a declaration cannot name it: declare the repository on the arc: comment ${id} "<why>" --source ${repository}:<branch>, or clear the arc's PR declarations with --pr none and declare its PRs again without it.`)));
486
553
  // A card with no repository of its own and none registered (scratch) has no discovery: every declared PR is outside.
487
554
  if (!ownRepository && !registered.length) {
488
555
  const outside = outsideArcRepositories(declared.map(source => source.url), arcScope);
@@ -506,6 +573,30 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
506
573
  const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
507
574
  .filter(Boolean).map(r => r.toLowerCase()));
508
575
  const answered = answeredChanges(board, id);
576
+ // A branch an earlier `--source` declaration named, and no task or the latest declaration names, keeps its merged
577
+ // history on a long cache: it is read, never skipped, but a lane card's weeks of branches stay a small share of the
578
+ // home's discovery budget. A task's branch and the latest declaration's are read as before.
579
+ const earlier = earlierDeclaredBranches(board.db, id);
580
+ if (earlier.size) for (const r of registeredBranches(board, id, [])) earlier.delete(`${r.repository.toLowerCase()}:${r.branch}`);
581
+ // An advisory discovery starts at most NEW_READS_PER_ROUND reads it does not already hold for the arc's earlier
582
+ // branches and declared PRs, and only one once the home has spent FILL_SHARE of its hour's discovery budget (every
583
+ // arc's reads), so an arc still advances through a long busy stretch (issue() still refuses past the budget itself);
584
+ // what is left is read on later rounds, and until then the set is incomplete and says so. A fresh or strict scan reads
585
+ // everything.
586
+ let admitted = 0, deferred = 0, spent = false;
587
+ const admit = async (key) => {
588
+ if (fresh || strict || await heldRead(key)) return true;
589
+ if (admitted < NEW_READS_PER_ROUND) {
590
+ if (!spent && githubDiscoveryUsed() >= GITHUB_DISCOVERY_ATTEMPTS * FILL_SHARE) spent = true;
591
+ if (!spent || admitted === 0) { admitted += 1; return true; }
592
+ }
593
+ deferred += 1;
594
+ return false;
595
+ };
596
+ const openSeen = new Map(), openKey = (url) => `${repositoryOf(url).toLowerCase()}#${numberOf(url)}`;
597
+ // A repository's open PRs, every page, on the five-minute list every arc reading that repository shares.
598
+ const openList = (repository) => discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
599
+ (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
509
600
  const stacked = [], workRepositories = new Set();
510
601
  const groups = await readTogether(repositoriesToRead, async repository => {
511
602
  const prs = [];
@@ -513,8 +604,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
513
604
  // Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
514
605
  let list;
515
606
  try {
516
- const items = await discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
517
- (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
607
+ const items = await openList(repository);
518
608
  // Empty unrelated repositories need no default metadata. Keep that lookup separate from the shared list,
519
609
  // so a card declaring the repository can acquire it without listing the same open PRs again.
520
610
  const needsDefault = items.length > 0 || hasWorkBranches;
@@ -528,6 +618,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
528
618
  }
529
619
  // Include excluded drafts/other bases in the fresh scope: becoming ready
530
620
  // must not let a sibling source escape the complete-set check.
621
+ for (const pr of list.items) openSeen.set(openKey(pr.url), pr);
531
622
  if (list.items.some(pr => mine(repository, pr.headRefName))) reviewRepositories.add(repository);
532
623
  // A repository holding the arc's own work branch is the arc's: a declared PR there is in scope.
533
624
  if (list.items.some(pr => !pr.isCrossRepository && mine(repository, pr.headRefName))) workRepositories.add(repository);
@@ -552,7 +643,11 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
552
643
  // A judged merged head is passed over, except while an answered changes-requested verdict makes it due again.
553
644
  const histories = await readTogether(historical, async headBranch => {
554
645
  if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
555
- return (await mergedBranchPullRequests(repository, headBranch, { fresh })).filter(pr =>
646
+ const older = earlier.has(`${repository}:${headBranch}`) && !(repository === ownRepository && headBranch === branch);
647
+ // An earlier branch's history already held is its answer, in a fresh scan too, until its long cache ends.
648
+ const held = older ? await heldRead(`history:${repository}:${headBranch}`) : null;
649
+ if (older && !held && !await admit(`history:${repository}:${headBranch}`)) return [];
650
+ return (held ?? await mergedBranchPullRequests(repository, headBranch, { fresh, ...(older ? { ttl: spread(EARLIER_BRANCH_TTL, `history:${repository}:${headBranch}`) } : {}) })).filter(pr =>
556
651
  !pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
557
652
  && pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
558
653
  });
@@ -562,15 +657,31 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
562
657
  return prs;
563
658
  });
564
659
  const prs = groups.flat();
565
- // Each declared PR discovery did not find is read and followed at its current head (GitHub's), never pinned to the
566
- // declared one, so a push after the declaration is reviewed instead of failing every tick; one from a fork, closed, or
567
- // an open draft is left out. One outside the arc's repositories is in scope only when it is itself on the arc's work
568
- // branch in its own repository. One GitHub says is absent or denied (404, 403, 410) stays outside and is refused
569
- // with the rest, unless another declared PR brings its repository into scope, where its read error is thrown as it
570
- // is. Any other read failure (5xx, reset, timeout) is thrown as a transient failure, never refused as undeclared.
660
+ // Each declared PR discovery did not find is followed at its current head (GitHub's), never pinned to the declared
661
+ // one, so a push after the declaration is reviewed instead of failing every tick. One open in a repository listed above
662
+ // is taken from that list, with no read of its own; any other is read (a merged answer already held is reused, in a
663
+ // fresh scan too: a merged PR never changes again). One from a fork, closed, or an open draft is left out, and so is
664
+ // a merged one whose merged head a passed review judged (it was reviewed at the head it merged at; one that moved
665
+ // after its pass and merged unreviewed is still in). One outside the arc's repositories is in scope only when it is
666
+ // itself on the arc's work branch in its own repository. One GitHub says is absent or denied (404, 403, 410) stays
667
+ // outside and is refused with the rest, unless another declared PR brings its repository into scope, where its read
668
+ // error is thrown as it is. Any other read failure (5xx, reset, timeout) is thrown as a transient failure, never
669
+ // refused as undeclared.
571
670
  const inScope = new Set([...arcScope, ...workRepositories]);
572
671
  const sources = declared.filter(source => !prs.some(known => known.url === source.url));
672
+ // A declared PR's repository discovery did not list is listed here, once, on that shared list, so its open PRs are
673
+ // looked up there instead of each being read every round; one that cannot be listed leaves its PRs to be read.
674
+ const unlisted = [...new Set(sources.map(source => repositoryOf(source.url).toLowerCase()))].filter(r => !repositoriesToRead.includes(r));
675
+ await readTogether(unlisted, async repository => {
676
+ try { for (const pr of await openList(repository)) openSeen.set(openKey(pr.url), pr); }
677
+ catch { /* read one by one below */ }
678
+ });
573
679
  const read = await readTogether(sources, async source => {
680
+ const listed = openSeen.get(openKey(source.url));
681
+ if (listed) return listed;
682
+ const held = await heldRead(pullRequestKey(source.url));
683
+ if (held && (!fresh || held.state === 'MERGED')) return held;
684
+ if (!held && !await admit(pullRequestKey(source.url))) return { deferred: true };
574
685
  const reading = fresh ? readPullRequest(source.url) : discoverPullRequest(source.url);
575
686
  if (inScope.has(repositoryOf(source.url).toLowerCase())) return reading;
576
687
  try { return await reading; } catch (error) {
@@ -578,14 +689,17 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
578
689
  return { unread: error };
579
690
  }
580
691
  });
581
- read.forEach((pr, i) => { if (!pr.unread && !pr.isCrossRepository && mine(repositoryOf(sources[i].url).toLowerCase(), pr.headRefName)) inScope.add(repositoryOf(sources[i].url).toLowerCase()); });
582
- const outside = outsideArcRepositories(sources.map(source => source.url), inScope);
692
+ read.forEach((pr, i) => { if (!pr.unread && !pr.deferred && !pr.isCrossRepository && mine(repositoryOf(sources[i].url).toLowerCase(), pr.headRefName)) inScope.add(repositoryOf(sources[i].url).toLowerCase()); });
693
+ const outside = outsideArcRepositories(sources.filter((_, i) => !read[i].deferred).map(source => source.url), inScope);
583
694
  if (outside.length) throw undeclared(outside);
584
695
  // Every source left unread is now in scope: its own read error is the answer.
585
696
  const unread = read.find(pr => pr.unread);
586
697
  if (unread) throw unread.unread;
698
+ // Reads this round did not start leave the set incomplete: the review waits for them, never runs without them.
699
+ if (deferred) throw Object.assign(new Error(`${deferred} of ${id}'s earlier-declared PRs and branches are not read yet (${spent ? `the home has spent ${Math.round(FILL_SHARE * 100)}% of its hour's ${GITHUB_DISCOVERY_ATTEMPTS} discovery reads, so an arc starts one new read a round` : `an advisory round starts at most ${NEW_READS_PER_ROUND} new reads for an arc`}); the rest are read on the next rounds`), { code: 'github_read_admission_full', deferred: true });
587
700
  for (const pr of read) {
588
701
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
702
+ if (pr.state === 'MERGED' && !answered && passedHead(board, id, pr.headRefOid)) continue;
589
703
  if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
590
704
  }
591
705
  // Retain scope even when all matching PRs are excluded drafts/other bases,
@@ -755,6 +869,19 @@ export function judgedHead(board, id, head) {
755
869
  OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
756
870
  }
757
871
 
872
+ /** Whether a passed review judged `head` of the card's PR: a pass that answered a review request naming that head,
873
+ * before the next request, and no void of that request since. Unlike judgedHead (the first verdict on a head), a head
874
+ * first sent back and passed in a later round counts. */
875
+ export function passedHead(board, id, head) {
876
+ if (!head) return false;
877
+ return Boolean(board.db.prepare(`SELECT 1 FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
878
+ AND v.kind = 'review_pass' AND v.id > r.id
879
+ AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
880
+ AND NOT EXISTS (SELECT 1 FROM task_events x WHERE x.task_id=r.task_id AND x.kind='review_voided' AND x.id>v.id AND json_extract(x.payload,'$.review_request')=r.id)
881
+ WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
882
+ OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) LIMIT 1`).get(id, head, head));
883
+ }
884
+
758
885
  export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
759
886
  return withFreshReviewReads(() => inspectCurrentCompletionEvidence(board, id, entry, payload, stage));
760
887
  }
@@ -812,7 +939,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
812
939
  const registered = registeredBranches(board, id);
813
940
  const declared = (pr) => namedWorkBranch(id, pr.headRefName) || registered.some((r) => r.branch === pr.headRefName && r.repository.toLowerCase() === repositoryOf(pr.url).toLowerCase());
814
941
  const strays = read.filter((pr) => pr.state !== 'MERGED' && !declared(pr));
815
- if (strays.length) throw refusalOf(strays.map((stray) => refused('undeclared', stray.url, `An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName} (with every other source the arc changes)`)));
942
+ if (strays.length) throw refusalOf(strays.map((stray) => refused('undeclared', stray.url, `An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName}`)));
816
943
  }
817
944
  // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
818
945
  // always names every opened PR it changed, independent of deployment.
package/board/cli.mjs CHANGED
@@ -116,7 +116,7 @@ The board's verbs, with their flags:
116
116
  set <id> <assignee|machine|model|every|reviewer> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
117
117
  reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
118
118
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
119
- comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch]... attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
119
+ comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch... | --source none] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
120
120
  attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
121
121
  complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
122
122
  finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
@@ -164,8 +164,8 @@ And:
164
164
 
165
165
  specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
166
166
  specify <id> --workspace … --machine M: declares the workspace on M and moves the card there, never asking the machine it leaves.
167
- comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; the latest declaration replaces the previous one, and --pr none alone replaces it with none. This records evidence; only the board starts its review.
168
- comment <arc> TEXT --source owner/repo:branch...: the arc's implementer or the manager declares every repository and branch the arc changes, on the arc itself; its review discovers PRs from them and accepts --pr declarations in them, with no task registered there. The latest declaration is the complete set.
167
+ comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; each declaration adds to the earlier ones, on any run, and --pr none alone clears them. This records evidence; only the board starts its review.
168
+ comment <arc> TEXT --source owner/repo:branch...: the arc's implementer or the manager declares every repository and branch the arc changes, on the arc itself; its review discovers PRs from them and accepts --pr declarations in them, with no task registered there. Each declaration adds to the earlier ones, and --source none alone clears them.
169
169
  request-review <id> (--pr URL --pr-head SHA)...: hand over every open PR the arc changed, each at its pushed head; one review covers them all.
170
170
  request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
171
171
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
package/board/engine.mjs CHANGED
@@ -287,6 +287,14 @@ export function createCard(board, spec) {
287
287
  spec.branch ?? null, tags[0] ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
288
288
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
289
289
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
290
+ // Only cards are numbered: a task (--subtask-of) is a row under its card, as a checklist item is, with no number of
291
+ // its own (the account manager's ruling, m-83f821fe). The store numbers every inserted row (store.mjs
292
+ // layOutNumbers); a task's number is taken back in this transaction, and the sequence with it when it was the last.
293
+ if (arc) {
294
+ const taken = numberOf(db, id);
295
+ run(db, `DELETE FROM ${CARD_NUMBERS} WHERE task_id = ?`, id);
296
+ if (taken != null) run(db, 'UPDATE sqlite_sequence SET seq = ? WHERE name = ? AND seq = ?', taken - 1, CARD_NUMBERS, taken);
297
+ }
290
298
  const readableId = allocateReadableId(db, title, spec.readableId ?? null, id);
291
299
  run(db, 'UPDATE tasks SET readable_id=? WHERE id=?', readableId, id);
292
300
  const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
@@ -787,15 +795,18 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
787
795
  let sources, branches;
788
796
  // The repositories and branches an arc changes are the arc's own: declared on it (`--source owner/repo:branch`, each
789
797
  // repository the arc changes), never registered as a subtask's workspace to be reviewed (VO-2858 had to register
790
- // volter-org and runhuman-2 on its tasks, VO-2860). The latest declaration is the arc's complete set.
798
+ // volter-org and runhuman-2 on its tasks, VO-2860). Each declaration adds to the earlier ones (ci.arcSources), and
799
+ // `--source none` alone clears them (a declared repository that can no longer be listed leaves the arc this way).
791
800
  if (source != null) {
792
801
  const named = [].concat(source).map((item) => String(item).trim());
793
- branches = named.map((item) => {
802
+ const none = named.length === 1 && named[0] === 'none';
803
+ if (!none && named.includes('none')) throw new Error('--source none clears the arc\'s declared sources and is given alone; declare sources in a comment of their own.');
804
+ branches = none ? [] : named.map((item) => {
794
805
  const found = /^(?:https:\/\/github\.com\/|github\.com\/)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?:([^\s:]+)$/.exec(item);
795
806
  if (!found) throw new Error(`Declare each source as owner/repository:branch, not ${JSON.stringify(item)}.`);
796
807
  return { repository: found[1], branch: found[2] };
797
808
  });
798
- if (!branches.length) throw new Error('Declare at least one source as owner/repository:branch.');
809
+ if (!none && !branches.length) throw new Error('Declare at least one source as owner/repository:branch, or clear them with --source none alone.');
799
810
  if (new Set(branches.map((b) => `${b.repository.toLowerCase()}:${b.branch}`)).size !== branches.length) throw new Error('Declare each source once.');
800
811
  const arc = one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of;
801
812
  if (arc) throw new Error(`${id} is a task of ${arc}: an arc's sources are declared on the arc (comment ${arc} … --source owner/repository:branch).`);
@@ -803,9 +814,10 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
803
814
  }
804
815
  if (pr != null || pr_head != null) {
805
816
  const urls = [].concat(pr ?? []), heads = [].concat(pr_head ?? []);
806
- // `--pr none` alone declares no PR: the arc's declaration becomes the empty set (a stored declaration no longer in
807
- // the arc's repositories is cleared this way when no PR in them remains to declare in its place).
817
+ // Each declaration adds its PRs to the earlier ones (ci.declaredPullRequests). `--pr none` alone clears them (a
818
+ // stored declaration no longer in the arc's repositories leaves this way, before the rest are declared again).
808
819
  const none = urls.length === 1 && urls[0] === 'none' && !heads.length;
820
+ if (!none && urls.includes('none')) throw new Error('--pr none clears the arc\'s declared PRs and is given alone, with no --pr-head; declare PRs in a comment of their own.');
809
821
  if (!none && (!urls.length || urls.length !== heads.length)) throw new Error('Record every PR with --pr URL --pr-head SHA, one full head per URL, or declare none with --pr none alone.');
810
822
  if (new Set(urls).size !== urls.length) throw new Error('Record each PR once.');
811
823
  sources = none ? [] : urls.map((url, i) => {
@@ -815,10 +827,10 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
815
827
  // Only the card's own implementer or the board's manager declares its sources; the author text is not identity.
816
828
  if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager records its PR sources.`);
817
829
  // A declared PR is in one of the arc's repositories (its workspace, its declared sources, its tasks'; a `--source`
818
- // in this same comment replaces the arc's declared sources): the review reads no other, so one outside them is
830
+ // in this same comment counts with the arc's declared sources): the review reads no other, so one outside them is
819
831
  // refused here and nothing is stored, never kept to be dropped from every review request (t_ef2aa6ce). The board's
820
832
  // own records are read first; the workspace is resolved (its daemon, its machine, git) only for a URL still outside.
821
- const declaredSources = branches ?? arcSources(board.db, id);
833
+ const declaredSources = arcSources(board.db, id, branches);
822
834
  const urlsOf = (list) => list.map((s) => s.url);
823
835
  let outside = outsideArcRepositories(urlsOf(sources), recordedArcRepositories(board, id, declaredSources));
824
836
  if (outside.length) {
@@ -827,7 +839,7 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
827
839
  catch (error) { throw new Error(`${id}'s workspace could not be read to check the repository of ${outside.map((o) => o.url).join(', ')} (a --pr declaration is checked against the arc's repositories): ${error.message}. Nothing was recorded.`); }
828
840
  outside = outsideArcRepositories(urlsOf(outside), repositories);
829
841
  }
830
- if (outside.length) throw new Error(outside.map(({ url, repository }) => `${url} is in ${repository}, which is not one of ${id}'s repositories (its workspace, its declared sources, its tasks'): declare ${repository} first with comment ${id} "<why>" --source ${repository}:<branch> (with every other source the arc changes), then record its PRs. Nothing was recorded.`).join(' '));
842
+ if (outside.length) throw new Error(outside.map(({ url, repository }) => `${url} is in ${repository}, which is not one of ${id}'s repositories (its workspace, its declared sources, its tasks'): declare ${repository} first with comment ${id} "<why>" --source ${repository}:<branch>, then record its PRs. Nothing was recorded.`).join(' '));
831
843
  }
832
844
  const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}), ...(branches ? { branches } : {}) } });
833
845
  return result.commentId;
@@ -46,6 +46,20 @@ const discoveryPending = () => Object.assign(new Error('GitHub discovery is alre
46
46
  /** Advisory observations survive command/owner restarts. Fresh transition reads never consume a cached value,
47
47
  * but publish their successful observation for discovery. A short transaction claims each advisory read; no
48
48
  * transaction spans I/O and a different process defers rather than waiting or duplicating its wire attempts. */
49
+ /** How many discovery reads this home started in the last hour, of its GITHUB_DISCOVERY_ATTEMPTS: every arc's, so a
50
+ * caller filling a cache it can defer reads by the home's use, not only its own. */
51
+ export function githubDiscoveryUsed() {
52
+ return connection().prepare("SELECT COUNT(*) AS n FROM github_attempts WHERE started_at>? AND purpose='discovery'").get(Date.now() - HOUR).n;
53
+ }
54
+
55
+ /** The live advisory observation kept for `identity`, or null: read without a claim or a wire attempt, so a caller can
56
+ * tell a read it already holds from one that spends the home's budget. */
57
+ export function githubDiscoveryHeld(identity) {
58
+ const key = createHash('sha256').update(identity).digest('hex');
59
+ const held = connection().prepare('SELECT value, until_at FROM github_discovery WHERE key=?').get(key);
60
+ return held && held.until_at > Date.now() && held.value != null ? JSON.parse(held.value) : null;
61
+ }
62
+
49
63
  export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlOf = () => ttl, cacheErrors = true } = {}) {
50
64
  const key = createHash('sha256').update(identity).digest('hex');
51
65
  const localKey = `${pathOf()}\0${key}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.221",
3
+ "version": "0.5.223",
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": {
@@ -36,10 +36,10 @@
36
36
  "check-types": "node scripts/generate-types.mjs --check"
37
37
  },
38
38
  "dependencies": {
39
- "@volter/supercode-teams": "^0.3.377",
39
+ "@volter/supercode-teams": "^0.3.380",
40
40
  "@volter/supercode-terminal": "^0.3.12",
41
41
  "@volter/supercode-harness-sdk": "^0.3.106",
42
- "@volter/teams": "0.2.104",
42
+ "@volter/teams": "0.2.106",
43
43
  "@volter/ztrack": "^2.4.23",
44
44
  "yaml": "^2.9.0"
45
45
  },