@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 +177 -108
- package/board/cli.mjs +58 -35
- package/board/dispatch.mjs +169 -43
- package/board/engine.mjs +130 -30
- package/board/notices.mjs +5 -0
- package/board/request-mail.mjs +59 -0
- package/board/store.mjs +6 -3
- package/board/tools.mjs +15 -9
- package/board/workflow.mjs +40 -32
- package/board/workspace.mjs +26 -0
- package/board/ztrack.mjs +781 -544
- package/hermes-locks.mjs +1 -20
- package/mcp/hermes-kanban-tools.json +76 -14
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/ci.mjs
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
|
-
//
|
|
2
|
-
// process runs inside the optimistic transaction
|
|
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,
|
|
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:
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
142
|
-
|
|
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] [--
|
|
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
|
|
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> --
|
|
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', '
|
|
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
|
-
|
|
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,
|
|
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}.
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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));
|