@volter/supercode-orchestrator 0.5.222 → 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 +160 -33
- package/board/cli.mjs +3 -3
- package/board/engine.mjs +20 -8
- package/board/github-rest.mjs +14 -0
- package/package.json +3 -3
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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' ?
|
|
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
|
|
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
|
|
408
|
-
*
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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`
|
|
419
|
-
* stands in for
|
|
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
|
|
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
|
-
|
|
484
|
-
const declared = (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
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}
|
|
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]
|
|
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;
|
|
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.
|
|
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).
|
|
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
|
-
|
|
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
|
-
//
|
|
807
|
-
// the arc's repositories
|
|
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
|
|
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 =
|
|
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
|
|
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;
|
package/board/github-rest.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
42
|
+
"@volter/teams": "0.2.106",
|
|
43
43
|
"@volter/ztrack": "^2.4.23",
|
|
44
44
|
"yaml": "^2.9.0"
|
|
45
45
|
},
|