@volter/supercode-orchestrator 0.4.147 → 0.4.149

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,27 +1,36 @@
1
- // Deployment evidence is observed before a board mutation. No network or Git
2
- // process runs inside the optimistic transaction that decides completion.
1
+ // Review candidates are observed before a board mutation. No network or Git
2
+ // process runs inside the optimistic transaction. Deployment is not a completion gate.
3
3
  import { spawnSync } from 'node:child_process';
4
- import { readFileSync, realpathSync, writeFileSync, renameSync, lstatSync } from 'node:fs';
4
+ import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
5
5
  import { dirname, join, relative } from 'node:path';
6
- import { homedir } from 'node:os';
7
- import { pathToFileURL } from 'node:url';
8
6
  import { createHash } from 'node:crypto';
7
+ import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
9
8
 
10
9
  const observations = new Map();
11
10
  const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
12
11
  const run = (program, args, cwd) => {
12
+ if (cwd) {
13
+ let directory = false;
14
+ try { directory = statSync(cwd).isDirectory(); } catch { /* reported below */ }
15
+ if (!directory) throw new Error(`Repository checkout ${cwd} is unavailable; resolve the card's declared workspace before verifying its source.`);
16
+ }
13
17
  const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
14
18
  if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
15
19
  return result.stdout.trim();
16
20
  };
