@volter/supercode-orchestrator 0.5.212 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 caches advisory reads while waiting. Review requests, verdicts
136
- and acceptance refresh the complete source set, including previously draft or
137
- closed declared sources, and validate its current heads. An unread set defers
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 against its local worktrees. A mismatched HEAD or uncommitted
140
- work refuses that attempt locally. Each later round rechecks the checkout, so
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. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
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 readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
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
- const discoveredPullRequests = new Map();
184
- export async function discoverPullRequest(url) {
185
- const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${url}`;
186
- let held = discoveredPullRequests.get(key);
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 fresh ? read() : discoveryList(mergedPullRequestLists, key, 900_000, read);
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
- const repositoriesToRead = [...new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))];
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 read = async () => {
453
- const items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map((p) => pullRequest(repository, p));
454
- // An empty unrelated repository has neither candidates nor branch history to filter. Its default
455
- // branch cannot affect this set; only the card's own or registered repositories need that metadata.
456
- const needsDefault = items.length > 0 || hasWorkBranches;
457
- const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch
458
- ?? (needsDefault ? (await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch : null);
459
- return { items, defaultBranch };
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 repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
501
- const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
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. Recheck the local checkout each
508
- // round, so a repaired HEAD or clean worktree wakes it without caching the refusal. This is never evidence
509
- // for a transition: verifyPullRequest repeats the checks against fresh metadata and the pushed head below.
510
- if (checkWorkspaces) await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
511
- return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
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
- // An empty PR history can authorize a no-code review; keep this validation fresh too.
546
- if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
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({ fresh = false } = {}) {
553
- try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
554
- catch (error) { if (fresh) throw error; return []; }
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
- const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
620
- if (commitReads.has(key)) return commitReads.get(key);
621
- const commit = (await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit;
622
- const at = Math.floor(Date.parse(commit?.committer?.date) / 1000), tree = commit?.tree?.sha;
623
- if (!Number.isFinite(at) || typeof tree !== 'string' || !tree) throw new Error(`GitHub commit ${head} of ${url} is unreadable; review eligibility is unknown`);
624
- const read = { at, tree };
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
- /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
756
- async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
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.'); });
@@ -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
- // One prompt is one notice: the same wait (what it waits for, since when its harness says) on the same
1746
- // session, whichever of its cards a round reads first and however long it stands.
1747
- const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
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 = live.turn_source === 'pane_prompt' ? 'shows a prompt on its pane that waits for an answer'
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
- if (to) event(db, id, 'board_message', { to, text: `${id}: its session ${address} ${what}.${others.length ? ` It is also on ${others.join(', ')}.` : ''}` }, null);
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
- // a kept session is told what this run is for (its card came back to it, with what happened since), only while
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
- if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
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.session_id !== sessionId && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
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
- setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, detached_at: null, detached_to: null, launch_deferred: false, slot: currentRun(db, id)?.metadata?.supercode?.slot ?? beforeSession?.slot ?? expected?.session ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
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
- async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
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 local refusal (wrong HEAD or uncommitted work) must not spend a full fresh REST scan every
3719
- // round. Advisory discovery is sufficient to refuse, never to authorize; the scan below still owns that.
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 && session.pane && (before?.session_id !== session.session_id || before?.pane !== session.pane)) retireSupersededRuns(db, task.task_id, runId, whole.supercode);
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
  }
@@ -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) { db.exec('ROLLBACK'); throw 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
@@ -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
  }
@@ -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);
@@ -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
+ }
@@ -1256,16 +1256,25 @@ const ACTIONS = {
1256
1256
  /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
1257
1257
  export function retireSupersededRuns(db, id, runId, session) {
1258
1258
  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 ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1259
+ for (const old of all(db, `SELECT id,task_id,ended_at,metadata FROM task_runs WHERE id IN (
1260
+ SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
1261
1261
  AND json_extract(metadata, '$.supercode.session_id')=?
1262
- UNION SELECT id FROM task_runs WHERE id<? AND ended_at IS NULL AND json_extract(metadata, '$.supercode.machine') IS ?
1262
+ UNION SELECT id FROM task_runs WHERE id<? AND json_extract(metadata, '$.supercode.machine') IS ?
1263
1263
  AND json_extract(metadata, '$.supercode.pane')=?)`,
1264
1264
  runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
1265
1265
  const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
1266
1266
  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
1267
  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
- if (s.pane && s.pane === session.pane) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1268
+ const transferred = (s.pane && s.pane === session.pane) || (!s.pane && !session.pane && s.session_id === session.session_id);
1269
+ if (old.ended_at) {
1270
+ if (transferred && s.close_transferred_to !== runId) {
1271
+ whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1272
+ run(db, 'UPDATE task_runs SET metadata=? WHERE id=?', JSON.stringify(whole), old.id);
1273
+ record(db, old.task_id, 'session_transferred', { successor: runId, session_id: s.session_id, pane: s.pane ?? null }, old.id);
1274
+ }
1275
+ continue;
1276
+ }
1277
+ if (transferred) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1269
1278
  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
1279
  record(db, old.task_id, 'run_superseded', { successor: runId, session_id: s.session_id, pane: s.pane }, old.id);
1271
1280
  }
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.212",
3
+ "version": "0.5.213",
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.347",
35
+ "@volter/supercode-teams": "^0.3.344",
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": "d017029f1b1d9293e295839a496be94a641b1ac0"
65
66
  }