@volter/supercode-orchestrator 0.5.212 → 0.5.214
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 +33 -7
- package/board/ci.mjs +105 -78
- package/board/cli.mjs +26 -1
- package/board/dispatch.mjs +87 -31
- package/board/engine.mjs +1 -1
- package/board/github-rest.mjs +84 -2
- package/board/incidents.mjs +1 -0
- package/board/machine-fleet.mjs +3 -1
- package/board/session-work.mjs +7 -0
- package/board/workflow.mjs +16 -5
- package/board/workspace.mjs +13 -1
- package/connection.mjs +5 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -124,7 +124,10 @@ range under the same worker scope. The maximum `length` is 1,048,576 bytes.
|
|
|
124
124
|
`XDG_CONFIG_HOME`). It needs neither a running board nor a GitHub request. It
|
|
125
125
|
groups retained wire attempts by caller and endpoint, includes their purposes
|
|
126
126
|
and outcomes, and shows the latest 32 response-header receipts. Unknown transport
|
|
127
|
-
completion remains counted. Tokens and response bodies are not retained
|
|
127
|
+
completion remains counted. Tokens and raw response bodies are not retained in
|
|
128
|
+
the attempts ledger. Budget refusals have separate local receipts, exposed as
|
|
129
|
+
`refusals`; `refusals_since` and `refusals_complete` say whether collection covers
|
|
130
|
+
the requested window. An older ledger cannot prove the absence of refusals.
|
|
128
131
|
|
|
129
132
|
Board actions check their local role, guard and validator rules before GitHub.
|
|
130
133
|
Fresh source observations are reused only within that action, with at most eight
|
|
@@ -132,15 +135,38 @@ concurrent REST operations per process. Owner receipts retain the return code an
|
|
|
132
135
|
redacted refusal even when a failed call is fast. See
|
|
133
136
|
[acceptance latency readings](../../docs/evidence/board-acceptance-latency-2026-10-14.md).
|
|
134
137
|
|
|
135
|
-
Board discovery
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
Board discovery persists advisory projections in this same private SQLite file:
|
|
139
|
+
one open-PR list per API origin, credential digest and repository for five minutes,
|
|
140
|
+
independent of which card declares that repository. Default-branch metadata has
|
|
141
|
+
its own entry; acquiring it never repeats the list. Branch histories last fifteen
|
|
142
|
+
minutes, merged PR observations an hour and other PR observations a minute.
|
|
143
|
+
Failures remain failures for the entry's interval. In-process readers share a
|
|
144
|
+
pending result; another process defers on its durable claim instead of duplicating
|
|
145
|
+
the request. A claim expires after six minutes if its process disappears.
|
|
146
|
+
Successful fresh validation refreshes advisory discovery, including after restart.
|
|
147
|
+
|
|
148
|
+
Review requests, verdicts
|
|
149
|
+
and acceptance refresh the complete source set in the arc's repositories,
|
|
150
|
+
including previously draft or closed declared sources, and validate its current
|
|
151
|
+
heads. That scope includes its workspace, registered branches, declared sources,
|
|
152
|
+
previous candidate and discovered sibling work, and is retained in the source
|
|
153
|
+
receipt. Broad known-repository discovery remains advisory; unrelated checkouts
|
|
154
|
+
spend no validation calls. Every fresh source entry first acquires that advisory
|
|
155
|
+
scope, even for old receipts and sibling checkouts now on main; an unread scope
|
|
156
|
+
refuses authorization. A round with every head judged stops at advisory
|
|
157
|
+
eligibility, and the review evidence door refuses a redundant proposed head set
|
|
158
|
+
before a fresh scan. An unread set defers
|
|
138
159
|
the action. Before the dispatcher spends those validation reads, it checks the
|
|
139
|
-
advisory candidate
|
|
140
|
-
|
|
160
|
+
advisory candidate's PR properties and local worktrees. An invalid PR state,
|
|
161
|
+
draft, head or base refuses before the full validation scan, including a declared
|
|
162
|
+
PR on the wrong base. Discovery refreshes changes to those properties. A mismatched
|
|
163
|
+
HEAD or uncommitted work also refuses locally. Each later round rechecks the checkout, so
|
|
141
164
|
a repair wakes it; a successful preflight still needs the fresh complete-source,
|
|
142
165
|
local and pushed-head checks before review is recorded. Unread commit dates also defer eligibility and are never cached as
|
|
143
|
-
old work.
|
|
166
|
+
old work. An arc that already named a PR for review cannot become a no-code
|
|
167
|
+
disposition merely because it merged; it skips the repeated no-code history
|
|
168
|
+
validation. Advisory no-code and commit eligibility use discovery's allowance.
|
|
169
|
+
Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
|
|
144
170
|
reserves its own attempt. All processes and credentials sharing this supercode
|
|
145
171
|
home share a rolling-hour ceiling of 1,200 attempts, including at most 600
|
|
146
172
|
discovery attempts. Endpoint exhaustion/Retry-After also defers subsequent work.
|
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, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
9
9
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
|
-
import { githubFetch } from './github-rest.mjs';
|
|
10
|
+
import { githubFetch, githubDiscovery } 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
|
|
@@ -177,22 +177,15 @@ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
|
177
177
|
/** A PR as the board reads it (gh's field names). */
|
|
178
178
|
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
179
179
|
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) });
|
|
180
|
-
const
|
|
180
|
+
const discoveryIdentity = async key => `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
|
|
181
|
+
const discoveryRead = async (key, ttl, read, options) => githubDiscovery(await discoveryIdentity(key), ttl, read, options);
|
|
182
|
+
const readPullRequest = async (url) => discoverPullRequest(url, { fresh: true });
|
|
181
183
|
// Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
|
|
182
184
|
// still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (held && Date.now() < held.until) return held.pending;
|
|
188
|
-
if (discoveredPullRequests.size >= 4096 && !held) discoveredPullRequests.delete(discoveredPullRequests.keys().next().value);
|
|
189
|
-
held = { until: Date.now() + 60_000 };
|
|
190
|
-
held.pending = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: 'discovery' })
|
|
191
|
-
.then(body => { const pr = pullRequest(repositoryOf(url), body); held.until = Date.now() + (pr.state === 'MERGED' ? 3600_000 : 60_000); return pr; });
|
|
192
|
-
// Failed reads stay failed for a minute; no cached empty source or invented successful head.
|
|
193
|
-
void held.pending.catch(() => {});
|
|
194
|
-
discoveredPullRequests.set(key, held);
|
|
195
|
-
return held.pending;
|
|
185
|
+
export async function discoverPullRequest(url, { fresh = false } = {}) {
|
|
186
|
+
return discoveryRead(`pr:${repositoryOf(url).toLowerCase()}:${numberOf(url)}`, 60_000, async () => pullRequest(repositoryOf(url),
|
|
187
|
+
await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: fresh ? 'validation' : 'discovery' })),
|
|
188
|
+
{ fresh, ttlOf: pr => pr.state === 'MERGED' ? 3600_000 : 60_000 });
|
|
196
189
|
}
|
|
197
190
|
|
|
198
191
|
/**
|
|
@@ -339,31 +332,16 @@ async function recreatedWorktree(workspace, resolved) {
|
|
|
339
332
|
try { const made = await resolveWorkspace(workspace, true); return made.path === resolved.path && checkoutPresent(made.path); } catch { return false; }
|
|
340
333
|
}
|
|
341
334
|
|
|
342
|
-
const pullRequestLists = new Map();
|
|
343
|
-
const mergedPullRequestLists = new Map();
|
|
344
|
-
// Keep in-flight and failed list reads too, with room for the registered branch set. History changes rarely;
|
|
345
|
-
// ordinary open work is discovered by the repository's open-PR listing.
|
|
346
|
-
async function discoveryList(cache, key, ttl, read) {
|
|
347
|
-
key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
|
|
348
|
-
let held = cache.get(key);
|
|
349
|
-
if (held && Date.now() < held.until) return held.pending;
|
|
350
|
-
if (cache.size >= 4096 && !held) cache.delete(cache.keys().next().value);
|
|
351
|
-
held = { until: Date.now() + ttl };
|
|
352
|
-
held.pending = Promise.resolve().then(read).catch(error => { held.until = Date.now() + 60_000; throw error; });
|
|
353
|
-
void held.pending.catch(() => {}); cache.set(key, held);
|
|
354
|
-
return held.pending;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
335
|
/** Historical work can already be merged when an outage prevented the board's
|
|
358
336
|
* review request. Read only the arc's declared branch, never all closed PRs. */
|
|
359
337
|
async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
|
|
360
|
-
const key = `${repository}:${branch}`;
|
|
338
|
+
const key = `${repository.toLowerCase()}:${branch}`;
|
|
361
339
|
const read = async () => {
|
|
362
340
|
const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
|
|
363
341
|
return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
|
|
364
342
|
.filter(p => p.merged_at).map(p => pullRequest(repository, p));
|
|
365
343
|
};
|
|
366
|
-
return
|
|
344
|
+
return discoveryRead(`history:${key}`, 900_000, read, { fresh });
|
|
367
345
|
}
|
|
368
346
|
|
|
369
347
|
async function branchCheckout(board, id, repository, branch) {
|
|
@@ -430,18 +408,38 @@ export function declaredWorkBranches(db, id) {
|
|
|
430
408
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
431
409
|
* 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
|
|
432
410
|
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
433
|
-
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false } = {}) {
|
|
411
|
+
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, repositories = [] } = {}) {
|
|
412
|
+
if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, repositories });
|
|
413
|
+
// Every authorization entry acquires the broad advisory scope, not only
|
|
414
|
+
// dispatch. An older receipt and a checkout now on main still have to see
|
|
415
|
+
// implicit wt/<card> PRs. Discovery failures leave that scope unknown.
|
|
416
|
+
const scope = new Set(repositories);
|
|
417
|
+
await discoverReviewSources(board, id, { repositories, strict: true }, scope);
|
|
418
|
+
return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, repositories: [...scope] });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, repositories = [], strict = false } = {}, scope = null) {
|
|
434
422
|
const own = await repositoryWorkspace(board, id);
|
|
435
|
-
const registered = registeredBranches(board, id);
|
|
423
|
+
const registered = registeredBranches(board, id).map(r => ({ ...r, repository: r.repository.toLowerCase() }));
|
|
436
424
|
if (!own && !registered.length) return null;
|
|
437
425
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
438
|
-
const ownRepository = own?.repository ?? (own?.here ? await originOf(own.path) : null);
|
|
426
|
+
const ownRepository = (own?.repository ?? (own?.here ? await originOf(own.path) : null))?.toLowerCase();
|
|
439
427
|
if (!ownRepository && !registered.length) return null;
|
|
440
428
|
const current = task?.branch_name ? null : own?.branch ?? (own?.here ? await run('git', ['branch', '--show-current'], own.path) : null);
|
|
441
429
|
let branch = task?.branch_name ?? (current || null);
|
|
442
430
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
443
431
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
444
|
-
|
|
432
|
+
// Broad discovery finds wt/<card> work in sibling repositories. Authorization
|
|
433
|
+
// refreshes only this arc's repositories, including siblings already found or
|
|
434
|
+
// requested; an unrelated checkout is never a validation-purpose PR scan.
|
|
435
|
+
const previous = reviewCandidate(board, id);
|
|
436
|
+
const requested = [...candidatePullRequests(previous).map(p => p.repository ?? repositoryOf(p.pr.url)),
|
|
437
|
+
...(previous?.review_repositories ?? [])];
|
|
438
|
+
const siblings = fresh ? [] : await knownRepositories({ strict });
|
|
439
|
+
const repositoriesToRead = [...new Set([ownRepository, ...registered.map(r => r.repository), ...requested,
|
|
440
|
+
...repositories, ...siblings].filter(Boolean).map(r => r.toLowerCase()))];
|
|
441
|
+
const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
|
|
442
|
+
.filter(Boolean).map(r => r.toLowerCase()));
|
|
445
443
|
const answered = answeredChanges(board, id);
|
|
446
444
|
const groups = await readTogether(repositoriesToRead, async repository => {
|
|
447
445
|
const prs = [];
|
|
@@ -449,22 +447,22 @@ export async function openReviewPullRequests(board, id, { fresh = false, checkWo
|
|
|
449
447
|
// Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
|
|
450
448
|
let list;
|
|
451
449
|
try {
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
};
|
|
461
|
-
// An empty named-only observation cannot supply branch metadata when a later card registers this repo.
|
|
462
|
-
const listKey = `${repository}:${hasWorkBranches ? 'work' : 'named'}`;
|
|
463
|
-
list = fresh ? await read() : await discoveryList(pullRequestLists, listKey, 300_000, read);
|
|
450
|
+
const items = await discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
|
|
451
|
+
(await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
|
|
452
|
+
// Empty unrelated repositories need no default metadata. Keep that lookup separate from the shared list,
|
|
453
|
+
// so a card declaring the repository can acquire it without listing the same open PRs again.
|
|
454
|
+
const needsDefault = items.length > 0 || hasWorkBranches;
|
|
455
|
+
const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch
|
|
456
|
+
?? (needsDefault ? await discoveryRead(`default:${repository.toLowerCase()}`, 300_000, async () =>
|
|
457
|
+
(await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch, { fresh }) : null);
|
|
458
|
+
list = { items, defaultBranch };
|
|
464
459
|
} catch (error) {
|
|
465
|
-
if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
460
|
+
if (fresh || strict || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
466
461
|
return []; // An unread sibling stays unread: do not cache an empty successful result.
|
|
467
462
|
}
|
|
463
|
+
// Include excluded drafts/other bases in the fresh scope: becoming ready
|
|
464
|
+
// must not let a sibling source escape the complete-set check.
|
|
465
|
+
if (list.items.some(pr => mine(repository, pr.headRefName))) reviewRepositories.add(repository);
|
|
468
466
|
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
469
467
|
if (repository === ownRepository && branch) {
|
|
470
468
|
if (branch === list.defaultBranch) branch = null;
|
|
@@ -497,24 +495,34 @@ export async function openReviewPullRequests(board, id, { fresh = false, checkWo
|
|
|
497
495
|
// sources or its tasks' registered branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
|
|
498
496
|
// declared one, so a push after the declaration is reviewed instead of failing every tick.
|
|
499
497
|
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);
|
|
500
|
-
const
|
|
501
|
-
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source =>
|
|
498
|
+
const declaredRepositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
|
|
499
|
+
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => declaredRepositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
|
|
502
500
|
for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
|
|
503
501
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
504
502
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
505
503
|
}
|
|
504
|
+
// Retain scope even when all matching PRs are excluded drafts/other bases,
|
|
505
|
+
// or judged merged sources, so a fresh scan cannot inherit that exclusion.
|
|
506
|
+
if (scope) for (const repository of reviewRepositories) scope.add(repository);
|
|
506
507
|
if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
|
|
507
|
-
// Advisory facts can refuse an attempt before its authoritative REST scan.
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
508
|
+
// Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
|
|
509
|
+
// doing checkout work: a wrong base cannot be repaired by revalidating the whole arc every round. Discovery
|
|
510
|
+
// refreshes changed heads/bases/states; local readiness is read each round. Neither check authorizes a move:
|
|
511
|
+
// verifyPullRequest repeats both against fresh metadata and the pushed head below.
|
|
512
|
+
if (checkWorkspaces) {
|
|
513
|
+
for (const pr of prs) requireReviewPullRequestProperties(pr.url, pr.head, pr.observed);
|
|
514
|
+
await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
|
|
515
|
+
}
|
|
516
|
+
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort() };
|
|
512
517
|
}
|
|
513
518
|
|
|
514
519
|
/** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
|
|
515
520
|
* beyond its repository's default branch, locally or on origin, its checkout holds no uncommitted work, no PR was
|
|
516
521
|
* ever opened from it, and it registered no other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
|
|
517
|
-
export async function noCodeChange(board, id) {
|
|
522
|
+
export async function noCodeChange(board, id, { fresh = false } = {}) {
|
|
523
|
+
// Once this arc has named code for a review, an empty open set after merging is not a no-code disposition.
|
|
524
|
+
// In particular, do not revalidate the same merged branch's PR history on every advisory round.
|
|
525
|
+
if (candidatePullRequests(reviewCandidate(board, id)).length) return null;
|
|
518
526
|
const taskRow = board.db.prepare('SELECT workspace_kind FROM tasks WHERE id=?').get(id);
|
|
519
527
|
if (taskRow?.workspace_kind === 'scratch') {
|
|
520
528
|
if (board.db.prepare("SELECT 1 FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE c.subtask_of=? AND t.workspace_kind!='scratch' AND t.status!='cancelled' LIMIT 1").get(id)) return null;
|
|
@@ -542,16 +550,22 @@ export async function noCodeChange(board, id) {
|
|
|
542
550
|
const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
|
|
543
551
|
if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
|
|
544
552
|
const owner = repository.split('/')[0];
|
|
545
|
-
//
|
|
546
|
-
|
|
553
|
+
// Advisory eligibility never spends validation's reserve. The committed disposition repeats this read fresh.
|
|
554
|
+
const path = `/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`;
|
|
555
|
+
const history = await discoveryRead(`no-code:${repository.toLowerCase()}:${branch}`, 900_000, () =>
|
|
556
|
+
github(path, false, { purpose: fresh ? 'validation' : 'discovery' }), { fresh });
|
|
557
|
+
if (history.length) return null;
|
|
547
558
|
return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
|
|
548
559
|
} catch (error) { if (error.code === 'GITHUB_BUDGET') throw error; return null; }
|
|
549
560
|
}
|
|
550
561
|
|
|
551
562
|
/** The repositories this machine keeps a checkout of. */
|
|
552
|
-
async function knownRepositories({
|
|
553
|
-
try {
|
|
554
|
-
|
|
563
|
+
async function knownRepositories({ strict = false } = {}) {
|
|
564
|
+
try {
|
|
565
|
+
const checkouts = await branchCheckouts(null, '*');
|
|
566
|
+
return [...new Set(checkouts.map(row => row.repository))];
|
|
567
|
+
}
|
|
568
|
+
catch (error) { if (strict) throw error; return []; }
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: its last verdict's findings are answered, or
|
|
@@ -612,28 +626,27 @@ export function answeredChanges(board, id) {
|
|
|
612
626
|
return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
|
|
613
627
|
}
|
|
614
628
|
|
|
615
|
-
const commitReads = new Map();
|
|
616
629
|
/** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
|
|
617
630
|
* is unknown, never evidence of old work or of a changed tree. */
|
|
618
631
|
async function commitRead({ url, head }) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
commitReads.set(key, read);
|
|
626
|
-
return read;
|
|
632
|
+
return discoveryRead(`commit:${repositoryOf(url).toLowerCase()}:${head}`, 3600_000, async () => {
|
|
633
|
+
const commit = (await github(`/repos/${repositoryOf(url)}/commits/${head}`, false, { purpose: actionReads.getStore() ? 'validation' : 'discovery' })).commit;
|
|
634
|
+
const at = Math.floor(Date.parse(commit?.committer?.date) / 1000), tree = commit?.tree?.sha;
|
|
635
|
+
if (!Number.isFinite(at) || typeof tree !== 'string' || !tree) throw new Error(`GitHub commit ${head} of ${url} is unreadable; review eligibility is unknown`);
|
|
636
|
+
return { at, tree };
|
|
637
|
+
}, { cacheErrors: false });
|
|
627
638
|
}
|
|
628
639
|
const commitTime = async (p) => (await commitRead(p)).at;
|
|
629
640
|
/** Whether the PR's head carries the same source tree as `commit` of the same PR. */
|
|
630
641
|
const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
|
|
631
642
|
|
|
632
643
|
/** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
|
|
633
|
-
async function requireCurrentReviewSet(board, id, urls) {
|
|
634
|
-
const current = await openReviewPullRequests(board, id, { fresh: true
|
|
644
|
+
async function requireCurrentReviewSet(board, id, urls, repositories = []) {
|
|
645
|
+
const current = await openReviewPullRequests(board, id, { fresh: true,
|
|
646
|
+
repositories: [...repositories, ...urls.map(repositoryOf)] });
|
|
635
647
|
const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
|
|
636
648
|
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`);
|
|
649
|
+
return current?.review_repositories ?? repositories;
|
|
637
650
|
}
|
|
638
651
|
|
|
639
652
|
/** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
|
|
@@ -682,9 +695,16 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
682
695
|
}
|
|
683
696
|
const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
|
|
684
697
|
const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
|
|
685
|
-
if (stage === 'request_review' || stage === 'review_verdict') await requireCurrentReviewSet(board, id, urls);
|
|
686
|
-
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
687
698
|
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.');
|
|
699
|
+
// Advisory heads can refuse a redundant request, never authorize one. Do
|
|
700
|
+
// this before a fresh source scan, including for callers outside dispatch.
|
|
701
|
+
const proposed = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
702
|
+
if (stage === 'request_review' && urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
|
|
703
|
+
&& !await reviewDue(board, id, urls.map((url, i) => ({ url, head: proposed[i] })))) {
|
|
704
|
+
throw new Error('Every proposed PR head already has a verdict or carries no new source; no review is due.');
|
|
705
|
+
}
|
|
706
|
+
if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? []);
|
|
707
|
+
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
688
708
|
// Merged source is immutable and needs no surviving workspace. Resolve an
|
|
689
709
|
// unmerged candidate's checkout as before; a broken legacy path never
|
|
690
710
|
// prevents a verdict on already merged commits.
|
|
@@ -752,8 +772,8 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
|
|
|
752
772
|
}
|
|
753
773
|
}
|
|
754
774
|
|
|
755
|
-
/**
|
|
756
|
-
|
|
775
|
+
/** PR-only readiness, shared by advisory refusals and fresh source validation. */
|
|
776
|
+
function requireReviewPullRequestProperties(url, supplied, observed) {
|
|
757
777
|
if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
|
|
758
778
|
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
759
779
|
// A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
|
|
@@ -768,10 +788,17 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, ob
|
|
|
768
788
|
const mainBranch = observed.defaultBranch ?? 'main';
|
|
769
789
|
// A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
|
|
770
790
|
if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
|
|
771
|
-
if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
|
|
772
791
|
return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
|
|
773
792
|
}
|
|
774
793
|
|
|
794
|
+
/** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
|
|
795
|
+
async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
|
|
796
|
+
const source = requireReviewPullRequestProperties(url, supplied, observed);
|
|
797
|
+
const head = source.commit;
|
|
798
|
+
if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
|
|
799
|
+
return source;
|
|
800
|
+
}
|
|
801
|
+
|
|
775
802
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
776
803
|
/** Read the tracked workflow revision the next serve tick will load. */
|
|
777
804
|
export async function workflowRevision(root) {
|
package/board/cli.mjs
CHANGED
|
@@ -70,7 +70,7 @@ async function boardPublishing(root) {
|
|
|
70
70
|
const board = listed?.boards?.find((b) => b.root === here);
|
|
71
71
|
return board ? { ...board, refused: listed.refused ?? null } : null;
|
|
72
72
|
}
|
|
73
|
-
import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
73
|
+
import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
74
74
|
import { watchBoard } from './wake.mjs';
|
|
75
75
|
import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
|
|
76
76
|
import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
|
|
@@ -144,6 +144,8 @@ And:
|
|
|
144
144
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
145
145
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
146
146
|
adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> detach <id> [--to person:ID] close <id> serve (= daemon) list --machine M
|
|
147
|
+
answer <id> <option> choose an option (its number or label) of the question the card's session shows on its pane: the
|
|
148
|
+
card's manager, its creator or a signed-in person; recorded on the card as question_answered with who answered
|
|
147
149
|
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
148
150
|
notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
|
|
149
151
|
notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
|
|
@@ -664,6 +666,13 @@ export async function runVerb(argv) {
|
|
|
664
666
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
665
667
|
if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
|
|
666
668
|
if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
|
|
669
|
+
// the question the card's session shows on its pane, while it shows (VO-3395), with the door that answers it
|
|
670
|
+
const asked = card.runs.find((r) => r.id === t.current_run_id)?.metadata?.supercode?.question;
|
|
671
|
+
if (asked?.options?.length) {
|
|
672
|
+
out.push(` question: its session asks on pane ${asked.pane ?? '?'}${asked.machine ? ` on ${asked.machine}` : ''} (since ${ts(asked.since)}): ${asked.question || '(no words above its options)'}`);
|
|
673
|
+
for (const option of asked.options) out.push(` ${String(option.number).padStart(2)}. ${option.label}${option.detail ? ` (${option.detail})` : ''}`);
|
|
674
|
+
out.push(` answer: supercode workflow answer ${t.id} <number>`);
|
|
675
|
+
}
|
|
667
676
|
// where the card's time went: each role's runs, and the rest waiting
|
|
668
677
|
const mins = (sec) => `${Math.round(sec / 60)}m`;
|
|
669
678
|
out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
|
|
@@ -1263,6 +1272,22 @@ export async function runVerb(argv) {
|
|
|
1263
1272
|
say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
|
|
1264
1273
|
return 0;
|
|
1265
1274
|
}
|
|
1275
|
+
case 'answer': {
|
|
1276
|
+
// the question the card's session shows on its pane, answered through its machine's panes.answer door as this
|
|
1277
|
+
// board (VO-3395); the card's manager, its creator or a signed-in person answers, and is recorded as having answered
|
|
1278
|
+
need(2, 'a card id and the option to choose (its number or its label)');
|
|
1279
|
+
withBoard(b => {
|
|
1280
|
+
const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
|
|
1281
|
+
if (!roles.includes('manager') && !roles.includes('creator') && !(!sid && principalOf()?.startsWith('person:')))
|
|
1282
|
+
throw new Error(`Only ${id}'s manager, its creator or a signed-in person answers its session's question. Nothing was typed.`);
|
|
1283
|
+
});
|
|
1284
|
+
const by = sid ? callerAddress() ?? actorOf() : principalOf() ?? actorOf();
|
|
1285
|
+
const answered = await withBoardAsync((b) => answerQuestion(b, id, rest.slice(1).join(' '), { by }));
|
|
1286
|
+
const choice = answered.choice ? `${answered.choice.number}. ${answered.choice.label}` : rest.slice(1).join(' ');
|
|
1287
|
+
say(args, answered.answered ? `${id}: answered "${choice}" on pane ${answered.pane} (recorded as question_answered, by ${by})`
|
|
1288
|
+
: `${id}: chose "${choice}" on pane ${answered.pane}, but ${answered.reason ?? 'the pane did not show it taken'}`, { ok: answered.answered === true, answer: answered });
|
|
1289
|
+
return answered.answered ? 0 : 1;
|
|
1290
|
+
}
|
|
1266
1291
|
case 'adopt': {
|
|
1267
1292
|
need(2, 'a card id and a session');
|
|
1268
1293
|
withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
package/board/dispatch.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
|
25
25
|
import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
26
26
|
import { readMachineFleet } from './machine-fleet.mjs';
|
|
27
27
|
import { homeFleetMachines } from './fleet-targets.mjs';
|
|
28
|
-
import { delegatedWork, idleInterval } from './session-work.mjs';
|
|
28
|
+
import { delegatedWork, idleInterval, questionText } from './session-work.mjs';
|
|
29
29
|
import { localSessionDelegations } from '../agents.mjs';
|
|
30
30
|
import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
|
|
31
31
|
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
@@ -813,6 +813,40 @@ async function stopOwnedSession(board, session) {
|
|
|
813
813
|
}
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Answer the question card `id`'s session shows on its pane (VO-3395): the option `choice` names (its number or its
|
|
818
|
+
* label), chosen through the pane machine's `panes.answer` door, as this board, for `by` (who asked the board to answer,
|
|
819
|
+
* recorded as said). The card records `question_answered` with who answered, what was chosen and whether the pane
|
|
820
|
+
* showed the answer taken; a door that refused or could not be reached records nothing and throws.
|
|
821
|
+
*/
|
|
822
|
+
export async function answerQuestion(board, id, choice, { by }) {
|
|
823
|
+
const run = view(board, (db) => { const task = getTask(db, id); return task?.current_run_id ? currentRun(db, id) : null; });
|
|
824
|
+
if (!run) throw new Error(`${id} has no active run, so no session of it is asking anything`);
|
|
825
|
+
const session = run.metadata?.supercode ?? {};
|
|
826
|
+
if (!session.pane) throw new Error(`${id}'s session runs in no pane a machine keeps (${session.surface ?? 'unknown surface'}); its question has no pane door`);
|
|
827
|
+
const asked = session.question ?? null;
|
|
828
|
+
const machine = normalMachine(session.machine ?? localMachine()) === localMachine() ? null : session.machine;
|
|
829
|
+
const r = await runtimeDoor(machine, 'harness.v1.panes.answer', {
|
|
830
|
+
// the board has held the asker to the card's manager, creator or a person: on its own machine it says so as
|
|
831
|
+
// --foreign does, since a Windows daemon takes no local caller as established (#1245) and would refuse the board
|
|
832
|
+
pane: session.pane, choice: String(choice), answerer: by ?? null, foreign: true,
|
|
833
|
+
...(asked?.question ? { question: asked.question } : {}), ...(session.launch_key ? { launch: session.launch_key } : {}),
|
|
834
|
+
}, { timeout: 30_000, as: boardAddress(board.label) });
|
|
835
|
+
if (r.code !== 0) {
|
|
836
|
+
const failure = json(r.stderr.trim(), null);
|
|
837
|
+
throw new Error(`${id}: pane ${session.pane} on ${session.machine ?? localMachine()} was not answered: ${failure?.message ?? (r.stderr || r.stdout).trim().slice(-500)}`);
|
|
838
|
+
}
|
|
839
|
+
const answer = json(r.stdout.trim(), {});
|
|
840
|
+
tx(board, (db) => {
|
|
841
|
+
const current = currentRun(db, id);
|
|
842
|
+
event(db, id, 'question_answered', { by: by ?? null, through: boardAddress(board.label), pane: session.pane, machine: session.machine ?? null,
|
|
843
|
+
question: answer.question ?? asked?.question ?? null, choice: answer.choice ?? null, keys: answer.keys ?? null, answered: answer.answered === true,
|
|
844
|
+
...(answer.reason ? { reason: answer.reason } : {}) }, current?.id === run.id ? run.id : null);
|
|
845
|
+
if (answer.answered === true && current?.id === run.id) setRunSession(db, run.id, { question: null });
|
|
846
|
+
});
|
|
847
|
+
return answer;
|
|
848
|
+
}
|
|
849
|
+
|
|
816
850
|
// ------------------------------------------------------------------ starting, as the workflow's dispatch says
|
|
817
851
|
|
|
818
852
|
/** The command form a card's session uses to reach its board: `--root` on the board's machine, `--at` elsewhere. */
|
|
@@ -1740,23 +1774,35 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1740
1774
|
}
|
|
1741
1775
|
// a reading that shows the session no longer waiting ends the prompt; a round that did not read it does not
|
|
1742
1776
|
if (session.held_notice && live && !asked) setRunSession(db, runRow.id, { held_notice: null });
|
|
1777
|
+
if (session.question && live && !(asked && live.question)) setRunSession(db, runRow.id, { question: null });
|
|
1743
1778
|
if (asked) {
|
|
1744
1779
|
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
const
|
|
1780
|
+
// The question its pane shows, with its options, is the card's to carry while it shows (VO-3395): kept on the
|
|
1781
|
+
// run, said in its event and its manager's notice with the door that answers it.
|
|
1782
|
+
const question = live.question && Array.isArray(live.question.options) && live.question.options.length ? {
|
|
1783
|
+
question: String(live.question.question ?? '').slice(0, 1000),
|
|
1784
|
+
options: live.question.options.slice(0, 20).map((option) => ({ number: Number(option.number), label: String(option.label ?? '').slice(0, 300), ...(option.detail ? { detail: String(option.detail).slice(0, 300) } : {}) })),
|
|
1785
|
+
} : null;
|
|
1786
|
+
const asking = question ? JSON.stringify([question.question, question.options.map((option) => option.label)]) : '';
|
|
1787
|
+
if (question && JSON.stringify([session.question?.question, session.question?.options?.map((option) => option.label)]) !== asking)
|
|
1788
|
+
setRunSession(db, runRow.id, { question: { ...question, address, pane: session.pane ?? null, machine: session.machine ?? null, since: observedAt } });
|
|
1789
|
+
// One prompt is one notice: the same wait (what it waits for, since when its harness says, the question its
|
|
1790
|
+
// pane shows) on the same session, whichever of its cards a round reads first and however long it stands.
|
|
1791
|
+
const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? '', asking].join('|');
|
|
1748
1792
|
if (session.held_notice?.key === key) return;
|
|
1749
1793
|
// every run a card works under: an ended run can still be its card's current one
|
|
1750
1794
|
const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
|
|
1751
1795
|
setRunSession(db, runRow.id, { held_notice: { key, at: observedAt } });
|
|
1752
1796
|
if (told) return;
|
|
1753
|
-
const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null });
|
|
1797
|
+
const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null, ...(question ? { question } : {}) });
|
|
1754
1798
|
const others = (cardsOf.get(address) ?? []).filter((card) => card !== id);
|
|
1755
|
-
const what =
|
|
1799
|
+
const what = question ? `asks on its pane: ${questionText(question)}`
|
|
1800
|
+
: live.turn_source === 'pane_prompt' ? 'shows a prompt on its pane that waits for an answer'
|
|
1756
1801
|
: `reports that it is waiting on its user${live.waiting_for ? ` (${live.waiting_for})` : ''}${live.turn_since_ms ? ` since ${new Date(Number(live.turn_since_ms)).toISOString().slice(0, 19)}Z` : ''}`;
|
|
1757
1802
|
event(db, id, 'session_held', observation, runRow.id);
|
|
1758
1803
|
const to = managers[0] ?? null;
|
|
1759
|
-
|
|
1804
|
+
const door = question ? ` Answer it with: supercode workflow answer ${id} <option number> ${whereOf(board, task)}.` : '';
|
|
1805
|
+
if (to) event(db, id, 'board_message', { to, text: `${id}: its session ${address} ${what}.${door}${others.length ? ` It is also on ${others.join(', ')}.` : ''}` }, null);
|
|
1760
1806
|
(report.held ??= []).push([id, 'waiting_on_user', address]);
|
|
1761
1807
|
return;
|
|
1762
1808
|
}
|
|
@@ -2457,20 +2503,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2457
2503
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
2458
2504
|
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, ...kept } = prev;
|
|
2459
2505
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
2460
|
-
//
|
|
2461
|
-
// this run is still the card's current one (its pane and native reads were awaited)
|
|
2462
|
-
tx(board, (db) => {
|
|
2463
|
-
if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its session was read; nothing was told to it`), { launchSuperseded: true });
|
|
2464
|
-
setRunSession(db, runId, { ...session, ...meta });
|
|
2465
|
-
transferRetainedSession(db, task.id, runId, session);
|
|
2466
|
-
// an earlier run's close of this pane is this run's now: the pane is the kept session's, not a close to retry
|
|
2467
|
-
if (prev.pane) for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
|
|
2468
|
-
const s = json(r.metadata, {})?.supercode;
|
|
2469
|
-
if (s?.pane === prev.pane && s.machine === prev.machine && !s.close_confirmed_at && !s.close_transferred_to) setRunSession(db, r.id, { ...s, close_transferred_to: runId, close_requested: null });
|
|
2470
|
-
}
|
|
2471
|
-
});
|
|
2472
|
-
// filed in its mailbox, which delivers it
|
|
2473
|
-
if (input) { assertRunCurrent(board, task.id, runId); await fileMail(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
|
|
2506
|
+
// The common binding below transfers ownership and subscriptions before filing the continuation prompt.
|
|
2474
2507
|
} else if (prev.resumable !== false && await nativeAbsent(prev)) {
|
|
2475
2508
|
// nothing to resume and no context to keep: the slot's next session starts fresh
|
|
2476
2509
|
replaced = { session_id: prev.session_id, why: `${prev.machine} holds no ${prev.harness ?? 'claude-code'} session of that id (it never took a first turn)`, letGo: true };
|
|
@@ -2543,6 +2576,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2543
2576
|
return true;
|
|
2544
2577
|
});
|
|
2545
2578
|
if (!recorded) return { ...session, mode: 'finished_during_launch' };
|
|
2579
|
+
if (session.mode === 'adopt' && input) {
|
|
2580
|
+
assertRunCurrent(board, task.id, runId);
|
|
2581
|
+
await fileMail(board, session.address, input, `board-run-${task.id}-r${runId}`);
|
|
2582
|
+
}
|
|
2546
2583
|
return session;
|
|
2547
2584
|
}
|
|
2548
2585
|
|
|
@@ -2815,7 +2852,19 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2815
2852
|
const beforeRun = view(board, (db) => currentRun(db, id));
|
|
2816
2853
|
const beforeCustody = view(board, db => JSON.stringify(detachedCard(db, id)));
|
|
2817
2854
|
const beforeSession = beforeRun?.metadata?.supercode;
|
|
2818
|
-
|
|
2855
|
+
const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
|
|
2856
|
+
const adoptionSlot = beforeSession?.slot ?? view(board, db => {
|
|
2857
|
+
const scope = scopeOf(db, board, card0, { params: entry0.params });
|
|
2858
|
+
return String(templateValue(runSpec(entry0, statusOf(db, card0), card0)?.session ?? 'worker', scope));
|
|
2859
|
+
});
|
|
2860
|
+
const predecessor = view(board, db => lastSession(db, id, adoptionSlot));
|
|
2861
|
+
const sameIdentity = session => addressOf(session) === found.address;
|
|
2862
|
+
// A deferred run has issued no different identity. Bind its own slot's live predecessor, or the session
|
|
2863
|
+
// discovered in its keyed pane, without ending the run or opening another process.
|
|
2864
|
+
const unissued = !beforeSession?.session_id && !beforeSession?.pane && !beforeSession?.launch_key && !beforeSession?.pid && !beforeSession?.launch_pending;
|
|
2865
|
+
const keyedPane = !beforeSession?.session_id && found.pane && beforeSession?.pane === found.pane
|
|
2866
|
+
&& beforeSession?.machine === host && beforeSession?.harness === harness;
|
|
2867
|
+
if (beforeRun && !sameIdentity(beforeSession) && !keyedPane && !(unissued && sameIdentity(predecessor))) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
|
|
2819
2868
|
if (beforeSession?.pane && beforeSession.pane !== found.pane && await panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
|
|
2820
2869
|
|
|
2821
2870
|
// the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
|
|
@@ -2829,7 +2878,6 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2829
2878
|
}
|
|
2830
2879
|
// Rule 2: a session working a card role is that role's agent, however it started; adopted, it joins the agent as a
|
|
2831
2880
|
// launch does, and its recorded profile is compared with the role's intent (rule 4).
|
|
2832
|
-
const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
|
|
2833
2881
|
const agentRole = view(board, (db) => adoptedRole(db, board, entry0, card0, beforeRun)) ?? 'implementer';
|
|
2834
2882
|
const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
|
|
2835
2883
|
const lane = laneName ? lanes?.[laneName] : null, context = current().facts.SUPERCODE_CONTEXT, team = agentScope(context);
|
|
@@ -2839,6 +2887,8 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2839
2887
|
const stale = (db) => {
|
|
2840
2888
|
if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
|
|
2841
2889
|
if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
|
|
2890
|
+
const binding = currentRun(db, id)?.metadata?.supercode;
|
|
2891
|
+
if (['session_id', 'pane', 'launch_key', 'launch_pending', 'launch_deferred'].some(key => (binding?.[key] ?? null) !== (beforeSession?.[key] ?? null))) return new Error(`${id}'s session binding changed during adoption; retry against its current session`);
|
|
2842
2892
|
if (JSON.stringify(detachedCard(db, id)) !== beforeCustody) return new Error(`${id} changed session custody during adoption; request adoption again`);
|
|
2843
2893
|
return null;
|
|
2844
2894
|
};
|
|
@@ -2857,7 +2907,6 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2857
2907
|
if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
|
|
2858
2908
|
// Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
|
|
2859
2909
|
// adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
|
|
2860
|
-
const adoptionSlot = beforeSession?.slot ?? runSpec(entry0, view(board, db => statusOf(db, card0)), card0)?.session ?? 'worker';
|
|
2861
2910
|
await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
|
|
2862
2911
|
const adopted = tx(board, (db) => {
|
|
2863
2912
|
const task = getTask(db, id);
|
|
@@ -2870,7 +2919,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2870
2919
|
const was = lastSession(db, id, adoptionSlot);
|
|
2871
2920
|
// a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
|
|
2872
2921
|
// left running, and is read)
|
|
2873
|
-
if (was && was
|
|
2922
|
+
if (was && !sameIdentity(was) && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
|
|
2874
2923
|
if (!task.machine) db.prepare('INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine').run(id, host);
|
|
2875
2924
|
// the lane that resumes it if it is lost, set in the same transaction (assigning first would let a tick start another)
|
|
2876
2925
|
if (assignee && assignee !== task.assignee) { db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, id); event(db, id, 'assigned', { assignee }); }
|
|
@@ -2904,7 +2953,13 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2904
2953
|
...(agent ? { agent_id: agent.identity.id, agent_org: team.org, agent_context: context ?? null,
|
|
2905
2954
|
agent_profile_intent: { ...structuredClone(agent.intended.resolved), worker: { ...agent.intended.resolved?.worker, harness: lane.harness, model: lane.model ?? null, cwd: dir ?? task.workspace_path ?? null } } } : {}) };
|
|
2906
2955
|
// a deferred launch was never issued: the adopted session is the run's, and no later round launches another
|
|
2907
|
-
|
|
2956
|
+
const owned = [beforeSession, was].find(s => s && s.pane === session.pane && (sameIdentity(s) || s === beforeSession && keyedPane));
|
|
2957
|
+
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session,
|
|
2958
|
+
detached_at: null, detached_to: null, launch_key: owned?.launch_key ?? null, launch_pending: false, launch_error: null,
|
|
2959
|
+
launch_deferred: false, launch_deferred_error: null, slot: adoptionSlot, retain_after_run: false,
|
|
2960
|
+
close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, close_failed_at: null, close_claim: null,
|
|
2961
|
+
released: false, forgotten: false, monitor_held: false, missing_since: null });
|
|
2962
|
+
transferRetainedSession(db, id, runId, session);
|
|
2908
2963
|
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
|
|
2909
2964
|
replaceWorkerSubscriptions(db, id, found.address);
|
|
2910
2965
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
|
|
@@ -3660,7 +3715,8 @@ function reviewDueKey(board, id, payload) {
|
|
|
3660
3715
|
return `${source}|${closed ?? ''}`;
|
|
3661
3716
|
}
|
|
3662
3717
|
|
|
3663
|
-
|
|
3718
|
+
/** Review selection shared by rounds and read-only inspection (`dry: true`). */
|
|
3719
|
+
export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
|
|
3664
3720
|
const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
3665
3721
|
WHERE c.subtask_of IS NULL AND c.every IS NULL
|
|
3666
3722
|
${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
|
|
@@ -3715,14 +3771,14 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3715
3771
|
report.queued.push([id, `review waits: ${inTurn.why}`]);
|
|
3716
3772
|
continue;
|
|
3717
3773
|
}
|
|
3718
|
-
// A stable
|
|
3719
|
-
//
|
|
3774
|
+
// A stable PR-property or local refusal must not spend a full fresh REST scan every round. Advisory
|
|
3775
|
+
// discovery is sufficient to refuse, never to authorize; the scan below still owns that.
|
|
3720
3776
|
if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
|
|
3721
3777
|
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3722
3778
|
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3723
3779
|
const observed = await withFreshReviewReads(async () => {
|
|
3724
|
-
const candidate = await openReviewPullRequests(board, id, { fresh: true })
|
|
3725
|
-
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id) : null);
|
|
3780
|
+
const candidate = await openReviewPullRequests(board, id, { fresh: true, repositories: payload.review_repositories ?? [] })
|
|
3781
|
+
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id, { fresh: true }) : null);
|
|
3726
3782
|
const evidence = !candidate || candidate.waits ? null : candidate.no_code
|
|
3727
3783
|
? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
|
|
3728
3784
|
: await inspectCompletionEvidence(board, id, entry, candidate, 'request_review');
|
package/board/engine.mjs
CHANGED
|
@@ -131,7 +131,7 @@ export function setRunSession(db, runId, session) {
|
|
|
131
131
|
const before = whole.supercode;
|
|
132
132
|
whole.supercode = { ...before, ...session };
|
|
133
133
|
const task = one(db, 'SELECT task_id FROM task_runs WHERE id = ?', runId);
|
|
134
|
-
if (task && session.session_id &&
|
|
134
|
+
if (task && session.session_id && (before?.session_id !== session.session_id || before?.pane !== session.pane)) retireSupersededRuns(db, task.task_id, runId, whole.supercode);
|
|
135
135
|
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
|
|
136
136
|
return whole;
|
|
137
137
|
}
|
package/board/github-rest.mjs
CHANGED
|
@@ -26,10 +26,77 @@ function connection() {
|
|
|
26
26
|
resource TEXT, used INTEGER, remaining INTEGER, reset_at INTEGER, retry_after INTEGER);
|
|
27
27
|
CREATE INDEX IF NOT EXISTS github_attempts_window ON github_attempts(credential,started_at,purpose);
|
|
28
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)
|
|
29
|
+
CREATE TABLE IF NOT EXISTS github_cooldown (credential TEXT PRIMARY KEY, until_at INTEGER NOT NULL, reason TEXT NOT NULL);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS github_discovery (
|
|
31
|
+
key TEXT PRIMARY KEY, observed_at INTEGER NOT NULL, until_at INTEGER NOT NULL,
|
|
32
|
+
value TEXT, error TEXT, claim TEXT, claim_until INTEGER NOT NULL DEFAULT 0);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS github_refusals (
|
|
34
|
+
id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, credential TEXT NOT NULL, machine TEXT NOT NULL,
|
|
35
|
+
pid INTEGER NOT NULL, caller TEXT NOT NULL, purpose TEXT NOT NULL, method TEXT NOT NULL,
|
|
36
|
+
endpoint TEXT NOT NULL, code TEXT NOT NULL, reason TEXT NOT NULL);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS github_refusals_window ON github_refusals(started_at,purpose);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS github_observation_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);`);
|
|
39
|
+
db.prepare("INSERT OR IGNORE INTO github_observation_meta(key,value) VALUES('refusals_since',?)").run(Date.now());
|
|
30
40
|
connections.set(path, db);
|
|
31
41
|
return db;
|
|
32
42
|
}
|
|
43
|
+
|
|
44
|
+
const discoveryReads = new Map();
|
|
45
|
+
const discoveryPending = () => Object.assign(new Error('GitHub discovery is already being read by another process; no duplicate read was started'), { code: 'github_discovery_pending', deferred: true });
|
|
46
|
+
/** Advisory observations survive command/owner restarts. Fresh transition reads never consume a cached value,
|
|
47
|
+
* but publish their successful observation for discovery. A short transaction claims each advisory read; no
|
|
48
|
+
* transaction spans I/O and a different process defers rather than waiting or duplicating its wire attempts. */
|
|
49
|
+
export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlOf = () => ttl, cacheErrors = true } = {}) {
|
|
50
|
+
const key = createHash('sha256').update(identity).digest('hex');
|
|
51
|
+
const localKey = `${pathOf()}\0${key}`;
|
|
52
|
+
if (!fresh && discoveryReads.has(localKey)) return discoveryReads.get(localKey);
|
|
53
|
+
const work = async () => {
|
|
54
|
+
const db = connection(), began = Date.now(), claim = randomUUID();
|
|
55
|
+
if (!fresh) {
|
|
56
|
+
let cached = null;
|
|
57
|
+
db.exec('BEGIN IMMEDIATE');
|
|
58
|
+
try {
|
|
59
|
+
const held = db.prepare('SELECT * FROM github_discovery WHERE key=?').get(key);
|
|
60
|
+
if (held && held.until_at > began && (held.value != null || held.error != null)) {
|
|
61
|
+
cached = held;
|
|
62
|
+
} else {
|
|
63
|
+
if (held?.claim_until > began) throw discoveryPending();
|
|
64
|
+
db.prepare(`INSERT INTO github_discovery(key,observed_at,until_at,claim,claim_until) VALUES(?,0,0,?,?)
|
|
65
|
+
ON CONFLICT(key) DO UPDATE SET claim=excluded.claim,claim_until=excluded.claim_until`).run(key, claim, began + 360_000);
|
|
66
|
+
}
|
|
67
|
+
db.exec('COMMIT');
|
|
68
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
69
|
+
if (cached) {
|
|
70
|
+
if (cached.error) { const failure = JSON.parse(cached.error); throw Object.assign(new Error(failure.message), failure); }
|
|
71
|
+
return JSON.parse(cached.value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const retain = (value, error, duration = ttl) => {
|
|
75
|
+
db.prepare(`INSERT INTO github_discovery(key,observed_at,until_at,value,error) VALUES(?,?,?,?,?)
|
|
76
|
+
ON CONFLICT(key) DO UPDATE SET observed_at=excluded.observed_at,until_at=excluded.until_at,
|
|
77
|
+
value=excluded.value,error=excluded.error,claim=NULL,claim_until=0
|
|
78
|
+
WHERE github_discovery.observed_at<=excluded.observed_at`).run(key, began, Date.now() + duration, value, error);
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
const value = await read();
|
|
82
|
+
retain(JSON.stringify(value), null, ttlOf(value));
|
|
83
|
+
return value;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!fresh && cacheErrors) retain(null, JSON.stringify({ message: String(error.message).slice(0, 1000), code: error.code, deferred: error.deferred, retry_at: error.retry_at }));
|
|
86
|
+
throw error;
|
|
87
|
+
} finally {
|
|
88
|
+
db.prepare('UPDATE github_discovery SET claim=NULL,claim_until=0 WHERE key=? AND claim=?').run(key, claim);
|
|
89
|
+
// Retain only live observations/claims. Expired advisory data has no evidentiary role.
|
|
90
|
+
db.prepare('DELETE FROM github_discovery WHERE until_at<=? AND claim_until<=?').run(Date.now(), Date.now());
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const pending = work();
|
|
94
|
+
if (!fresh) {
|
|
95
|
+
discoveryReads.set(localKey, pending);
|
|
96
|
+
try { return await pending; } finally { if (discoveryReads.get(localKey) === pending) discoveryReads.delete(localKey); }
|
|
97
|
+
}
|
|
98
|
+
return pending;
|
|
99
|
+
}
|
|
33
100
|
const refusal = (message) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET' });
|
|
34
101
|
const integer = (value) => value != null && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
|
|
35
102
|
function endpointOf(url) {
|
|
@@ -63,7 +130,15 @@ async function issue(url, options, { purpose }) {
|
|
|
63
130
|
db.prepare('INSERT INTO github_attempts(id,credential,started_at,machine,pid,caller,purpose,method,endpoint) VALUES(?,?,?,?,?,?,?,?,?)')
|
|
64
131
|
.run(id, credential, at, hostname(), process.pid, caller, purpose, method, endpoint);
|
|
65
132
|
db.exec('COMMIT');
|
|
66
|
-
} catch (error) {
|
|
133
|
+
} catch (error) {
|
|
134
|
+
db.exec('ROLLBACK');
|
|
135
|
+
// A refused action made no wire attempt. Keep its receipt separately so a full-hour qualification can read
|
|
136
|
+
// actual budget refusals as well as usage, without inflating the allowance or inferring success from silence.
|
|
137
|
+
if (error.code === 'GITHUB_BUDGET') db.prepare(`INSERT INTO github_refusals
|
|
138
|
+
(id,started_at,credential,machine,pid,caller,purpose,method,endpoint,code,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)`)
|
|
139
|
+
.run(id, at, credential, hostname(), process.pid, caller, purpose, method, endpoint, error.code, error.message);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
67
142
|
let response, outcome = 'unknown';
|
|
68
143
|
try { response = await fetch(url, { ...options, redirect: 'manual' }); outcome = response.ok ? 'ok' : `HTTP_${response.status}`; return response; }
|
|
69
144
|
catch (error) { outcome = error.code ?? error.name ?? 'transport_error'; throw error; }
|
|
@@ -109,7 +184,14 @@ export function githubUsage({ since = Date.now() - HOUR } = {}) {
|
|
|
109
184
|
if (!existsSync(path)) return { path, recorded: false, callers: [], endpoints: [] };
|
|
110
185
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
111
186
|
try {
|
|
187
|
+
const hasRefusals = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='github_refusals'").get());
|
|
188
|
+
const hasMeta = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='github_observation_meta'").get());
|
|
189
|
+
const refusalsSince = hasMeta ? db.prepare("SELECT value FROM github_observation_meta WHERE key='refusals_since'").get()?.value ?? null : null;
|
|
112
190
|
return { path, recorded: true, since, limits: { hourly: GITHUB_HOURLY_ATTEMPTS, discovery: GITHUB_DISCOVERY_ATTEMPTS },
|
|
191
|
+
refusals_recorded: hasRefusals, refusals_since: refusalsSince, refusals_complete: refusalsSince != null && refusalsSince <= since,
|
|
192
|
+
refusals: hasRefusals ? db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,code,COUNT(*) refusals,
|
|
193
|
+
MIN(started_at) first_at,MAX(started_at) last_at FROM github_refusals WHERE started_at>=?
|
|
194
|
+
GROUP BY credential,machine,pid,caller,purpose,method,endpoint,code`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })) : [],
|
|
113
195
|
callers: db.prepare(`SELECT credential,machine,pid,caller,purpose,COUNT(*) attempts,SUM(status IS NULL) unanswered
|
|
114
196
|
FROM github_attempts WHERE started_at>=? GROUP BY credential,machine,pid,caller,purpose`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })),
|
|
115
197
|
endpoints: db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,status,COUNT(*) attempts
|
package/board/incidents.mjs
CHANGED
|
@@ -126,6 +126,7 @@ export function auditEndedRuns(db, board, entry) {
|
|
|
126
126
|
if (decided) continue; // its manager or creator ended it by an explicit act
|
|
127
127
|
const audited = db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='audited'").get(task.id,task.run_id);
|
|
128
128
|
if (audited) continue; // Every explicitly schedules the next successful audit
|
|
129
|
+
if (session?.retain_after_run && task.outcome === 'completed') continue; // an explicit finish hands work to review
|
|
129
130
|
if (['blocked','paused','reconfigured','review_passed','review','review_requested','audited'].includes(task.outcome)) continue;
|
|
130
131
|
surfaceIncident(db, board, entry, task.id, 'run_ended', { error: task.error, outcome: task.outcome }, task.run_id);
|
|
131
132
|
}
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -112,7 +112,9 @@ export async function readMachineFleet(machines, local) {
|
|
|
112
112
|
// what a session that needs input says it waits for, what started its open turn (Codex: `goal` for a thread
|
|
113
113
|
// goal's continuation) and since when, where its harness publishes them
|
|
114
114
|
waiting_for: activity.evidence?.waiting_for ?? null, turn_source: activity.evidence?.source ?? null, turn_since_ms: activity.evidence?.native_state_since_ms ?? null,
|
|
115
|
-
turn_trigger: activity.evidence?.turn_trigger ?? null
|
|
115
|
+
turn_trigger: activity.evidence?.turn_trigger ?? null,
|
|
116
|
+
// the question its pane shows and its options, where its machine read one off the screen (VO-3395)
|
|
117
|
+
question: activity.evidence?.question ?? null });
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
const ok = online.has(local);
|
package/board/session-work.mjs
CHANGED
|
@@ -50,3 +50,10 @@ export function idleInterval(session, live, observedAt) {
|
|
|
50
50
|
const since = Math.min(observedAt, Math.max(read, Number(session.watch_since ?? 0)));
|
|
51
51
|
return { since, quiet: Math.max(0, observedAt - since) };
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/** A question a run's session shows on its pane (VO-3395), as one line: its words and each option, numbered. */
|
|
55
|
+
export function questionText(question) {
|
|
56
|
+
if (!question) return '';
|
|
57
|
+
const options = (question.options ?? []).map((option) => `[${option.number}] ${option.label}`).join(' ');
|
|
58
|
+
return `${question.question ? `${question.question} ` : ''}${options}`.trim();
|
|
59
|
+
}
|
package/board/workflow.mjs
CHANGED
|
@@ -1027,7 +1027,9 @@ const ACTIONS = {
|
|
|
1027
1027
|
if (task.current_run_id) throw new Error('End the current run before moving its card.');
|
|
1028
1028
|
if ((value ?? null) === task.machine) return;
|
|
1029
1029
|
if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
|
|
1030
|
-
|
|
1030
|
+
ACTIONS.forget_session(ctx, '*');
|
|
1031
|
+
run(ctx.db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', ctx.id);
|
|
1032
|
+
cardField(ctx, 'workspace_root', null);
|
|
1031
1033
|
cardField(ctx, 'machine', value ?? null);
|
|
1032
1034
|
return;
|
|
1033
1035
|
}
|
|
@@ -1256,16 +1258,25 @@ const ACTIONS = {
|
|
|
1256
1258
|
/** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
|
|
1257
1259
|
export function retireSupersededRuns(db, id, runId, session) {
|
|
1258
1260
|
if (!session?.session_id && !session?.pane) return;
|
|
1259
|
-
for (const old of all(db, `SELECT id,task_id,metadata FROM task_runs WHERE id IN (
|
|
1260
|
-
SELECT id FROM task_runs WHERE id<? AND
|
|
1261
|
+
for (const old of all(db, `SELECT id,task_id,ended_at,metadata FROM task_runs WHERE id IN (
|
|
1262
|
+
SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1261
1263
|
AND json_extract(metadata, '$.supercode.session_id')=?
|
|
1262
|
-
UNION SELECT id FROM task_runs WHERE id<? AND
|
|
1264
|
+
UNION SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
|
|
1263
1265
|
AND json_extract(metadata, '$.supercode.pane')=?)`,
|
|
1264
1266
|
runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
|
|
1265
1267
|
const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
|
|
1266
1268
|
if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
|
|
1267
1269
|
if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
|
|
1268
|
-
|
|
1270
|
+
const transferred = (s.pane && s.pane === session.pane) || (!s.pane && !session.pane && s.session_id === session.session_id);
|
|
1271
|
+
if (old.ended_at) {
|
|
1272
|
+
if (transferred && s.close_transferred_to !== runId) {
|
|
1273
|
+
whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
|
|
1274
|
+
run(db, 'UPDATE task_runs SET metadata=? WHERE id=?', JSON.stringify(whole), old.id);
|
|
1275
|
+
record(db, old.task_id, 'session_transferred', { successor: runId, session_id: s.session_id, pane: s.pane ?? null }, old.id);
|
|
1276
|
+
}
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
if (transferred) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
|
|
1269
1280
|
run(db, "UPDATE task_runs SET status='superseded', outcome='superseded', ended_at=?, claim_lock=NULL, claim_expires=NULL, worker_pid=NULL, metadata=? WHERE id=?", now(), JSON.stringify(whole), old.id);
|
|
1270
1281
|
record(db, old.task_id, 'run_superseded', { successor: runId, session_id: s.session_id, pane: s.pane }, old.id);
|
|
1271
1282
|
}
|
package/board/workspace.mjs
CHANGED
|
@@ -78,8 +78,11 @@ export async function declareWorkspace(kind, value, machine, branch = null) {
|
|
|
78
78
|
const info = await workspaceDoor(machine, 'inspect', value);
|
|
79
79
|
if (kind === 'worktree' && info.linked) throw new Error('worktree workspace already names a linked worktree; name its repository for a solo worktree');
|
|
80
80
|
if (kind === 'dir' && !info.branch && !branch) throw new Error('a detached checkout has no portable branch identity; name the repository and a solo worktree branch');
|
|
81
|
+
if (kind === 'dir' && info.linked && branch && branch !== info.branch) throw new Error(`worktree ${value} is on branch ${info.branch ?? '(detached)'}, not ${branch}`);
|
|
81
82
|
const row = await workspaceDoor(machine, 'register', value);
|
|
82
|
-
|
|
83
|
+
// A primary path plus another branch names that repository's checkout of the branch. A matching explicit
|
|
84
|
+
// checkout stays pinned to the path the manager supplied, even when another clone has the same branch mapped.
|
|
85
|
+
return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' && (!branch || branch === row.branch) ? row.path : null };
|
|
83
86
|
}
|
|
84
87
|
/** The checkouts this machine keeps of any repository on `branch`: where an arc changing several repositories
|
|
85
88
|
* keeps its work in each of them (its own worktree aside). Another machine's map is not read from here. */
|
|
@@ -110,6 +113,14 @@ export async function resolveWorkspace(task, create = false) {
|
|
|
110
113
|
if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
|
|
111
114
|
throw new Error('legacy worktree path needs registration and a repository declaration before launch');
|
|
112
115
|
}
|
|
116
|
+
if (task.workspace_kind === 'dir' && task.workspace_path) {
|
|
117
|
+
const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path);
|
|
118
|
+
if (cached.repository !== repositoryId(declared) || (task.branch_name && cached.branch !== task.branch_name)) {
|
|
119
|
+
throw new Error(`card checkout ${task.workspace_path} does not match ${declared}, branch ${task.branch_name ?? '(primary)'}; retarget its workspace explicitly`);
|
|
120
|
+
}
|
|
121
|
+
if (create) await workspaceDoor(task.machine, 'register', cached.root);
|
|
122
|
+
return cached;
|
|
123
|
+
}
|
|
113
124
|
// A primary checkout can change when another clone is registered. That does
|
|
114
125
|
// not move an already allocated card worktree. Machine moves and workspace
|
|
115
126
|
// retargets clear this runtime receipt; never borrow an arbitrary checkout.
|
|
@@ -119,6 +130,7 @@ export async function resolveWorkspace(task, create = false) {
|
|
|
119
130
|
const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path);
|
|
120
131
|
if (cached.linked && basename(cached.path) === worktreeFolder(task) && basename(dirname(cached.path)) === '.worktrees' &&
|
|
121
132
|
cached.repository === repositoryId(declared) && cached.branch === worktreeBranch(task)) {
|
|
133
|
+
if (create) await workspaceDoor(task.machine, 'register', cached.root);
|
|
122
134
|
return { ...cached, root: dirname(dirname(cached.path)) };
|
|
123
135
|
}
|
|
124
136
|
throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${worktreeBranch(task)}; retarget its workspace explicitly`);
|
package/connection.mjs
CHANGED
|
@@ -38,6 +38,10 @@ export async function connectionCall(record, side, method, path, body, token) {
|
|
|
38
38
|
if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
|
|
39
39
|
return payload;
|
|
40
40
|
}
|
|
41
|
+
/** A registration that trusts a Workplace as its issuer trusts this connection's Workplace and organization, never the
|
|
42
|
+
* one its committed manifest names (agents.teams-app.json names production's): otherwise every connection's agents app
|
|
43
|
+
* trusted production's tokens, and its own Workplace's notices and views were decided as the issuer app instead. */
|
|
44
|
+
export function trustedIssuer(manifest,record){return manifest.issuer===undefined?{}:{issuer:record.issuer,issuer_organization:record.organization};}
|
|
41
45
|
export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
|
|
42
46
|
export function generateModuleConfig(root, record, grant) {
|
|
43
47
|
if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
|
|
@@ -172,7 +176,7 @@ export async function connectWorkplace(flags) {
|
|
|
172
176
|
grant.rh2=exchanged.data;privateJson(path,record);
|
|
173
177
|
}
|
|
174
178
|
if(selected.teamsManifest&&!grant.teams){
|
|
175
|
-
if(!grant.registration){grant.stagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.registration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{...selected.teamsManifest,name:`${selected.teamsManifest.name} (${id.slice(0,8)})`,secret:grant.stagedSecret},owner)).data;privateJson(path,record);}
|
|
179
|
+
if(!grant.registration){grant.stagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.registration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{...selected.teamsManifest,...trustedIssuer(selected.teamsManifest,record),name:`${selected.teamsManifest.name} (${id.slice(0,8)})`,secret:grant.stagedSecret},owner)).data;privateJson(path,record);}
|
|
176
180
|
grant.registration.secret=grant.stagedSecret;
|
|
177
181
|
if(!grant.consent){grant.stagedCode??=`sci_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.consent=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps/${grant.registration.id}/installations`,{redirect_url:selected.teamsManifest.redirect_urls[0],state:id,actions:selected.teamsManifest.actions,code:grant.stagedCode},owner)).data;privateJson(path,record);}
|
|
178
182
|
grant.consent.code=grant.stagedCode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.214",
|
|
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": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@volter/supercode-teams": "^0.3.
|
|
35
|
+
"@volter/supercode-teams": "^0.3.349",
|
|
36
36
|
"@volter/supercode-terminal": "^0.3.10",
|
|
37
37
|
"@volter/supercode-harness-sdk": "^0.3.104",
|
|
38
38
|
"@volter/teams": "0.2.99",
|
|
@@ -61,5 +61,6 @@
|
|
|
61
61
|
"board",
|
|
62
62
|
"apply",
|
|
63
63
|
"README.md"
|
|
64
|
-
]
|
|
64
|
+
],
|
|
65
|
+
"gitHead": "2455341d186426df67c39d16ad03fdb021e2fe8e"
|
|
65
66
|
}
|