17
- const codePath = path => path && !/^(?:docs\/|\.volter\/tracker\/)/.test(path) && (!/\.(?:md|txt|png|jpg|svg)$/i.test(path) || /(?:^|\/)prompts\//.test(path));
18
21
 
19
22
  export function completionEvidence(board, id) {
20
- return observations.get(key(board, id)) ?? { required: true, ready: false, reason: 'Deployment evidence has not been observed; request review or done through the board CLI with --commit and --ci-run.' };
23
+ return observations.get(key(board, id)) ?? { required: false, ready: true, phase: 'source', deployed_sha: null };
24
+ }
25
+
26
+ export function completionPhase(board, id, entry, verb, payload, roles = []) {
27
+ if (verb === 'request_review') return 'request_review';
28
+ if ((verb === 'request_state' && payload.state === 'done' || verb === 'complete') && roles.includes('reviewer')) return 'review_verdict';
29
+ return verb === 'complete' ? 'acceptance' : 'completion';
21
30
  }
22
31
 
23
- export function observeCompletionEvidence(board, id, entry, payload = {}) {
24
- const state = inspectCompletionEvidence(board, id, entry, payload);
32
+ export function observeCompletionEvidence(board, id, entry, payload = {}, phase = 'completion') {
33
+ const state = inspectCompletionEvidence(board, id, entry, payload, phase);
25
34
  observations.set(key(board, id), state);
26
35
  return state;
27
36
  }
@@ -37,114 +46,174 @@ export function withCompletionEvidence(board, id, state, fn) {
37
46
  }
38
47
  }
39
48
 
40
- export function inspectCompletionEvidence(board, id, entry, payload = {}) {
41
- const state = { required: true, ready: false, reason: null };
42
- if (!entry.params.completion_ci) { Object.assign(state, { required: false, ready: true }); return state; }
43
- try {
44
- const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
45
- const parent = task?.subtask_of && board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of);
46
- const path = task?.workspace_path ?? parent?.workspace_path;
47
- const old = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind IN ('review_requested','state_requested','review_pass') ORDER BY id DESC LIMIT 20").all(id)
48
- .map(row => { try { return JSON.parse(row.payload)?.ci; } catch { return null; } }).find(ci => ci?.commit);
49
- const supplied = payload.commit ?? payload.metadata?.commit ?? old?.commit;
50
- if (!path) {
51
- if (supplied) throw new Error('The named commit needs a resolved repository workspace.');
52
- Object.assign(state, { required: false, ready: true }); return state;
49
+ /** The latest explicitly requested PRs are the reviewer's candidate, never the deployment tip. */
50
+ export function reviewCandidate(board, id) {
51
+ const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
52
+ const payload = row && JSON.parse(row.payload);
53
+ return payload?.ci?.pr ? payload.ci : null;
54
+ }
55
+
56
+ /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
57
+ 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 }] : []);
58
+ const originOf = (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1] ?? null;
59
+
60
+ function repositoryWorkspace(board, id) {
61
+ 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);
62
+ 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);
63
+ const workspace = task?.workspace_kind === 'scratch' ? parent : task;
64
+ return workspace?.workspace_kind !== 'scratch' && workspace?.workspace_anchor ? resolveWorkspace(workspace, false).path : workspace?.workspace_kind !== 'scratch' ? workspace?.workspace_path : null;
65
+ }
66
+
67
+ const pullRequestLists = new Map();
68
+
69
+ function branchCheckout(board, id, repository) {
70
+ const task = board.db.prepare('SELECT t.branch_name, c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
71
+ try { return checkoutOf(task?.machine, repository, task?.branch_name ?? `wt/${id}`)?.path ?? null; } catch { return null; }
72
+ }
73
+
74
+ /** Every opened PR of the arc: each open PR whose head is the card's branch or a `wt/<card id>` branch, in the
75
+ * card's own repository and in every repository this machine keeps a checkout of. One review covers them all;
76
+ * the caller decides the transition. */
77
+ export function openReviewPullRequests(board, id) {
78
+ const own = repositoryWorkspace(board, id);
79
+ if (!own) return null;
80
+ const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
81
+ const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
82
+ const ownRepository = originOf(own);
83
+ if (!ownRepository) return null;
84
+ const mine = (head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
85
+ const prs = [];
86
+ for (const repository of new Set([ownRepository, ...knownRepositories()])) {
87
+ // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
88
+ let list = pullRequestLists.get(repository);
89
+ if (!list || Date.now() - list.at >= 60_000) {
90
+ let items = [];
91
+ try { items = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository'])); }
92
+ catch (error) { if (repository === ownRepository) throw error; } // another repository unreachable here is not this arc's
93
+ list = { at: Date.now(), items };
94
+ if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
95
+ pullRequestLists.set(repository, list);
53
96
  }
54
- const history = run('git', ['log', '--all', '--format=%H%x00', '--name-only', '--fixed-strings', `--grep=${id}`], path);
55
- const records = [...history.matchAll(/([a-f0-9]{40})\0([^\0]*?)(?=[a-f0-9]{40}\0|$)/gs)];
56
- const latest = records.find(([, , names]) => names.split('\n').some(codePath))?.[1];
57
- if (!latest && !supplied) { Object.assign(state, { required: false, ready: true }); return state; }
58
- const commit = run('git', ['rev-parse', '--verify', `${supplied ?? latest}^{commit}`], path);
59
- if (latest) run('git', ['merge-base', '--is-ancestor', latest, commit], path);
60
- const remote = run('git', ['remote', 'get-url', 'origin'], path);
61
- const repo = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1];
62
- if (!repo) throw new Error('Deployment evidence requires this workspace\'s GitHub origin.');
63
- Object.assign(state, { commit, repository: repo });
64
- const controller = entry.params.completion_ci.controller_deployments?.[repo];
65
- if (controller) {
66
- const expand = value => value.startsWith('~/') ? join(homedir(), value.slice(2)) : value;
67
- const installed = expand(controller.installed_path);
68
- if (!lstatSync(installed).isSymbolicLink()) throw new Error('The controller installation must be the declared checkout symlink.');
69
- const skill = realpathSync(installed), checkout = run('git', ['rev-parse', '--show-toplevel'], skill);
70
- const origin = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], checkout))?.[1];
71
- if (origin !== repo) throw new Error('The controller installation points to another repository.');
72
- const head = run('git', ['rev-parse', 'HEAD'], checkout);
73
- run('git', ['merge-base', '--is-ancestor', commit, head], checkout);
74
- run('git', ['diff', '--quiet', 'HEAD', '--', relative(checkout, join(skill, 'scripts'))], checkout);
75
- const sourceHash = run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e',
76
- 'const m=await import(process.argv[1]); process.stdout.write(m.installedSourceHash())', pathToFileURL(join(skill, 'scripts/controller-diagnostics.ts')).href], checkout);
77
- const port = Number(readFileSync(expand(controller.port_file), 'utf8').trim());
78
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('The controller has no valid retained port.');
79
- const url = `http://127.0.0.1:${port}/health`;
80
- const health = JSON.parse(run('curl', ['--fail', '--silent', '--show-error', '--max-time', '2', url], checkout));
81
- if (!health.alive || !health.cdp_connected || health.restart_required !== false || health.controller?.source_hash !== sourceHash || health.installed_source_hash !== sourceHash) throw new Error('The running browser controller has not loaded the committed installed code.');
82
- Object.assign(state, { ready: true, workflow: 'installed browser controller', run_id: `${health.controller.pid}@${health.controller.started_at}`, status: 'completed', conclusion: 'success', deployed_sha: head, url, source_hash: sourceHash });
97
+ for (const pr of list.items) if (!pr.isCrossRepository && mine(pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
98
+ }
99
+ if (!prs.length) return null;
100
+ return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
101
+ }
102
+
103
+ /** The repositories this machine keeps a checkout of. */
104
+ function knownRepositories() {
105
+ try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
106
+ }
107
+
108
+ /** Whether the arc's open PRs (`[{ url, head }]`) call for its review. Before any verdict: while some head has none.
109
+ * After a verdict: only once a PR that verdict judged shows a new head. A PR that joins the arc, or one that merged or
110
+ * closed, does not by itself bring the arc back: after changes are requested the next run is the implementer's. */
111
+ export function reviewDue(board, id, prs) {
112
+ const unjudged = prs.some((p) => !judgedHead(board, id, p.head));
113
+ const last = board.db.prepare(`SELECT r.payload FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
114
+ AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id)
115
+ ORDER BY r.id DESC LIMIT 1`).get(id);
116
+ if (!last) return unjudged;
117
+ const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
118
+ return unjudged && judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit));
119
+ }
120
+
121
+ /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
122
+ * a review request naming that head, before the next request. A head is reviewed once. */
123
+ export function judgedHead(board, id, head) {
124
+ if (!head) return null;
125
+ return board.db.prepare(`SELECT v.id, v.kind FROM task_events r JOIN task_events v ON v.task_id = r.task_id
126
+ AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id
127
+ AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
128
+ WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
129
+ 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;
130
+ }
131
+
132
+ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
133
+ const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
134
+ try {
135
+ if (stage === 'completion' || stage === 'acceptance') {
136
+ const supplied = payload.commit ?? payload.metadata?.commit;
137
+ if (supplied) {
138
+ const path = repositoryWorkspace(board, id);
139
+ if (!path) throw new Error('The named commit needs a resolved repository workspace.');
140
+ Object.assign(state, { repository: originOf(path), commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
141
+ }
142
+ const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
143
+ if (candidate) {
144
+ const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
145
+ const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
146
+ const wanted = candidatePullRequests(candidate);
147
+ 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.');
148
+ for (const w of wanted) {
149
+ const pr = JSON.parse(run('gh', ['pr', 'view', w.pr.url, '--repo', w.repository, '--json', 'state,headRefOid']));
150
+ 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.`);
151
+ }
152
+ Object.assign(state, { ...candidate, ready: true, reason: null });
153
+ }
83
154
  return state;
