@volter/supercode-orchestrator 0.5.73 → 0.5.74

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
@@ -1,79 +1,72 @@
1
1
  // Review candidates are observed before a board mutation. No network or Git
2
2
  // process runs inside the optimistic transaction. Deployment is not a completion gate.
3
- import { spawnSync } from 'node:child_process';
3
+ import { execFile } from 'node:child_process';
4
4
  import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
5
5
  import { dirname, join, relative } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace } from './workspace.mjs';
8
8
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
9
9
 
10
- // GitHub is read through its REST API with Node's own fetch, not the gh binary: Node takes a World's trust bundle and
11
- // injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a review World's GitHub twin answers it, where gh (Go) trusts only
12
- // the system keychain. The read runs in a child Node so the callers stay synchronous.
13
- const GITHUB_READ = `
14
- const [path, all] = process.argv.slice(1);
15
- const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
16
- const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
17
- const headers = { accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) };
18
- let url = base + path, out = [];
19
- for (let page = 0; url && page < 10; page += 1) {
20
- const r = await fetch(url, { headers });
21
- if (!r.ok) { process.stderr.write('GitHub ' + r.status + ' for ' + path + ': ' + (await r.text()).slice(0, 300)); process.exit(1); }
22
- const body = await r.json();
23
- if (all !== '1') { process.stdout.write(JSON.stringify(body)); process.exit(0); }
24
- out = out.concat(body);
25
- url = /<([^>]+)>;\\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
26
- }
27
- process.stdout.write(JSON.stringify(out));`;
10
+ // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
11
+ // waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
12
+ // review World's GitHub twin answers it, where gh (Go) trusts only the system keychain.
13
+ const githubBase = () => (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '');
14
+ const githubHeaders = (token, extra = {}) => ({ accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra });
28
15
  let loginToken = null;
16
+ async function githubToken() {
17
+ const fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
18
+ if (!fixed && !loginToken) try { loginToken = (await run('gh', ['auth', 'token'])) || null; } catch { loginToken = null; }
19
+ return fixed ?? loginToken;
20
+ }
29
21
  /** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
30
22
  * read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
31
- function github(path, all = false) {
32
- const fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
33
- const read = () => {
34
- if (!fixed && !loginToken) try { loginToken = run('gh', ['auth', 'token']) || null; } catch { loginToken = null; }
35
- const token = fixed ?? loginToken;
36
- const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
37
- return spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_READ, path, all ? '1' : '0'], { env, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
23
+ async function github(path, all = false) {
24
+ const read = async () => {
25
+ const token = await githubToken();
26
+ let url = githubBase() + path, out = [];
27
+ for (let page = 0; url && page < 10; page += 1) {
28
+ const r = await fetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) });
29
+ if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
30
+ const body = await r.json();
31
+ if (!all) return { body };
32
+ out = out.concat(body);
33
+ url = /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
34
+ }
35
+ return { body: out };
38
36
  };
39
37
  const started = performance.now();
40
- let result = read();
38
+ let result;
39
+ try { result = await read(); } catch (error) { result = { error: String(error.message) }; }
40
+ if (result.status === 401 && !(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) && loginToken) { loginToken = null; try { result = await read(); } catch (error) { result = { error: String(error.message) }; } }
41
41
  noteStep('github read', performance.now() - started);
42
- if (result.status !== 0 && !fixed && loginToken && /^GitHub 401 /.test(result.stderr ?? '')) { loginToken = null; result = read(); }
43
- if (result.status !== 0) throw new Error(`GitHub: ${(result.stderr || result.error?.message || 'read failed').trim().slice(-500)}`);
44
- return JSON.parse(result.stdout);
42
+ if (result.error) throw new Error(`GitHub: ${result.error.trim().slice(-500)}`);
43
+ return result.body;
45
44
  }
46
45
  const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
47
46
  const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
48
47
  /** A PR as the board reads it (gh's field names). */
49
48
  const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
50
49
  headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
51
- const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
50
+ const readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
52
51
 
