@volter/supercode-orchestrator 0.5.215 → 0.5.216

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/ci.mjs CHANGED
@@ -101,6 +101,27 @@ async function mapGithubSources(items, work) {
101
101
  return out;
102
102
  }
103
103
  const readTogether = mapGithubSources;
104
+ /** A review refusal only the arc's own act clears (a PR's base, draft or state; its checkout's head, commits or push; a
105
+ * PR on no branch the arc declares): `kind` and `url` name it for the dispatcher, which tells it as a review wait instead
106
+ * of only logging it each round, keyed by its kinds and PRs alone so a changing detail (a count, a path) is not a new wait. */
107
+ const refused = (kind, url, message) => Object.assign(new Error(message), { code: 'review_refused', refusals: [{ kind, url }] });
108
+ /** One refusal naming every refused PR, in a fixed order whatever order the reads finished in. */
109
+ const refusalOf = (errors) => {
110
+ const sorted = [...errors].sort((a, b) => a.refusals[0].kind.localeCompare(b.refusals[0].kind) || a.refusals[0].url.localeCompare(b.refusals[0].url));
111
+ const refusals = sorted.flatMap((e) => e.refusals).filter((r, i, all) => all.findIndex((o) => o.kind === r.kind && o.url === r.url) === i);
112
+ return Object.assign(new Error(sorted.map((e) => e.message).join(' ')), { code: 'review_refused', refusals });
113
+ };
114
+ /** `readTogether` that runs `check` on every item and collects every refusal instead of the first to reject; any other
115
+ * failure is thrown as before. Answers the checks' results. */
116
+ async function refuseTogether(items, check) {
117
+ const refusals = [];
118
+ const out = await readTogether(items, async (item, index) => {
119
+ try { return await check(item, index); }
120
+ catch (error) { if (error?.code !== 'review_refused') throw error; refusals.push(error); return null; }
121
+ });
122
+ if (refusals.length) throw refusalOf(refusals);
123
+ return out;
124
+ }
104
125
  /** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
105
126
  * read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
106
127
  const actionReads = new AsyncLocalStorage();
@@ -181,7 +202,7 @@ const discoveryIdentity = async key => `${githubBase()}\0${createHash('sha256').
181
202
  const discoveryRead = async (key, ttl, read, options) => githubDiscovery(await discoveryIdentity(key), ttl, read, options);
182
203
  const readPullRequest = async (url) => discoverPullRequest(url, { fresh: true });
183
204
  // Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
184
- // still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
205
+ // still uses readPullRequest and the fresh source checks in inspectCompletionEvidence, which bypass this cache and reads its current head.
185
206
  export async function discoverPullRequest(url, { fresh = false } = {}) {
186
207
  return discoveryRead(`pr:${repositoryOf(url).toLowerCase()}:${numberOf(url)}`, 60_000, async () => pullRequest(repositoryOf(url),
187
208
  await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: fresh ? 'validation' : 'discovery' })),
@@ -408,17 +429,17 @@ export function declaredWorkBranches(db, id) {
408
429
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
409
430
  * 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
410
431
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
411
- export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, repositories = [] } = {}) {
412
- if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, repositories });
432
+ export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, refuseStacked = false, repositories = [] } = {}) {
433
+ if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, refuseStacked, repositories });
413
434
  // Every authorization entry acquires the broad advisory scope, not only
414
435
  // dispatch. An older receipt and a checkout now on main still have to see
415
436
  // implicit wt/<card> PRs. Discovery failures leave that scope unknown.
416
437
  const scope = new Set(repositories);
417
438
  await discoverReviewSources(board, id, { repositories, strict: true }, scope);
418
- return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, repositories: [...scope] });
439
+ return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, refuseStacked, repositories: [...scope] });
419
440
  }
420
441
 
421
- async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, repositories = [], strict = false } = {}, scope = null) {
442
+ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, refuseStacked = false, repositories = [], strict = false } = {}, scope = null) {
422
443
  const own = await repositoryWorkspace(board, id);
423
444
  const registered = registeredBranches(board, id).map(r => ({ ...r, repository: r.repository.toLowerCase() }));
424
445
  if (!own && !registered.length) return null;
@@ -441,6 +462,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
441
462
  const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
442
463
  .filter(Boolean).map(r => r.toLowerCase()));
443
464
  const answered = answeredChanges(board, id);
465
+ const stacked = [];
444
466
  const groups = await readTogether(repositoriesToRead, async repository => {
445
467
  const prs = [];
446
468
  const hasWorkBranches = repository === ownRepository || registered.some(r => r.repository === repository);
@@ -469,6 +491,10 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
469
491
  }
470
492
  // a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
471
493
  for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
494
+ // A ready PR on one of the arc's branches proposed to any branch but the default (stacked on another PR, or a wrong
495
+ // base) would leave the arc's one review set without a word: it is kept as stacked, and a review request refuses
496
+ // and tells it until it is retargeted to the default branch. A PR from the default branch itself is a release.
497
+ for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.defaultBranch && pr.headRefName !== pr.defaultBranch && pr.baseRefName !== pr.defaultBranch && mine(repository, pr.headRefName)) stacked.push(pr);
472
498
  // A manual review and merge during a dispatcher outage may predate any
473
499
  // board request. The source-review door already accepts merged heads; make
474
500
  // them discoverable so the ordinary independent verdict can be retained.
@@ -504,16 +530,22 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
504
530
  // Retain scope even when all matching PRs are excluded drafts/other bases,
505
531
  // or judged merged sources, so a fresh scan cannot inherit that exclusion.
506
532
  if (scope) for (const repository of reviewRepositories) scope.add(repository);
533
+ stacked.sort((a, b) => a.url.localeCompare(b.url));
534
+ // Advisory discovery can be up to five minutes old: the dispatcher tells a stacked refusal only once a fresh read
535
+ // (refuseStacked) confirms it.
536
+ if (stacked.length && (checkWorkspaces || refuseStacked)) throw refusalOf(stacked.map((pr) => refused('base', pr.url, `${pr.url} proposes ${pr.headRefName} to ${pr.baseRefName}, not ${pr.defaultBranch}: retarget it to ${pr.defaultBranch} so the arc's one review includes it.`)));
537
+ if (!prs.length && stacked.length) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url) };
507
538
  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` };
508
539
  // Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
509
540
  // doing checkout work: a wrong base cannot be repaired by revalidating the whole arc every round. Discovery
510
541
  // 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.
542
+ // inspectCompletionEvidence repeats both, in the same order, against fresh metadata and the pushed head. Each
543
+ // collects every PR's refusal, so the same state always refuses with the same set.
512
544
  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));
545
+ await refuseTogether(prs, pr => requireReviewPullRequestProperties(pr.url, pr.head, pr.observed));
546
+ await refuseTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
515
547
  }
516
- return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort() };
548
+ return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url) };
517
549
  }
518
550
 
519
551
  /** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
@@ -582,8 +614,7 @@ async function knownRepositories({ strict = false } = {}) {
582
614
  * joins with a head committed before the verdict is not: the next run is the implementer's until it answers. After a
583
615
  * pass nothing is owed by the implementer, so any new head of a PR it judged is due (acceptance needs the verdict on
584
616
  * the current heads), and a PR that verdict's request did not name is due at whatever head it has: one that was a draft
585
- * or stacked on another PR's branch when the request was made, and became the arc's once that PR merged (t_bbbd2086's
586
- * #1355, committed 2 h before the pass that named only #1353). */
617
+ * when the request was made. */
587
618
  export async function reviewDue(board, id, prs) {
588
619
  if (prs.length && answeredChanges(board, id)) return true;
589
620
  const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
@@ -719,8 +750,8 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
719
750
  if (!own && !mergedOnly) {
720
751
  const registered = registeredBranches(board, id);
721
752
  const declared = (pr) => namedWorkBranch(id, pr.headRefName) || registered.some((r) => r.branch === pr.headRefName && r.repository.toLowerCase() === repositoryOf(pr.url).toLowerCase());
722
- const stray = read.find((pr) => pr.state !== 'MERGED' && !declared(pr));
723
- if (stray) throw new Error(`An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName} (with every other source the arc changes)`);
753
+ const strays = read.filter((pr) => pr.state !== 'MERGED' && !declared(pr));
754
+ if (strays.length) throw refusalOf(strays.map((stray) => refused('undeclared', stray.url, `An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName} (with every other source the arc changes)`)));
724
755
  }
725
756
  // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
726
757
  // always names every opened PR it changed, independent of deployment.
@@ -732,7 +763,10 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
732
763
  : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
733
764
  if (stage === 'request_review' && named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
734
765
  if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
735
- const prs = await mapGithubSources(urls, (url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage, read[index]));
766
+ // Every PR's properties, then (for a review request) every PR's checkout at its pushed head, as the advisory pass
767
+ // checks them; each collects every refusal (refuseTogether).
768
+ const prs = await refuseTogether(urls, (url, index) => requireReviewPullRequestProperties(url, stage === 'request_review' ? named[index] : null, read[index]));
769
+ if (stage === 'request_review') await refuseTogether(prs, (source, index) => requireReviewWorkspaceHead(board, id, own, read[index], source.commit, { pushed: true }));
736
770
  if (stage === 'request_review') {
737
771
  // A review is asked for only while some PR has a head no verdict has judged.
738
772
  if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('Every PR head has a verdict, carries the source tree its verdict judged, or predates it: push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting review.');
@@ -748,6 +782,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
748
782
  } catch (error) {
749
783
  Object.assign(state, { required: true, ready: false, reason: String(error?.message ?? error) });
750
784
  if (error?.code) Object.assign(state, { code: error.code, retryable: error.code === 'GITHUB_BUDGET' });
785
+ if (error?.refusals) state.refusals = error.refusals;
751
786
  if (['GITHUB_BUDGET', 'github_rate_limited', 'github_metadata_capacity', 'github_read_admission_full'].includes(error?.code))
752
787
  Object.assign(state, { deferred: true, deferred_code: error.code, retry_at: error.retry_at ?? null });
753
788
  }
@@ -763,12 +798,12 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
763
798
  const path = observed.state === 'MERGED' || (own && !own.here) ? null : !own ? await branchCheckout(board, id, repository, observed.headRefName) : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
764
799
  if (!path) return;
765
800
  await run('git', ['check-ref-format', '--branch', branch], path);
766
- if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
801
+ if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw refused('head', url, `The head named for ${url} must be its worktree head.`);
767
802
  const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
768
- if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
803
+ if (uncommitted.length) throw refused('uncommitted', url, `Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
769
804
  if (pushed) {
770
805
  const remote = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
771
- if (remote !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
806
+ if (remote !== head) throw refused('unpushed', url, `Push the exact head of ${url} before requesting review.`);
772
807
  }
773
808
  }
774
809
 
@@ -779,26 +814,18 @@ function requireReviewPullRequestProperties(url, supplied, observed) {
779
814
  // A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
780
815
  // verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
781
816
  const merged = observed.state === 'MERGED';
782
- if ((observed.state !== 'OPEN' && !merged) || observed.isCrossRepository) throw new Error(`Source review needs an open or merged PR from its repository (${url}).`);
783
- if (!merged && observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
817
+ if ((observed.state !== 'OPEN' && !merged) || observed.isCrossRepository) throw refused('state', url, `Source review needs an open or merged PR from its repository (${url}).`);
818
+ if (!merged && observed.isDraft) throw refused('draft', url, `${url} is a draft; its review starts when it is marked ready for review.`);
784
819
  const head = supplied ?? observed.headRefOid;
785
820
  if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
786
821
  if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
787
822
  const branch = observed.headRefName;
788
823
  const mainBranch = observed.defaultBranch ?? 'main';
789
824
  // A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
790
- if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
825
+ if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw refused('base', url, `${url} must propose its worktree branch to the repository main branch.`);
791
826
  return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
792
827
  }
793
828
 
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
-
802
829
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
803
830
  /** Read the tracked workflow revision the next serve tick will load. */
804
831
  export async function workflowRevision(root) {
package/board/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
2
2
  // format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
3
3
  // output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`set`, `adopt`, `detach`, `close`,
4
- // `serve`, `--at`) are added beside them. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`,
5
- // which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`; the board is
4
+ // `serve`, `--at`) are added beside them. The npm Node front door dispatches operator verbs here.
5
+ // The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`; the board is
6
6
  // `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
7
7
  import { machineCall, closeDoors, onLocalReopen } from './doors.mjs';
8
8
  import { createHash } from 'node:crypto';
@@ -13,18 +13,20 @@ import { current, isCallersCall, ownCall, within } from './call.mjs';
13
13
  import { cardState, holdDescription } from './card-state.mjs';
14
14
  import { detachSession } from './custody.mjs';
15
15
  import { taskStatusOf } from './published.mjs';
16
- import { executionOf } from './session-work.mjs';
16
+ import { executionOf, readingContext } from './session-work.mjs';
17
17
  import { attachmentRecord, readAttachment } from './attachment-read.mjs';
18
18
  import { receiveAttachment } from './attachment-write.mjs';
19
19
  import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
20
20
  import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
21
21
  import { openOwnerDoor } from './owner-door.mjs';
22
+ import { inBoardGeneration, generationWork, admitGeneration, grantGenerationWriter, parkGeneration, stopGeneration, tellOwner } from './generation.mjs';
22
23
  import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
23
24
  import { notRuntime, ownHome } from './owner-files.mjs';
24
25
  import { execFile, spawnSync } from 'node:child_process';
26
+ import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
25
27
  import { holdLock } from './lock-holder.mjs';
26
28
  import { takeEpoch } from './fence.mjs';
27
- import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
29
+ import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch, watchOwnerHealth } from './owner-health.mjs';
28
30
  import { boardTool } from './tools.mjs';
29
31
  import { arcSources, assertSourceReceipt } from './ci.mjs';
30
32
  import { noteStep, recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
@@ -41,7 +43,10 @@ import { recordCardAgentChanges } from './agent-declarations.mjs';
41
43
 
42
44
  // A mailbox read through the mailbox's own command, awaited (never a wait that holds the event loop). Gap A31: a
43
45
  // helper process until the daemon owns mail (order 3 PR 4).
44
- const mailRead = (verb) => new Promise((resolve) => execFile(supercodeBin(), ['message', verb, '--json'], { encoding: 'utf8', env: commandEnv(), timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }, (_, stdout) => resolve(stdout ?? '')));
46
+ const mailRead = (verb) => new Promise((resolve) => {
47
+ const env = commandEnv(), selected = supercodeInvocation(supercodeBin(), ['message', verb, '--json'], env);
48
+ execFile(selected.program, selected.arguments, { encoding: 'utf8', env, timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }, (_, stdout) => resolve(stdout ?? ''));
49
+ });
45
50
 
46
51
  /** The messages on this machine the mailbox still has to deliver (`supercode message waiting`), by message id. */
47
52
  async function mailWaiting() {
@@ -70,7 +75,7 @@ async function boardPublishing(root) {
70
75
  const board = listed?.boards?.find((b) => b.root === here);
71
76
  return board ? { ...board, refused: listed.refused ?? null } : null;
72
77
  }
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';
78
+ import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
74
79
  import { watchBoard } from './wake.mjs';
75
80
  import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
76
81
  import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
@@ -78,7 +83,7 @@ import { FleetListenerRouter, fleetListenerStatus } from './fleet-listeners.mjs'
78
83
  import { enrichFleetEvent, observeRoleFleet } from './fleet-observer.mjs';
79
84
  import { fileFleetNotice } from './dispatch.mjs';
80
85
  import { streamBoardPublications } from './event-stream.mjs';
81
- import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
86
+ import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor, watchWorkflows } from './workflow.mjs';
82
87
  import { evaluate } from './expr.mjs';
83
88
  import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
84
89
 
@@ -99,6 +104,7 @@ A card is named by its id (t_4406c88a) or by its number, keyed or bare (VO-42, v
99
104
  gave it when it was made, read with the board's key, the first letters of the board's name.
100
105
 
101
106
  The board's verbs, with their flags:
107
+ declaration [--json] read the serving board declaration
102
108
  init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
103
109
  migrate --backing ztrack --document <arcs.md> --status-map <json>
104
110
  boards list|create|rm|switch|show|rename|set-default-workdir|export|import
@@ -144,6 +150,7 @@ And:
144
150
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
145
151
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
146
152
  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
153
+ handover --entry <orchestrator.mjs> --supercode <native> prepare a board generation, retain its predecessor
147
154
  answer <id> <option> choose an option (its number or label) of the question the card's session shows on its pane: the
148
155
  card's manager, its creator or a signed-in person; recorded on the card as question_answered with who answered
149
156
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
@@ -334,7 +341,7 @@ const ts = (t) => {
334
341
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
335
342
  };
336
343
  // a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
337
- const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.readable_id).padEnd(18)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
344
+ const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.readable_id).padEnd(18)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}${t.execution?.reason ? ` — ${t.execution.reason}` : ''}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
338
345
  // `key` is the card's board's (store.mjs boardKey); `ref` the card as a person names it (`VO-856`)
339
346
  const stateOptions = root => ({ nativeHolds: Boolean(workflowFor(root).workflow.flags?.paused) });
340
347
  const cardDict = (raw, key, options) => { const t = cardState(raw, options); return ({
@@ -427,6 +434,12 @@ export async function runVerb(argv) {
427
434
  // C4: one task store per Org. A named board without a store of its own is a saved view over it: its tag selects
428
435
  // the cards it shows, and a card filed through it carries that tag.
429
436
  const named = boardOf(root, args);
437
+ if (verb === 'declaration') {
438
+ const slug = isView(root, named) ? DEFAULT_BOARD : named, backing = backingOf(root, slug);
439
+ const declared = backing === 'ztrack' ? JSON.parse(readFileSync(backingPaths(root, slug).ztrack, 'utf8')) : null;
440
+ say(args, JSON.stringify({ board: slug, backing, declared }), { board: slug, backing, declared });
441
+ return 0;
442
+ }
430
443
  const viewTag = isView(root, named) ? named : null;
431
444
  const slug = viewTag ? DEFAULT_BOARD : named;
432
445
  const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
@@ -640,7 +653,7 @@ export async function runVerb(argv) {
640
653
  const card = withBoard((b) => {
641
654
  const got = engine.showCard(b.db, id, stateOptions(root));
642
655
  got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
643
- got.task.execution = executionOf(engine.currentRun(b.db, id));
656
+ got.task.execution = executionOf(engine.currentRun(b.db, id), readingContext(b.db, got.task.id, got.task.current_run_id));
644
657
  got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
645
658
  got.diagnostics = engine.diagnose(b.db, got.task, { ...stateOptions(root), failureLimit: settings.failureLimit, launchWindowSeconds: settings.launchWindow });
646
659
  got.stage = statusOf(b.db, got.task);
@@ -666,6 +679,8 @@ export async function runVerb(argv) {
666
679
  if (t.machine) out.push(` machine: ${t.machine}`);
667
680
  if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
668
681
  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)`);
682
+ // what the card's session is doing as the board last read it, beside the lane (ADR 0024)
683
+ else if (['running', 'review'].includes(t.status)) out.push(` session: ${card.task.execution?.state ?? 'unknown'}${card.task.execution?.reason ? ` — ${card.task.execution.reason}` : ''}`);
669
684
  // the question the card's session shows on its pane, while it shows (VO-3395), with the door that answers it
670
685
  const asked = card.runs.find((r) => r.id === t.current_run_id)?.metadata?.supercode?.question;
671
686
  if (asked?.options?.length) {
@@ -1757,6 +1772,14 @@ function setDispatching(root, slug, on, reason) {
1757
1772
  */
1758
1773
  async function answerVerbs(root, log) {
1759
1774
  const home = boardHome(root);
1775
+ if (inBoardGeneration) {
1776
+ ownHome(home);
1777
+ const epoch = takeEpoch(root, () => listBoards(root).map(slug => openBoard(root, slug)));
1778
+ holdOwnerHealth(listBoards(root).map(slug => [slug, dispatchingOf(root, slug)]), null);
1779
+ log(`generation writer epoch ${epoch}; the stable owner holds the public door`);
1780
+ admitGeneration();
1781
+ return () => {};
1782
+ }
1760
1783
  const lockPath = join(home, 'board.lock');
1761
1784
  // The lock is held by a process of its own (lock-holder.mjs), so nothing this process reads can drop it; a verb
1762
1785
  // naming the lock or socket as its input is refused (owner-files.mjs)
@@ -1784,6 +1807,11 @@ async function answerVerbs(root, log) {
1784
1807
  * Show/list/comment/create also retain receipts below the slow-log threshold;
1785
1808
  * absence from a slow-only log cannot prove those calls met the time bound. */
1786
1809
  export async function answerOne(root, line, { readOnly = false } = {}) {
1810
+ if (inBoardGeneration && !readOnly) return generationWork(() => answerOneAdmitted(root, line));
1811
+ return answerOneAdmitted(root, line, { readOnly });
1812
+ }
1813
+
1814
+ async function answerOneAdmitted(root, line, { readOnly = false } = {}) {
1787
1815
  const scoped = typeof slowLog.withStepScope === 'function';
1788
1816
  // The owner's receipt includes asynchronous custody preparation too: it is part of the caller's wait.
1789
1817
  const began = performance.now(), before = slowLog.processCounters?.() ?? null;
@@ -1866,6 +1894,74 @@ async function answerRequest(root, line, naming, { readOnly = false } = {}) {
1866
1894
  }
1867
1895
  }
1868
1896
 
1897
+ // Prepared children import and read their declarations without writer authority. Activation and parking are ordered
1898
+ // on one control chain, so a late park acknowledgement can never park a generation after rollback resumed it.
1899
+ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--root', root]) {
1900
+ if (!inBoardGeneration) throw new Error('a board generation requires its stable owner IPC connection');
1901
+ // The direct parent has already resolved the machine identity. An unavailable daemon must not consume a second
1902
+ // fifteen-second lookup before this child can report preparation through its eight-second owner door.
1903
+ await knowLocalMachine(process.env.SUPERCODE_BOARD_GENERATION_MACHINE);
1904
+ // readHome owns an SDK transport startup and one orchestration.load, each with its existing 30-second bound.
1905
+ tellOwner({ type: 'startup-step', step: 'reading the home through its orchestration door', budget_ms: 65000 });
1906
+ await readHome(root);
1907
+ tellOwner({ type: 'startup-step', step: 'reading board storage without writer authority', budget_ms: 8000 });
1908
+ await within({ cwd: root, readOnly: true, out: () => {}, err: () => {} }, async () => {
1909
+ for (const slug of listBoards(root)) {
1910
+ let board;
1911
+ try { board = openBoard(root, slug); board.db.prepare('SELECT 1').get(); }
1912
+ catch (error) { if (error?.code !== READ_BY_OWNER) throw error; }
1913
+ finally { board?.close(); }
1914
+ }
1915
+ });
1916
+ let begin, started = false, controls = Promise.resolve();
1917
+ const activation = new Promise(done => { begin = done; });
1918
+ const unwatchHealth = watchOwnerHealth(value => tellOwner({ type: 'health', ...value }));
1919
+ const unwatchWorkflow = watchWorkflows((at, { workflow, params, profiles }) => tellOwner({ type: 'workflow', root: at, entry: { workflow, params, profiles } }));
1920
+ const receive = frame => {
1921
+ if (frame?.type === 'request') {
1922
+ answerOne(root, frame.line).catch(error => ({ code: 1, stdout: '', stderr: `${error.message}\n` }))
1923
+ .then(answer => tellOwner({ type: 'answer', id: frame.id, answer }));
1924
+ return;
1925
+ }
1926
+ if (!['activate', 'park'].includes(frame?.type)) return;
1927
+ controls = controls.then(async () => {
1928
+ if (frame.type === 'park') {
1929
+ stopDispatching();
1930
+ await parkGeneration(async () => { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); });
1931
+ retireHeldBoards();
1932
+ tellOwner({ type: 'parked' });
1933
+ } else {
1934
+ grantGenerationWriter();
1935
+ if (!started) {
1936
+ tellOwner({ type: 'startup-step', phase: 'serving', step: 'starting the native board dispatcher', budget_ms: 65000 });
1937
+ started = true; begin();
1938
+ }
1939
+ else {
1940
+ takeEpoch(root, () => listBoards(root).map(slug => openBoard(root, slug)));
1941
+ await registerGenerationOwner();
1942
+ resumeDispatching();
1943
+ admitGeneration();
1944
+ // The loop's next admitted round supplies the serving receipt, also for a resumed predecessor.
1945
+ generationServingDue = true;
1946
+ wakeGeneration();
1947
+ }
1948
+ }
1949
+ }).catch(error => { process.stderr.write(`board generation control: ${error.message}\n`); process.exit(1); });
1950
+ };
1951
+ const disconnected = () => { stopDispatching(); process.exit(1); };
1952
+ process.on('message', receive); process.once('disconnect', disconnected);
1953
+ // A transition cannot restore database files over committed card writes. Refuse a storage-code change
1954
+ // until a compatible migration has its own protocol; retained generations must use the same storage contract.
1955
+ const storage = createHash('sha256');
1956
+ for (const file of ['store.mjs', 'files.mjs', 'ztrack.mjs', 'published.mjs']) storage.update(file).update(readFileSync(new URL(file, import.meta.url)));
1957
+ tellOwner({ type: 'prepared', storage: storage.digest('hex') });
1958
+ try { await activation; return await runVerb(argv); }
1959
+ finally { process.off('message', receive); process.off('disconnect', disconnected); unwatchHealth(); unwatchWorkflow(); }
1960
+ }
1961
+
1962
+ let generationServingDue = true;
1963
+ let wakeGeneration = () => {};
1964
+ let registerGenerationOwner = async () => {};
1869
1965
  async function serve(root, args = {}) {
1870
1966
  // Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
1871
1967
  const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
@@ -1876,7 +1972,7 @@ async function serve(root, args = {}) {
1876
1972
  let events;
1877
1973
  let fleetListeners = null, reloadListeners = () => {}, listenerRevision = 0, listenerBootstrapped = false;
1878
1974
  // a signal ends the round in progress with nothing new started (dispatch.mjs stopDispatching) and no round after it
1879
- const stop = () => { stopped = true; stopDispatching(); events?.close(); fleetListeners?.suspend(); };
1975
+ const stop = () => { stopped = true; stopDispatching(); if (inBoardGeneration) stopGeneration(); events?.close(); fleetListeners?.suspend(); };
1880
1976
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, stop);
1881
1977
  // what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
1882
1978
  const kept = join(root, 'logs', 'workflow-serve.log');
@@ -1977,6 +2073,7 @@ async function serve(root, args = {}) {
1977
2073
  if (watched !== undefined && board) heldDocumentsWatched(board, id ?? key, watched);
1978
2074
  else if (uncertain && !board) heldDocumentsChanged();
1979
2075
  } });
2076
+ wakeGeneration = () => events?.wake('generation-activated');
1980
2077
  // a machine's panes changing in its record (a session came up under its pane, a pane or its session ended) is what a
1981
2078
  // round binds a launch, verifies an adoption and reads a session's end from: the next round is due then
1982
2079
  const unfollowPanes = onPanesChanged(() => events?.wake('machine-record'));
@@ -1992,13 +2089,14 @@ async function serve(root, args = {}) {
1992
2089
  // panes the board launched (a close refused as "not started by this command").
1993
2090
  const registerOwner = async () => {
1994
2091
  let named = null, refusal = null;
1995
- try { named = await machineCall(null, 'harness.v1.boards.register', { root, claim: { pid: process.pid, ppid: process.ppid } }, { timeoutMs: 30_000 }); } catch (error) { refusal = error.message; }
2092
+ try { named = await machineCall(null, 'harness.v1.boards.register', { root, claim: { pid: process.pid, ppid: process.ppid } }, { timeoutMs: inBoardGeneration ? 2000 : 30_000 }); } catch (error) { refusal = error.message; }
1996
2093
  const refused = named?.refused ?? null;
1997
2094
  log(refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}`
1998
2095
  : refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
1999
2096
  : named?.published === false ? `board ownership: registered locally; this machine has no team connector to publish events`
2000
2097
  : `board events: this home is published by the machine's connector`);
2001
2098
  };
2099
+ registerGenerationOwner = registerOwner;
2002
2100
  await registerOwner();
2003
2101
  onLocalReopen(registerOwner);
2004
2102
  let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
@@ -2060,6 +2158,7 @@ async function serve(root, args = {}) {
2060
2158
  })().finally(() => { listenerReloading = null; });
2061
2159
  return listenerReloading;
2062
2160
  };
2161
+ if (inBoardGeneration && generationServingDue) tellOwner({ type: 'startup-step', phase: 'serving', step: 'reading the listener configuration through its orchestration door', budget_ms: 65000 });
2063
2162
  await reloadListeners();
2064
2163
  // Retry a failed home decode without waiting for a dispatch sweep. Reading the held stream also enforces its
2065
2164
  // heartbeat deadline and reconnects after local caller loss, even when every board's dispatching is off.
@@ -2082,11 +2181,12 @@ async function serve(root, args = {}) {
2082
2181
  // A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
2083
2182
  // starts the new one. Nothing waits for an installation; a second round, old or new, is harmless.
2084
2183
  const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
2085
- if (replaced) { log(`orchestrator ${replaced} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
2184
+ if (replaced && !inBoardGeneration) { log(`orchestrator ${replaced} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
2086
2185
  pausedSaid = Boolean(paused(root)) && pausedSaid;
2087
2186
  let interval = 60;
2088
2187
  let nextMailAt = Infinity;
2089
2188
  const began = Date.now();
2189
+ if (inBoardGeneration && generationServingDue) tellOwner({ type: 'startup-step', phase: 'serving', step: 'completing the first native board round', budget_ms: 65000 });
2090
2190
  if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
2091
2191
  wakeReason = null;
2092
2192
  const loopStep = async (name, fn) => { const at = performance.now(); try { return await fn(); } finally { noteStep(`owner loop: ${name}`, performance.now() - at); } };
@@ -2101,6 +2201,10 @@ async function serve(root, args = {}) {
2101
2201
  // Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
2102
2202
  const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
2103
2203
  const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true })) : [];
2204
+ if (inBoardGeneration && generationServingDue && reports.every(report => !report.error)) {
2205
+ generationServingDue = false;
2206
+ tellOwner({ type: 'serving', pid: process.pid, boards: listBoards(root) });
2207
+ }
2104
2208
  // with dispatching off on every board the owner only answers verbs: no round, no delivery, nothing told
2105
2209
  if (dispatching.length) {
2106
2210
  round += 1;
@@ -2144,7 +2248,7 @@ async function serve(root, args = {}) {
2144
2248
  if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
2145
2249
  moved = newer();
2146
2250
  }
2147
- if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
2251
+ if (moved && !inBoardGeneration) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
2148
2252
  }
2149
2253
  } finally {
2150
2254
  fleetListeners.suspend(); clearInterval(listenerTimer); unfollowRecords(); unfollowPanes();
package/board/client.mjs CHANGED
@@ -30,6 +30,15 @@ export async function runWorkflow(argv) {
30
30
  }
31
31
  const help = !verb || verb === 'help' || args.help;
32
32
  const root = help ? null : homeOf(args);
33
+ if (!help && verb === 'handover') return sendToOwner(root, argv, args);
34
+ if (!help && ['serve', 'daemon'].includes(verb)) {
35
+ const { inBoardGeneration } = await import('./generation.mjs');
36
+ if (inBoardGeneration) {
37
+ process.send({ type: 'startup-step', step: 'loading the board dispatcher', budget_ms: 8000 });
38
+ return (await import('./cli.mjs')).serveBoardGeneration(root, args, argv);
39
+ }
40
+ return (await import('./generation-owner.mjs')).serveBoardOwner(root, args);
41
+ }
33
42
  if (help || BOARD_OPERATION_VERBS.has(verb)) {
34
43
  // The board has one writer. An operation that writes the boards (exclusive, below) takes the home's lock itself
35
44
  // and so is that writer, refusing while an owner holds it. The rest only read, or are the owner itself (serve), and