84
155
  }
85
- if (entry.params.completion_ci.keeper_repositories?.includes(repo)) {
86
- const receiptPath = join(board.root, 'runtime-deployment.json');
87
- const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
88
- if (receipt.repository !== repo || receipt.completed_at < Number(run('git', ['show', '-s', '--format=%ct', commit], path)) * 1000) throw new Error('No keeper tick after this runtime commit.');
89
- run('git', ['merge-base', '--is-ancestor', commit, receipt.head], receipt.checkout);
90
- const current = workflowRevision(board.root);
91
- if (!current || current.head !== receipt.head || current.workflow_hash !== receipt.workflow_hash) throw new Error('The retained keeper tick does not describe the live checkout and loaded workflow revision.');
92
- Object.assign(state, { ready: true, workflow: 'launchd keeper', run_id: `${receipt.pid}@${receipt.completed_at}`, status: 'completed', conclusion: 'success', deployed_sha: receipt.head, url: receiptPath });
156
+ const own = repositoryWorkspace(board, id);
157
+ const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
158
+ const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
159
+ if (!own) {
160
+ if (urls.length) throw new Error('A PR candidate needs a resolved repository workspace.');
93
161
  return state;
94
162
  }
95
- const configured = entry.params.completion_ci.workflows?.[repo] ?? entry.params.completion_ci.workflows?.['*'];
96
- if (!Array.isArray(configured) || !configured.length) throw new Error(`No deployment workflow is declared for ${repo} in completion_ci.workflows.`);
97
- const matchedRules = (entry.params.completion_ci.path_workflows?.[repo] ?? []).map(rule => ({ ...rule,
98
- changed_at: records.find(([, , names]) => names.split('\n').some(path => rule.paths.some(prefix => path.startsWith(prefix))))?.[1]
99
- })).filter(rule => rule.changed_at);
100
- const requestedRun = payload.ci_run ?? payload['ci-run'] ?? payload.metadata?.ci_run ?? (old?.commit === commit ? old?.run_id : null);
101
- let receipt;
102
- if (requestedRun) {
103
- receipt = JSON.parse(run('gh', ['run', 'view', String(requestedRun), '--repo', repo, '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
104
- } else {
105
- const runs = JSON.parse(run('gh', ['run', 'list', '--repo', repo, '--commit', commit, '--limit', '100', '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
106
- receipt = runs.find(row => configured.includes(row.workflowName));
107
- }
108
- if (!receipt) throw new Error(`No deployment CI run exists for ${repo}@${commit}.`);
109
- Object.assign(state, { run_id: receipt.databaseId, workflow: receipt.workflowName, status: receipt.status, conclusion: receipt.conclusion, url: receipt.url });
110
- if (receipt.headSha !== commit) throw new Error(`CI run ${receipt.databaseId} is for ${receipt.headSha}, not ${commit}.`);
111
- if (!configured.includes(receipt.workflowName)) throw new Error(`${receipt.workflowName} is not a declared deployment workflow for ${repo}.`);
112
- if (receipt.status !== 'completed' || receipt.conclusion !== 'success') throw new Error(`Deployment CI ${receipt.databaseId} is ${receipt.status}/${receipt.conclusion || 'pending'}.`);
113
- // A later SDK publication need not rebuild unchanged native code. It does
114
- // need a successful native deployment containing this task's native changes.
115
- // Retain each artifact's actual SHA, rather than calling the SDK run a native release.
116
- state.coverage = [];
117
- const deployments = new Map();
118
- for (const rule of matchedRules) {
119
- if (rule.workflows.includes(receipt.workflowName)) continue;
120
- for (const workflow of rule.workflows) {
121
- if (!deployments.has(workflow)) deployments.set(workflow, JSON.parse(run('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--limit', '100', '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path)));
122
- }
123
- const successful = rule.workflows.flatMap(workflow => deployments.get(workflow)).filter(row => row.status === 'completed' && row.conclusion === 'success');
124
- const covering = successful.find(row => {
125
- if (!rule.workflows.includes(row.workflowName) || !configured.includes(row.workflowName)) return false;
126
- try {
127
- run('git', ['merge-base', '--is-ancestor', rule.changed_at, row.headSha], path);
128
- try {
129
- run('git', ['merge-base', '--is-ancestor', commit, row.headSha], path);
130
- return true;
131
- } catch {}
132
- run('git', ['merge-base', '--is-ancestor', row.headSha, commit], path);
133
- // Any later change in these paths requires another native deployment,
134
- // even when a different task authored that change.
135
- return run('git', ['diff', '--name-only', row.headSha, commit], path).split('\n').every(name => !rule.paths.some(prefix => name.startsWith(prefix)));
136
- } catch { return false; }
137
- });
138
- if (!covering) throw new Error(`No successful ${rule.workflows.join('/')} deployment covers ${rule.paths.join(', ')} through ${commit}.`);
139
- state.coverage.push({ paths: rule.paths, commit: covering.headSha, workflow: covering.workflowName, run_id: covering.databaseId, conclusion: covering.conclusion, url: covering.url });
163
+ // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
164
+ // always names every opened PR it changed, independent of completion_ci.
165
+ if (stage === 'review_verdict' && !asked && !urls.length) return state;
166
+ Object.assign(state, { required: true, ready: false });
167
+ if (!urls.length) throw new Error('Name each opened PR with --pr.');
168
+ const named = [].concat(stage === 'review_verdict'
169
+ ? payload.reviewed_sha ?? payload.pr_head ?? payload.commit ?? []
170
+ : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
171
+ 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.');
172
+ if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
173
+ const prs = urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
174
+ if (stage === 'request_review') {
175
+ // A review is asked for only while some PR has a head no verdict has judged.
176
+ 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.');
177
+ } else if (asked) {
178
+ const requested = candidatePullRequests(asked);
179
+ 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.');
180
+ if (requested.some((r) => !named.includes(r.commit))) throw new Error('Name every reviewed PR head with --reviewed-sha.');
181
+ const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
182
+ const bound = candidatePullRequests(latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate);
183
+ if (bound.length && (bound.length !== requested.length || bound.some((b) => !requested.some((r) => r.pr.url === b.pr.url && r.commit === b.commit)))) throw new Error('This reviewer run was launched for different PR heads.');
140
184
  }
141
- state.deployed_sha = commit;
142
- state.ready = true;
143
- state.reason = null;
144
- } catch (error) { state.reason = error.message; }
185
+ Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
186
+ } catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message }); }
145
187
  return state;
146
188
  }
147
189
 
190
+ /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
191
+ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
192
+ if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
193
+ const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
194
+ // An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
195
+ // card's own checkout, and this machine's checkout of another repository on the card's branch when it keeps one,
196
+ // must also hold that head with nothing uncommitted.
197
+ const path = originOf(own) === repository ? own : branchCheckout(board, id, repository);
198
+ const observed = JSON.parse(run('gh', ['pr', 'view', url, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository']));
199
+ if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
200
+ const head = supplied ?? observed.headRefOid;
201
+ if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
202
+ if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
203
+ const branch = observed.headRefName;
204
+ const mainBranch = entry.params.completion_ci?.main_branches?.[repository] ?? 'main';
205
+ if (branch === mainBranch || observed.baseRefName !== mainBranch) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
206
+ if (stage === 'request_review' && path) {
207
+ run('git', ['check-ref-format', '--branch', branch], path);
208
+ if (run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
209
+ run('git', ['diff', '--quiet', 'HEAD'], path);
210
+ if (run('git', ['ls-files', '--others', '--exclude-standard'], path)) throw new Error(`Commit the untracked work in ${path} before requesting review.`);
211
+ const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
212
+ if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
213
+ }
214
+ return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
215
+ }
216
+
148
217
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
149
218
  /** Read the tracked workflow revision the next serve tick will load. */
150
219
  export function workflowRevision(root) {
package/board/cli.mjs CHANGED
@@ -36,9 +36,10 @@ Hermes Kanban's verbs, with their flags:
36
36
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
37
37
  pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
38
38
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
39
- unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
39
+ unblock <id>... [--reason] request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
40
40
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
41
- specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
41
+ specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
42
+ decompose <id> [--child "<title>[@<profile>]"]... [--all]
42
43
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
43
44
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
44
45
  dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
@@ -47,26 +48,32 @@ Hermes Kanban's verbs, with their flags:
47
48
  supercode's own:
48
49
  init --backing sqlite|files · boards create <slug> --backing sqlite|files
49
50
  Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
50
- create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start [--review-only]
51
+ create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
51
52
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
52
53
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
53
54
  move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
54
55
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
56
+ notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
57
+ notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
55
58
  Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
56
59
  <event> <id>... [reason] [--<field> value]: any other event the board's workflow declares, its flags the event's fields.
57
60
  start <id> release a --no-start hold and start through the declared workflow
58
61
  create ... --adopt <session> hold dispatch until that live session is attached
59
62
  goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
60
63
 
61
- specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
64
+ specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
65
+ request-review <id> (--pr URL --pr-head SHA)...: hand over every open PR the arc changed, each at its pushed head; one review covers them all.
66
+ request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
67
+ request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
68
+ complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
62
69
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
63
70
  heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
64
71
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
65
72
  `;
66
73
 
67
- const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
74
+ const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha']);
68
75
  const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
69
- 'goal', 'no-goal', 'review-only', 'no-review-only', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
76
+ 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
70
77
  // flags that take every following word up to the next flag (Hermes's nargs='+')
71
78
  const GREEDY = new Set(['ids', 'rm']);
72
79
 
@@ -172,11 +179,11 @@ async function laneState(root) {
172
179
  }
173
180
 
174
181
  /** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
182
+ // The calling session's own address, as its actor is named: no fleet scan (a `message list` costs seconds).
175
183
  function callerAddress() {
176
184
  const id = callerSession();
177
185
  if (!id) return null;
178
- const fleet = observeFleet();
179
- return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
186
+ return `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
180
187
  }
181
188
 
182
189
  const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
@@ -199,7 +206,7 @@ const cardDict = (t) => ({
199
206
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
200
207
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
201
208
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
202
- machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, review_only: Boolean(t.review_only),
209
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
203
210
  });
204
211
 
205
212
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
@@ -231,7 +238,6 @@ const metadataOf = (v) => {
231
238
  export async function runWorkflow(argv) {
232
239
  const args = parse(argv);
233
240
  const [verb, ...rest] = args._;
234
- if (args['review-only'] && args['no-review-only']) throw new Error('Choose review-only or no-review-only, not both.');
235
241
  if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
236
242
  const root = homeOf(args);
237
243
  const slug = boardOf(root, args);
@@ -274,7 +280,7 @@ export async function runWorkflow(argv) {
274
280
  const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug, compatPath: paths.sqlite });
275
281
  migrated.close();
276
282
  } finally { source.close(); }
277
- renameSync(`${preparing}.runtime.db`, `${paths.ztrack}.runtime.db`);
283
+ renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
278
284
  renameSync(preparing, paths.ztrack);
279
285
  say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
280
286
  return 0;
@@ -290,7 +296,6 @@ export async function runWorkflow(argv) {
290
296
 
291
297
  case 'create': {
292
298
  need(1, 'a title');
293
- const { lanes, settings } = await laneState(root);
294
299
  const maxRetries = args['max-retries'] !== undefined ? Number(args['max-retries']) : null;
295
300
  if (maxRetries !== null && !(maxRetries >= 1)) throw new Error(`--max-retries must be >= 1 (got ${args['max-retries']}); use 1 to trip on the first failure`);
296
301
  const meta = boardMeta(root, slug);
@@ -302,22 +307,19 @@ export async function runWorkflow(argv) {
302
307
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
303
308
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
304
309
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
305
- noStart: Boolean(args['no-start']), adopt: args.adopt ?? null, reviewOnly: Boolean(args['review-only']),
310
+ noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
306
311
  every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
307
312
  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,
308
313
  }), true);
309
314
  let answer = { id: created.id, existing: created.existing };
310
315
  if (args.adopt) {
311
316
  withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
317
+ const { settings } = await laneState(root);
312
318
  const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
313
319
  answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
314
- } else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
315
- const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
316
- const started = report.started.find(([t]) => t === created.id);
317
- const queued = report.queued.find(([t]) => t === created.id);
318
- const failed = report.failed.find(([t]) => t === created.id);
319
- answer = { ...answer, ...(started ? { started: true, session: started[1], mode: started[2] } : {}), ...(queued ? { started: false, waits: queued[1] } : {}), ...(failed ? { started: false, failed: failed[1] } : {}) };
320
320
  }
321
+ // Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
322
+ // thread records the start. A create never runs a dispatch tick inline.
321
323
  // the creator hears what happens from here on; a start answered above is not told twice
322
324
  const address = args['no-notify'] ? null : args.notify ?? callerAddress();
323
325
  if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
@@ -544,7 +546,7 @@ export async function runWorkflow(argv) {
544
546
  case 'complete': {
545
547
  need(1, 'a card id');
546
548
  const metadata = metadataOf(args.metadata);
547
- for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null }));
549
+ for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
548
550
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
549
551
  }
550
552
  case 'edit': {
@@ -574,7 +576,7 @@ export async function runWorkflow(argv) {
574
576
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
575
577
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
576
578
  }
577
- case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
579
+ case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
578
580
  case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
579
581
  case 'reopen': {
580
582
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
@@ -629,8 +631,8 @@ export async function runWorkflow(argv) {
629
631
  if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
630
632
  const results = [];
631
633
  for (const t of targets) {
632
- if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args['review-only'] || args['no-review-only'])) {
633
- const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, reviewOnly: args['review-only'] ? true : args['no-review-only'] ? false : null, author: args.author ?? callerAddress() ?? callerName() }));
634
+ if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined)) {
635
+ const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, author: args.author ?? callerAddress() ?? callerName() }));
634
636
  results.push({ id: t, specified: true, status: moved.to });
635
637
  } else if (verb === 'decompose' && args.child?.length) {
636
638
  const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
@@ -711,6 +713,12 @@ export async function runWorkflow(argv) {
711
713
 
712
714
  case 'notify-subscribe': case 'notify-unsubscribe': {
713
715
  need(1, 'a card id');
716
+ if (args['for-card']) {
717
+ if (args.address || args['chat-id'] || args['thread-id'] || (args.platform && args.platform !== 'supercode') || (args.role && args.role !== 'dependency')) throw new Error('--for-card targets that arc\'s session; do not supply another recipient or audience.');
718
+ const subscription = withBoard(board => engine.subscribeDependency(board, args['for-card'], id, { remove: verb === 'notify-unsubscribe' }));
719
+ say(args, verb === 'notify-unsubscribe' ? `Removed ${args['for-card']}'s dependency subscription to ${id}` : `${args['for-card']} hears ${id} through ${subscription.address}`, { ok: true, ...subscription });
720
+ return 0;
721
+ }
714
722
  const platform = args.platform ?? 'supercode';
715
723
  const chat = args['chat-id'] ?? args.address ?? (platform === 'supercode' ? callerAddress() : null);
716
724
  if (!chat) throw new Error(`${verb} names the subscriber: --platform P --chat-id C (a supercode session's address with --platform supercode)`);
@@ -863,9 +871,10 @@ export async function runWorkflow(argv) {
863
871
  if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
864
872
  need(1, 'a card id');
865
873
  const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
866
- const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
874
+ const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, reviewed_sha: args['reviewed-sha'] ?? fields.reviewed_sha, pr_head: args['pr-head'] ?? fields.pr_head, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (name === 'request_state' ? null : rest.slice(1).join(' ') || null) };
867
875
  const results = [];
868
- for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
876
+ if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
877
+ else for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
869
878
  say(args, results.map(([t, s]) => name === 'request_state' ? `${t}: ${payload.state} requested; state remains ${s}. Mail delivery is recorded on the card.` : `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
870
879
  }
871
880
  }
@@ -1101,7 +1110,11 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1101
1110
  const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
1102
1111
  if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
1103
1112
  else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
1104
- if (task.subtask_of && engine.lastSession(db, task.subtask_of)?.session_id === sessionId) roles.push('arc');
1113
+ if (task.subtask_of) {
1114
+ const parent = engine.getTask(db, task.subtask_of);
1115
+ const worker = parent?.current_run_id ? engine.currentRun(db, parent.id)?.metadata?.supercode : engine.lastSession(db, task.subtask_of);
1116
+ if (worker?.session_id === sessionId) roles.push('arc');
1117
+ }
1105
1118
  return roles;
1106
1119
  }
1107
1120
 
@@ -1116,14 +1129,18 @@ function authorize(db, verb, rest, args, own) {
1116
1129
  // (what a session may do to a card is its workflow's: each transition's `by`)
1117
1130
  if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1118
1131
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1119
- const card = engine.getTask(db, rest[0]);
1120
- if (!card) return; // the verb itself answers "no card"
1121
- const creator = completionOwnerOf(db, card.id);
1122
- if (creator && creator === actorOf()) return;
1123
- if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) return;
1124
- // a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
1125
- if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1126
- for (const other of args.ids ?? []) if (other !== own.id && engine.getTask(db, other)?.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${other}`);
1132
+ if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1133
+ if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
1134
+ return;
1135
+ }
1136
+ const targets = verb === 'request-state' ? [...rest, ...(args.ids ?? [])] : [rest[0], ...(args.ids ?? [])];
1137
+ for (const target of targets) {
1138
+ const card = engine.getTask(db, target);
1139
+ if (!card) continue;
1140
+ if (completionOwnerOf(db, card.id) === actorOf()) continue;
1141
+ if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
1142
+ if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1143
+ }
1127
1144
  }
1128
1145
 
1129
1146
  /** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
@@ -1200,8 +1217,13 @@ async function serve(root, args = {}) {
1200
1217
  await new Promise(resolve => setTimeout(resolve, 250));
1201
1218
  continue;
1202
1219
  }
1220
+ // A serve never ticks on code older than the installed orchestrator, also right after an
1221
+ // installation window: its keeper starts the new one, whose board store this one does not keep.
1222
+ const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
1223
+ if (replaced) { log(`orchestrator ${replaced} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1203
1224
  pausedSaid = Boolean(paused(root)) && pausedSaid;
1204
1225
  let interval = 60;
1226
+ let nextMailAt = Infinity;
1205
1227
  const began = Date.now();
1206
1228
  if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
1207
1229
  wakeReason = null;
@@ -1210,6 +1232,7 @@ async function serve(root, args = {}) {
1210
1232
  const settings = { ...state.settings, ...override };
1211
1233
  interval = settings.interval;
1212
1234
  for (const r of await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true })) {
1235
+ nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
1213
1236
  const said = summary(r);
1214
1237
  if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
1215
1238
  if (!said.endsWith('nothing to do') || args.verbose) log(said);
@@ -1233,7 +1256,7 @@ async function serve(root, args = {}) {
1233
1256
  // the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
1234
1257
  events.refresh();
1235
1258
  if (!stopped && !moved) {
1236
- wakeReason = await events.wait(Math.max(0, interval * 1000 - (Date.now() - began)));
1259
+ wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now())));
1237
1260
  // A busy event stream is coalesced to at most one tick per second.
1238
1261
  const remaining = 1000 - (Date.now() - began);
1239
1262
  if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));