53
- const GITHUB_MERGE = `
54
- const [path, sha] = process.argv.slice(1);
55
- const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
56
- const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
57
- const r = await fetch(base + path, { method: 'PUT', headers: { accept: 'application/vnd.github+json', 'content-type': 'application/json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) }, body: JSON.stringify({ sha, merge_method: 'merge' }) });
58
- const text = await r.text();
59
- process.stdout.write(JSON.stringify({ status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() }));`;
60
52
  /**
61
53
  * Merge a reviewed PR at the head its review judged, and only there: GitHub refuses the merge when the PR's head is no
62
54
  * longer that commit (409), so a head pushed after the verdict is never merged unreviewed. Answers
63
55
  * `{ merged, sha }` (already merged answers its merge commit), `{ moved, head }` when the head is not the reviewed one,
64
56
  * or throws on anything GitHub did not decide (the caller tries again).
65
57
  */
66
- export function mergeReviewedPullRequest(url, reviewed) {
67
- const pr = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`);
58
+ export async function mergeReviewedPullRequest(url, reviewed) {
59
+ const pr = await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`);
68
60
  if (pr.merged_at) return { merged: true, sha: pr.merge_commit_sha, already: true };
69
61
  if (pr.head?.sha !== reviewed) return { moved: true, head: pr.head?.sha ?? null };
70
62
  if (String(pr.state).toLowerCase() !== 'open') return { closed: true };
71
- const fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
72
- if (!fixed && !loginToken) try { loginToken = run('gh', ['auth', 'token']) || null; } catch { loginToken = null; }
73
- const token = fixed ?? loginToken;
74
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_MERGE, `/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, reviewed], { env: token ? { ...process.env, GH_TOKEN: token } : process.env, encoding: 'utf8', timeout: 60_000 });
75
- if (result.status !== 0) throw new Error(`GitHub merge: ${(result.stderr || result.error?.message || 'failed').trim().slice(-300)}`);
76
- const answer = JSON.parse(result.stdout);
63
+ const token = await githubToken();
64
+ let answer;
65
+ try {
66
+ const r = await fetch(`${githubBase()}/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, { method: 'PUT', headers: githubHeaders(token, { 'content-type': 'application/json' }), body: JSON.stringify({ sha: reviewed, merge_method: 'merge' }), signal: AbortSignal.timeout(60_000) });
67
+ const text = await r.text();
68
+ answer = { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
69
+ } catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
77
70
  if (answer.status === 200 && answer.body?.merged) return { merged: true, sha: answer.body.sha };
78
71
  if (answer.status === 409) return { moved: true, head: null, message: answer.body?.message ?? null };
79
72
  throw new Error(`GitHub merge ${answer.status}: ${answer.body?.message ?? 'not merged'}`);
