@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/README.md +71 -9
- package/activation.mjs +47 -11
- package/adapters/chat-sdk.mjs +82 -15
- package/adapters/rh2.mjs +11 -7
- package/agents.mjs +1 -5
- package/bin/agent-channel.mjs +33 -177
- package/bin/connection-runner.mjs +3 -17
- package/bin/orchestrator.mjs +12 -7
- package/bin/runtime.mjs +74 -51
- package/board/call.mjs +2 -1
- package/board/ci.mjs +55 -28
- package/board/cli.mjs +117 -13
- package/board/client.mjs +9 -0
- package/board/dispatch.mjs +45 -15
- package/board/engine.mjs +2 -2
- package/board/fence.mjs +2 -0
- package/board/generation-owner.mjs +293 -0
- package/board/generation.mjs +49 -0
- package/board/listing.mjs +17 -5
- package/board/owner-door-worker.mjs +22 -7
- package/board/owner-door.mjs +2 -2
- package/board/owner-files.mjs +1 -1
- package/board/published.mjs +12 -7
- package/board/reply.mjs +2 -2
- package/board/session-work.mjs +85 -15
- package/connection.mjs +14 -20
- package/cron.mjs +57 -4
- package/daemon-control.d.ts +39 -0
- package/daemon-control.mjs +275 -0
- package/hermes-locks.mjs +13 -3
- package/index.d.ts +9 -4
- package/installed-apps.mjs +35 -14
- package/kanban-worker.mjs +4 -4
- package/lease.mjs +1 -1
- package/loop.mjs +198 -47
- package/mcp/mount.mjs +6 -5
- package/orchestration.mjs +11 -17
- package/package.json +9 -6
- package/reducer.mjs +31 -6
- package/source-lock.mjs +15 -24
- package/webhook.mjs +65 -12
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
|
|
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
|
-
//
|
|
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
|
-
|
|
514
|
-
await
|
|
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
|
-
*
|
|
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
|
|
723
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
783
|
-
if (!merged && observed.isDraft) throw
|
|
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
|
|
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
|
|
5
|
-
//
|
|
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) =>
|
|
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
|