@@ -81,15 +74,17 @@ export function mergeReviewedPullRequest(url, reviewed) {
81
74
 
82
75
  const observations = new Map();
83
76
  const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
84
- const run = (program, args, cwd) => {
77
+ // A git (or gh) command, awaited: never a wait that holds the event loop.
78
+ const run = async (program, args, cwd) => {
85
79
  if (cwd) {
86
80
  let directory = false;
87
81
  try { directory = statSync(cwd).isDirectory(); } catch { /* reported below */ }
88
82
  if (!directory) throw new Error(`Repository checkout ${cwd} is unavailable; resolve the card's declared workspace before verifying its source.`);
89
83
  }
90
- const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
91
- if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
92
- return result.stdout.trim();
84
+ return new Promise((resolve, reject) => execFile(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 }, (error, stdout, stderr) => {
85
+ if (error) reject(new Error(`${program}: ${(stderr || error.message || 'command failed').trim().slice(-500)}`));
86
+ else resolve(String(stdout).trim());
87
+ }));
93
88
  };
94
89
 
95
90
  export function completionEvidence(board, id) {
@@ -102,8 +97,8 @@ export function completionPhase(board, id, entry, verb, payload, roles = []) {
102
97
  return verb === 'complete' ? 'acceptance' : 'completion';
103
98
  }
104
99
 
105
- export function observeCompletionEvidence(board, id, entry, payload = {}, phase = 'completion') {
106
- const state = inspectCompletionEvidence(board, id, entry, payload, phase);
100
+ export async function observeCompletionEvidence(board, id, entry, payload = {}, phase = 'completion') {
101
+ const state = await inspectCompletionEvidence(board, id, entry, payload, phase);
107
102
  observations.set(key(board, id), state);
108
103
  return state;
109
104
  }
@@ -129,26 +124,26 @@ export function reviewCandidate(board, id) {
129
124
  /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
130
125
  export const candidatePullRequests = (ci) => ci?.prs ?? (ci?.pr ? [{ repository: ci.repository, branch: ci.branch, commit: ci.commit, source_sha: ci.source_sha ?? ci.commit, pr: ci.pr }] : []);
131
126
  // The repository a checkout declares (remote.origin.url), not where a URL rewrite sends its transport.
132
- const originOf = (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['config', '--get', 'remote.origin.url'], path))?.[1] ?? null;
127
+ const originOf = async (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(await run('git', ['config', '--get', 'remote.origin.url'], path))?.[1] ?? null;
133
128
 
134
129
  /** The card's checkout as its machine answers for it (path, repository, branch), and whether it is on this machine:
135
130
  * git runs only in a checkout here, and another machine's is known by what its door said. */
136
- function repositoryWorkspace(board, id) {
131
+ async function repositoryWorkspace(board, id) {
137
132
  const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
138
133
  const parent = task?.subtask_of && board.db.prepare('SELECT t.*,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(task.subtask_of);
139
134
  const workspace = task?.workspace_kind === 'scratch' ? parent : task;
140
135
  if (!workspace || workspace.workspace_kind === 'scratch') return null;
141
136
  const here = isLocalWorkspace(workspace);
142
- if (workspace.workspace_anchor) return { ...resolveWorkspace(workspace, false), here };
137
+ if (workspace.workspace_anchor) return { ...(await resolveWorkspace(workspace, false)), here };
143
138
  return workspace.workspace_path ? { path: workspace.workspace_path, here } : null;
144
139
  }
145
140
 
146
141
  const pullRequestLists = new Map();
147
142
 
148
- function branchCheckout(board, id, repository, branch) {
143
+ async function branchCheckout(board, id, repository, branch) {
149
144
  const task = board.db.prepare('SELECT c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
150
145
  if (!isLocalWorkspace(task ?? {})) return null;
151
- try { return checkoutOf(task?.machine, repository, branch)?.path ?? null; } catch { return null; }
146
+ try { return (await checkoutOf(task?.machine, repository, branch))?.path ?? null; } catch { return null; }
152
147
  }
153
148
 
154
149
  /** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
@@ -184,24 +179,24 @@ export function declaredWorkBranches(db, id) {
184
179
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
185
180
  * 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
186
181
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
187
- export function openReviewPullRequests(board, id) {
188
- const own = repositoryWorkspace(board, id);
182
+ export async function openReviewPullRequests(board, id) {
183
+ const own = await repositoryWorkspace(board, id);
189
184
  if (!own) return null;
190
185
  const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
191
- const ownRepository = own.repository ?? (own.here ? originOf(own.path) : null);
186
+ const ownRepository = own.repository ?? (own.here ? await originOf(own.path) : null);
192
187
  if (!ownRepository) return null;
193
- const current = task?.branch_name ? null : own.branch ?? (own.here ? run('git', ['branch', '--show-current'], own.path) : null);
188
+ const current = task?.branch_name ? null : own.branch ?? (own.here ? await run('git', ['branch', '--show-current'], own.path) : null);
194
189
  let branch = task?.branch_name ?? (current || null);
195
190
  const registered = registeredBranches(board, id);
196
191
  const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
197
192
  || registered.some((r) => r.repository === repository && r.branch === head);
198
193
  const prs = [];
199
- for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
194
+ for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories())])) {
200
195
  // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
201
196
  let list = pullRequestLists.get(repository);
202
197
  if (!list || Date.now() - list.at >= 60_000) {
203
198
  let items = [];
204
- try { items = github(`/repos/${repository}/pulls?state=open&per_page=100`, true).map((p) => pullRequest(repository, p)); }
199
+ try { items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true)).map((p) => pullRequest(repository, p)); }
205
200
  catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
206
201
  list = { at: Date.now(), items };
207
202
  if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
@@ -209,7 +204,7 @@ export function openReviewPullRequests(board, id) {
209
204
  }
210
205
  // The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
211
206
  if (repository === ownRepository && branch) {
212
- const defaultBranch = list.items.find((pr) => pr.defaultBranch)?.defaultBranch ?? (() => { try { return run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own.path).replace(/^origin\//, ''); } catch { return null; } })();
207
+ const defaultBranch = list.items.find((pr) => pr.defaultBranch)?.defaultBranch ?? (await (async () => { try { return (await run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own.path)).replace(/^origin\//, ''); } catch { return null; } })());
213
208
  if (branch === defaultBranch) branch = null;
214
209
  }
215
210
  // 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
@@ -222,32 +217,32 @@ export function openReviewPullRequests(board, id) {
222
217
  /** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
223
218
  * beyond its repository's default branch, locally or on origin, its checkout holds no uncommitted work, no PR was
224
219
  * ever opened from it, and it registered no other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
225
- export function noCodeChange(board, id) {
226
- const checkout = repositoryWorkspace(board, id);
220
+ export async function noCodeChange(board, id) {
221
+ const checkout = await repositoryWorkspace(board, id);
227
222
  if (!checkout?.here) return null;
228
223
  const own = checkout.path;
229
224
  try {
230
- const repository = originOf(own);
225
+ const repository = await originOf(own);
231
226
  const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
232
- const branch = task?.branch_name ?? (run('git', ['branch', '--show-current'], own) || null);
233
- const base = run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own);
227
+ const branch = task?.branch_name ?? (await run('git', ['branch', '--show-current'], own) || null);
228
+ const base = await run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own);
234
229
  if (!repository || !branch || !base || branch === base.replace(/^origin\//, '')) return null;
235
230
  if (registeredBranches(board, id).some((r) => r.repository !== repository || r.branch !== branch)) return null;
236
231
  // Uncommitted work in the checkout is code not yet committed: a dirty worktree is never no-code.
237
- if (run('git', ['status', '--porcelain', '--untracked-files=normal'], own)) return null;
232
+ if (await run('git', ['status', '--porcelain', '--untracked-files=normal'], own)) return null;
238
233
  // An unknown local or remote commit is code this checkout cannot see: run() throws and the arc waits.
239
- if (run('git', ['for-each-ref', '--format=%(refname)', `refs/heads/${branch}`], own)) run('git', ['merge-base', '--is-ancestor', `refs/heads/${branch}`, base], own);
240
- const remote = run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own).split(/\s+/)[0];
241
- if (remote) run('git', ['merge-base', '--is-ancestor', remote, base], own);
234
+ if (await run('git', ['for-each-ref', '--format=%(refname)', `refs/heads/${branch}`], own)) await run('git', ['merge-base', '--is-ancestor', `refs/heads/${branch}`, base], own);
235
+ const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
236
+ if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
242
237
  const owner = repository.split('/')[0];
243
- if (github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`).length) return null;
238
+ if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
244
239
  return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
245
240
  } catch { return null; }
246
241
  }
247
242
 
248
243
  /** The repositories this machine keeps a checkout of. */
249
- function knownRepositories() {
250
- try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
244
+ async function knownRepositories() {
245
+ try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; } catch { return []; }
251
246
  }
252
247
 
253
248
  /** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
@@ -255,7 +250,7 @@ function knownRepositories() {
255
250
  * committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). A PR that
256
251
  * joins with a head committed before the verdict is not: after changes are requested the next run is the
257
252
  * implementer's until it pushes. */
258
- export function reviewDue(board, id, prs) {
253
+ export async function reviewDue(board, id, prs) {
259
254
  const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
260
255
  if (!unjudged.length) return false;
261
256
  const voided = board.db.prepare("SELECT r.payload FROM task_events v JOIN task_events r ON r.id=json_extract(v.payload,'$.review_request') AND r.task_id=v.task_id WHERE v.task_id=? AND v.kind='review_voided' ORDER BY v.id DESC LIMIT 1").get(id);
@@ -268,16 +263,17 @@ export function reviewDue(board, id, prs) {
268
263
  if (!last) return true;
269
264
  const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
270
265
  if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
271
- return unjudged.some((p) => (commitTime(p) ?? 0) > Number(last.verdict_at));
266
+ for (const p of unjudged) if (((await commitTime(p)) ?? 0) > Number(last.verdict_at)) return true;
267
+ return false;
272
268
  }
273
269
 
274
270
  const commitTimes = new Map();
275
271
  /** When a PR's head was committed (seconds), read from GitHub once per head; null when it cannot be read. */
276
- function commitTime({ url, head }) {
272
+ async function commitTime({ url, head }) {
277
273
  if (commitTimes.has(head)) return commitTimes.get(head);
278
274
  let at = null;
279
275
  try {
280
- at = Math.floor(Date.parse(github(`/repos/${repositoryOf(url)}/commits/${head}`).commit.committer.date) / 1000) || null;
276
+ at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit.committer.date) / 1000) || null;
281
277
  } catch { /* unreadable: not counted as new */ }
282
278
  commitTimes.set(head, at);
283
279
  return at;
@@ -295,15 +291,15 @@ export function judgedHead(board, id, head) {
295
291
  OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
296
292
  }
297
293
 
298
- export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
294
+ export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
299
295
  const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
300
296
  try {
301
297
  if (stage === 'completion' || stage === 'acceptance') {
302
298
  const supplied = payload.commit ?? payload.metadata?.commit;
303
299
  if (supplied) {
304
- const path = repositoryWorkspace(board, id)?.path;
300
+ const path = (await repositoryWorkspace(board, id))?.path;
305
301
  if (!path) throw new Error('The named commit needs a resolved repository workspace.');
306
- Object.assign(state, { repository: originOf(path), commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
302
+ Object.assign(state, { repository: await originOf(path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
307
303
  }
308
304
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
309
305
  if (candidate) {
@@ -314,14 +310,14 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
314
310
  const wanted = candidatePullRequests(candidate);
315
311
  if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
316
312
  for (const w of wanted) {
317
- const pr = readPullRequest(w.pr.url);
313
+ const pr = await readPullRequest(w.pr.url);
318
314
  if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
319
315
  }
320
316
  Object.assign(state, { ...candidate, ready: true, reason: null });
321
317
  }
322
318
  return state;
323
319
  }
324
- const own = repositoryWorkspace(board, id);
320
+ const own = await repositoryWorkspace(board, id);
325
321
  const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
326
322
  const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
327
323
  if (!own) {
@@ -338,10 +334,10 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
338
334
  : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
339
335
  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.');
340
336
  if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
341
- const prs = urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
337
+ const prs = await Promise.all(urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage)));
342
338
  if (stage === 'request_review') {
343
339
  // A review is asked for only while some PR has a head no verdict has judged.
344
- if (!reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('The PRs the last verdict judged have no new head; push a new head before requesting review.');
340
+ if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('The PRs the last verdict judged have no new head; push a new head before requesting review.');
345
341
  } else if (asked) {
346
342
  const requested = candidatePullRequests(asked);
347
343
  if (requested.length !== prs.length || requested.some((r) => !prs.some((p) => p.pr.url === r.pr.url && p.commit === r.commit))) throw new Error('The verdict covers the requested PRs at their requested heads; a PR whose head moved needs a new review request.');
@@ -356,14 +352,14 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
356
352
  }
357
353
 
358
354
  /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
359
- function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
355
+ async function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
360
356
  if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
361
357
  const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
362
358
  // An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
363
359
  // card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
364
360
  // with nothing uncommitted. A card on another machine is reviewed at its pushed head: its checkout is not read from here.
365
- const observed = readPullRequest(url);
366
- const path = !own.here ? null : originOf(own.path) === repository && run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : branchCheckout(board, id, repository, observed.headRefName);
361
+ const observed = await readPullRequest(url);
362
+ const path = !own.here ? null : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
367
363
  // A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
368
364
  // verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
369
365
  const merged = observed.state === 'MERGED';
@@ -377,11 +373,11 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
377
373
  // A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
378
374
  if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
379
375
  if (stage === 'request_review' && path && !merged) {
380
- run('git', ['check-ref-format', '--branch', branch], path);
381
- if (run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
382
- const uncommitted = run('git', ['status', '--porcelain', '--untracked-files=normal'], path).split('\n').filter(Boolean);
376
+ await run('git', ['check-ref-format', '--branch', branch], path);
377
+ if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
378
+ const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
383
379
  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 ? ', …' : ''}`);
384
- const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
380
+ const pushed = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
385
381
  if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
386
382
  }
387
383
  return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
@@ -389,25 +385,25 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
389
385
 
390
386
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
391
387
  /** Read the tracked workflow revision the next serve tick will load. */
392
- export function workflowRevision(root) {
388
+ export async function workflowRevision(root) {
393
389
  try {
394
390
  const workflow = realpathSync(join(root, 'workflow.yaml'));
395
- const checkout = run('git', ['rev-parse', '--show-toplevel'], dirname(workflow));
396
- const head = run('git', ['rev-parse', 'HEAD'], checkout);
397
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['config', '--get', 'remote.origin.url'], checkout))?.[1];
391
+ const checkout = await run('git', ['rev-parse', '--show-toplevel'], dirname(workflow));
392
+ const head = await run('git', ['rev-parse', 'HEAD'], checkout);
393
+ const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(await run('git', ['config', '--get', 'remote.origin.url'], checkout))?.[1];
398
394
  const name = relative(checkout, workflow).replaceAll('\\', '/');
399
395
  const source = readFileSync(workflow, 'utf8').trimEnd();
400
- if (run('git', ['show', `${head}:${name}`], checkout) !== source) return null;
396
+ if (await run('git', ['show', `${head}:${name}`], checkout) !== source) return null;
401
397
  // Prompts are loaded with the workflow and must belong to the same revision.
402
- run('git', ['diff', '--quiet', 'HEAD', '--', name, join(dirname(name), 'prompts')], checkout);
398
+ await run('git', ['diff', '--quiet', 'HEAD', '--', name, join(dirname(name), 'prompts')], checkout);
403
399
  return { checkout, repository, head, workflow_hash: digest(source), started_at: Date.now(), pid: process.pid };
404
400
  } catch { return null; }
405
401
  }
406
402
 
407
403
  /** Only the retained serve loop calls this after a successful tick. */
408
- export function retainWorkflowTick(root, before) {
404
+ export async function retainWorkflowTick(root, before) {
409
405
  if (!before) return null;
410
- const after = workflowRevision(root);
406
+ const after = await workflowRevision(root);
411
407
  if (!after || after.head !== before.head || after.workflow_hash !== before.workflow_hash) return null;
412
408
  const receipt = { ...before, completed_at: Date.now() };
413
409
  const path = join(root, 'runtime-deployment.json'), temporary = `${path}.${process.pid}.tmp`;
package/board/cli.mjs CHANGED
@@ -14,7 +14,7 @@ import { taskStatusOf } from './published.mjs';
14
14
  import { createServer } from 'node:net';
15
15
  import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
16
16
  import { notRuntime, ownHome } from './owner-files.mjs';
17
- import { spawnSync } from 'node:child_process';
17
+ import { execFile, spawnSync } from 'node:child_process';
18
18
  import { holdLock } from './lock-holder.mjs';
19
19
  import { takeEpoch } from './fence.mjs';
20
20
  import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
@@ -28,19 +28,23 @@ import { isLocalWorkspace } from './workspace.mjs';
28
28
  import { openZtrackBoard } from './ztrack.mjs';
29
29
  import * as engine from './engine.mjs';
30
30
 
31
+ // A mailbox read through the mailbox's own command, awaited (never a wait that holds the event loop). Gap A31: a
32
+ // helper process until the daemon owns mail (order 3 PR 4).
33
+ 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 ?? '')));
34
+
31
35
  /** The messages on this machine the mailbox still has to deliver (`supercode message waiting`), by message id. */
32
- function mailWaiting() {
33
- const r = spawnSync(supercodeBin(), ['message', 'waiting', '--json'], { encoding: 'utf8', env: commandEnv(), timeout: 15_000 });
34
- try { return new Map((JSON.parse(r.stdout) ?? []).map((mail) => [mail.id, mail])); } catch { return new Map(); }
36
+ async function mailWaiting() {
37
+ const stdout = await mailRead('waiting');
38
+ try { return new Map((JSON.parse(stdout) ?? []).map((mail) => [mail.id, mail])); } catch { return new Map(); }
35
39
  }
36
40
 
37
41
  /**
38
42
  * The messages on this machine whose delivery the mailbox gave up on (`supercode message expired`), by message id:
39
43
  * filed by the board, never delivered within the mailbox's bound, kept unread. Empty when the mailbox does not answer.
40
44
  */
41
- function mailExpiries() {
42
- const r = spawnSync(supercodeBin(), ['message', 'expired', '--json'], { encoding: 'utf8', env: commandEnv(), timeout: 15_000 });
43
- try { return new Map((JSON.parse(r.stdout) ?? []).map((expiry) => [expiry.id, expiry])); } catch { return new Map(); }
45
+ async function mailExpiries() {
46
+ const stdout = await mailRead('expired');
47
+ try { return new Map((JSON.parse(stdout) ?? []).map((expiry) => [expiry.id, expiry])); } catch { return new Map(); }
44
48
  }
45
49
 
46
50
  /**
@@ -319,34 +323,6 @@ const metadataOf = (v) => {
319
323
  return got;
320
324
  };
321
325
 
322
- /**
323
- * What is wrong with this orchestrator's version, or null: a build no release names (a hand install of a branch) or one
324
- * older than npm's latest. Every workflow call says it and the dispatcher tells its managers; action is required until
325
- * the release is installed. The latest is read from the registry at most once an hour.
326
- */
327
- function releaseBehind() {
328
- let running;
329
- try { running = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return null; }
330
- const cache = join(tmpdir(), 'supercode-orchestrator-latest.json');
331
- let latest = null;
332
- let fresh = false;
333
- try { const seen = JSON.parse(readFileSync(cache, 'utf8')); if (Date.now() - seen.at < 3_600_000) { fresh = true; latest = seen.latest; } } catch { /* read below */ }
334
- if (!fresh) {
335
- // a failed read is kept for the hour too: offline, no call waits on the registry more than once an hour
336
- const r = spawnSync('npm', ['view', '@volter/supercode-orchestrator', 'version'], { encoding: 'utf8', timeout: 5000 });
337
- latest = r.status === 0 ? r.stdout.trim() || null : null;
338
- try { writeFileSync(cache, JSON.stringify({ at: Date.now(), latest })); } catch { /* read again next call */ }
339
- }
340
- if (!latest || running === latest) return null;
341
- const parts = (v) => v.split('-')[0].split('.').map(Number);
342
- const [a, b] = [parts(running), parts(latest)];
343
- const older = a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
344
- const fix = `npm install -g @volter/supercode-orchestrator@${latest} (and each install's pinned copy)`;
345
- if (running.includes('-')) return { key: `${running}>${latest}`, text: `orchestrator ${running} is not a published release (latest ${latest}); action required: ${fix}` };
346
- if (older < 0) return { key: `${running}>${latest}`, text: `orchestrator ${running} is behind the published ${latest}; action required: ${fix}` };
347
- return null;
348
- }
349
-
350
326
  /** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
351
327
  export async function runVerb(argv) {
352
328
  await knowLocalMachine();
@@ -362,7 +338,7 @@ export async function runVerb(argv) {
362
338
  const slug = viewTag ? DEFAULT_BOARD : named;
363
339
  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; };
364
340
  const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
365
- const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
341
+ const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
366
342
  const id = rest[0];
367
343
  const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
368
344
  const ids = (first = rest) => [...first, ...(args.ids ?? [])];
@@ -380,7 +356,7 @@ export async function runVerb(argv) {
380
356
  if (sid && !READS.has(verb) && boardExists(root, slug)) {
381
357
  // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
382
358
  // never writes)
383
- withBoard((b) => { if (isCallersCall()) reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
359
+ await withBoardAsync(async (b) => { if (isCallersCall()) await reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
384
360
  }
385
361
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
386
362
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
@@ -472,7 +448,7 @@ export async function runVerb(argv) {
472
448
  // --goal / --no-goal are the create's words; without either, the workflow's initial decides
473
449
  // a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
474
450
  const sponsorship = args.sponsor || args['requested-by'] ? await requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {};
475
- const created = withBoard((b) => engine.createCard(b, {
451
+ const created = await withBoardAsync(async (b) => engine.createCard(b, await engine.prepareCard(b, {
476
452
  title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
477
453
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
478
454
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
@@ -482,7 +458,7 @@ export async function runVerb(argv) {
482
458
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
483
459
  tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])],
484
460
  ...sponsorship,
485
- }), true);
461
+ })), true);
486
462
  await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
487
463
  let answer = { id: created.id, existing: created.existing };
488
464
  if (args.adopt) {
@@ -881,10 +857,9 @@ export async function runVerb(argv) {
881
857
 
882
858
  case 'diagnostics': case 'diag': {
883
859
  const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
884
- const fleet = observeFleet(root);
860
+ const fleet = await observeFleet(root);
885
861
  const publishing = await boardPublishing(root);
886
- const expiredMail = mailExpiries();
887
- const waitingMail = mailWaiting();
862
+ const [expiredMail, waitingMail] = await Promise.all([await mailExpiries(), await mailWaiting()]);
888
863
  const order = ['warning', 'error', 'critical'];
889
864
  const found = withBoard((b) => {
890
865
  const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
@@ -1146,7 +1121,7 @@ export async function runVerb(argv) {
1146
1121
  }
1147
1122
  case 'close': {
1148
1123
  need(1, 'a card id');
1149
- const closed = withBoard((b) => closeSession(b, id));
1124
+ const closed = await withBoardAsync((b) => closeSession(b, id));
1150
1125
  say(args, closed ? closed.map((c) => `closed ${c.session_id} (pane ${c.pane} on ${c.machine})`).join('\n') : `${id} has no live session`, { ok: true, closed });
1151
1126
  return 0;
1152
1127
  }
@@ -1453,13 +1428,13 @@ function authorize(db, verb, rest, args, own) {
1453
1428
  }
1454
1429
 
1455
1430
  /** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
1456
- function closeSession(board, id) {
1431
+ async function closeSession(board, id) {
1457
1432
  const task = engine.getTask(board.db, id);
1458
1433
  if (!task) throw new Error(`no card ${id}`);
1459
1434
  if (task.current_run_id != null) throw new Error(`${id} has an active run; pause, block or reclaim it before closing its session`);
1460
1435
  tx(board, (db) => requestSessionClose(db, id));
1461
1436
  const report = { undelivered: [], closed: [] };
1462
- closeRequested(board, report, { taskId: id, caller: callerSession() });
1437
+ await closeRequested(board, report, { taskId: id, caller: callerSession() });
1463
1438
  if (report.undelivered.length) throw new Error(`close remains queued for retry: ${report.undelivered.map((r) => r[2]).join('; ')}`);
1464
1439
  return report.closed.length ? report.closed : null;
1465
1440
  }
@@ -1604,17 +1579,7 @@ async function serve(root, args = {}) {
1604
1579
  const FAILED_ROUNDS_ALERT = 5;
1605
1580
  const failing = new Map();
1606
1581
  let round = 0;
1607
- // A dispatcher that is not the published release is told up the chain, once per version pair: it keeps running, and
1608
- // its manager hears that action is required (a caller's stderr line alone reached no one).
1609
- const versionsTold = new Set();
1610
- const tellReleaseBehind = async () => {
1611
- const behind = releaseBehind();
1612
- if (!behind || versionsTold.has(behind.key)) return;
1613
- // the send's key makes a retry safe; a pair counts as told only once a manager got it
1614
- if (await alertManagers(root, 'default', `The dispatcher of ${root} runs ${behind.text}`, `release-behind-${behind.key}`) > 0) versionsTold.add(behind.key);
1615
- };
1616
1582
  const watchRounds = async (reports) => {
1617
- await tellReleaseBehind().catch(() => {});
1618
1583
  if (reports.every((r) => !r.error)) {
1619
1584
  // A publication the team refused holds this home's publishing where it is (nothing after it is skipped): a fact of
1620
1585
  // the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
@@ -1660,6 +1625,9 @@ async function serve(root, args = {}) {
1660
1625
  onChange: (name) => log(`home changed (${name}): read again on its next use`),
1661
1626
  onLost: (error) => { log(`the home's files can no longer be watched (${error.message}); stopping, for the keeper to start the owner again`); stop(); },
1662
1627
  });
1628
+ // Hermes's install is located once per process (a launch's memory snapshot reads it): here, before any verb is
1629
+ // answered, so no verb or round waits on it
1630
+ try { (await import('../hermes-cli.mjs')).hermesInstall(); } catch { /* none installed */ }
1663
1631
  let closeVerbs;
1664
1632
  try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
1665
1633
  log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
@@ -1748,6 +1716,8 @@ async function serve(root, args = {}) {
1748
1716
  if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1749
1717
  }
1750
1718
  } finally {
1719
+ // a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
1720
+ try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
1751
1721
  closeVerbs();
1752
1722
  unfollowHome();
1753
1723
  await closeDoors();