@volter/supercode-orchestrator 0.4.148 → 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 +129 -56
- package/board/cli.mjs +21 -23
- package/board/dispatch.mjs +39 -24
- package/board/engine.mjs +50 -18
- package/board/store.mjs +6 -3
- package/board/tools.mjs +2 -2
- package/board/workflow.mjs +20 -27
- package/board/workspace.mjs +26 -0
- package/board/ztrack.mjs +779 -542
- package/hermes-locks.mjs +1 -20
- package/mcp/hermes-kanban-tools.json +40 -18
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/ci.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// Review candidates are observed before a board mutation. No network or Git
|
|
2
2
|
// process runs inside the optimistic transaction. Deployment is not a completion gate.
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, relative } from 'node:path';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
-
import { resolveWorkspace } from './workspace.mjs';
|
|
7
|
+
import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
|
|
8
8
|
|
|
9
9
|
const observations = new Map();
|
|
10
10
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
11
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
|
+
}
|
|
12
17
|
const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
13
18
|
if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
|
|
14
19
|
return result.stdout.trim();
|
|
@@ -41,13 +46,17 @@ export function withCompletionEvidence(board, id, state, fn) {
|
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
/** The latest explicitly requested
|
|
49
|
+
/** The latest explicitly requested PRs are the reviewer's candidate, never the deployment tip. */
|
|
45
50
|
export function reviewCandidate(board, id) {
|
|
46
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);
|
|
47
52
|
const payload = row && JSON.parse(row.payload);
|
|
48
53
|
return payload?.ci?.pr ? payload.ci : null;
|
|
49
54
|
}
|
|
50
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
|
+
|
|
51
60
|
function repositoryWorkspace(board, id) {
|
|
52
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);
|
|
53
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);
|
|
@@ -57,90 +66,154 @@ function repositoryWorkspace(board, id) {
|
|
|
57
66
|
|
|
58
67
|
const pullRequestLists = new Map();
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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;
|
|
64
80
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
65
|
-
const branch = task?.branch_name ?? run('git', ['branch', '--show-current'],
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
list
|
|
74
|
-
|
|
75
|
-
|
|
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);
|
|
96
|
+
}
|
|
97
|
+
for (const pr of list.items) if (!pr.isCrossRepository && mine(pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
76
98
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
80
130
|
}
|
|
81
131
|
|
|
82
132
|
export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
83
133
|
const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
|
|
84
134
|
try {
|
|
85
|
-
const path = repositoryWorkspace(board, id);
|
|
86
135
|
if (stage === 'completion' || stage === 'acceptance') {
|
|
87
136
|
const supplied = payload.commit ?? payload.metadata?.commit;
|
|
88
137
|
if (supplied) {
|
|
138
|
+
const path = repositoryWorkspace(board, id);
|
|
89
139
|
if (!path) throw new Error('The named commit needs a resolved repository workspace.');
|
|
90
|
-
|
|
91
|
-
Object.assign(state, { repository, commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
|
|
140
|
+
Object.assign(state, { repository: originOf(path), commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
|
|
92
141
|
}
|
|
93
142
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
94
143
|
if (candidate) {
|
|
95
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);
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|
|
100
152
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
101
153
|
}
|
|
102
154
|
return state;
|
|
103
155
|
}
|
|
156
|
+
const own = repositoryWorkspace(board, id);
|
|
104
157
|
const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
-
if (
|
|
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.');
|
|
108
161
|
return state;
|
|
109
162
|
}
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
if (stage === 'review_verdict' && !asked && !
|
|
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;
|
|
113
166
|
Object.assign(state, { required: true, ready: false });
|
|
114
|
-
if (
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (observed.headRefOid !== supplied) throw new Error(`PR head changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
|
|
122
|
-
const branch = payload.branch ?? payload.metadata?.branch ?? observed.headRefName;
|
|
123
|
-
const mainBranch = entry.params.completion_ci?.main_branches?.[repository] ?? 'main';
|
|
124
|
-
if (branch !== observed.headRefName || branch === mainBranch || observed.baseRefName !== mainBranch) throw new Error('The PR must propose its named worktree branch to the repository main branch.');
|
|
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));
|
|
125
174
|
if (stage === 'request_review') {
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
run('git', ['diff', '--quiet', 'HEAD'], path);
|
|
129
|
-
if (run('git', ['ls-files', '--others', '--exclude-standard'], path)) throw new Error('Commit the untracked work before requesting review.');
|
|
130
|
-
const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
|
|
131
|
-
if (pushed !== supplied) throw new Error('Push the exact PR head before requesting 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.');
|
|
132
177
|
} else if (asked) {
|
|
133
|
-
|
|
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.');
|
|
134
181
|
const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
|
|
135
|
-
const bound = latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate;
|
|
136
|
-
if (bound && (bound.
|
|
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.');
|
|
137
184
|
}
|
|
138
|
-
Object.assign(state, { ready: true,
|
|
139
|
-
pr: { url: observed.url, number: observed.number, head: supplied, branch, base: observed.baseRefName }, reason: null });
|
|
185
|
+
Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
|
|
140
186
|
} catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message }); }
|
|
141
187
|
return state;
|
|
142
188
|
}
|
|
143
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
|
+
|
|
144
217
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
145
218
|
/** Read the tracked workflow revision the next serve tick will load. */
|
|
146
219
|
export function workflowRevision(root) {
|
package/board/cli.mjs
CHANGED
|
@@ -36,7 +36,7 @@ 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> --pr URL --pr-head SHA [--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
41
|
specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
|
|
42
42
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
@@ -48,7 +48,7 @@ Hermes Kanban's verbs, with their flags:
|
|
|
48
48
|
supercode's own:
|
|
49
49
|
init --backing sqlite|files · boards create <slug> --backing sqlite|files
|
|
50
50
|
Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
|
|
51
|
-
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
|
|
52
52
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
53
53
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
54
54
|
move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
|
|
@@ -61,10 +61,9 @@ supercode's own:
|
|
|
61
61
|
create ... --adopt <session> hold dispatch until that live session is attached
|
|
62
62
|
goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
|
|
63
63
|
|
|
64
|
-
specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
|
|
65
64
|
specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
|
|
66
|
-
request-review <id> --pr URL --pr-head SHA
|
|
67
|
-
request-state <id> --state done --reviewed-sha SHA
|
|
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.
|
|
68
67
|
request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
|
|
69
68
|
complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
|
|
70
69
|
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
@@ -72,9 +71,9 @@ supercode's own:
|
|
|
72
71
|
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
73
72
|
`;
|
|
74
73
|
|
|
75
|
-
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']);
|
|
76
75
|
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
77
|
-
'goal', 'no-goal', '
|
|
76
|
+
'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
|
|
78
77
|
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
79
78
|
const GREEDY = new Set(['ids', 'rm']);
|
|
80
79
|
|
|
@@ -180,11 +179,11 @@ async function laneState(root) {
|
|
|
180
179
|
}
|
|
181
180
|
|
|
182
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).
|
|
183
183
|
function callerAddress() {
|
|
184
184
|
const id = callerSession();
|
|
185
185
|
if (!id) return null;
|
|
186
|
-
|
|
187
|
-
return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
|
|
186
|
+
return `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
|
|
188
187
|
}
|
|
189
188
|
|
|
190
189
|
const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
|
|
@@ -207,7 +206,7 @@ const cardDict = (t) => ({
|
|
|
207
206
|
created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
208
207
|
skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
|
|
209
208
|
session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
|
|
210
|
-
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
|
|
209
|
+
machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
|
|
211
210
|
});
|
|
212
211
|
|
|
213
212
|
/** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
|
|
@@ -239,7 +238,6 @@ const metadataOf = (v) => {
|
|
|
239
238
|
export async function runWorkflow(argv) {
|
|
240
239
|
const args = parse(argv);
|
|
241
240
|
const [verb, ...rest] = args._;
|
|
242
|
-
if (args['review-only'] && args['no-review-only']) throw new Error('Choose review-only or no-review-only, not both.');
|
|
243
241
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
244
242
|
const root = homeOf(args);
|
|
245
243
|
const slug = boardOf(root, args);
|
|
@@ -282,7 +280,7 @@ export async function runWorkflow(argv) {
|
|
|
282
280
|
const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug, compatPath: paths.sqlite });
|
|
283
281
|
migrated.close();
|
|
284
282
|
} finally { source.close(); }
|
|
285
|
-
renameSync(`${preparing}.
|
|
283
|
+
renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
|
|
286
284
|
renameSync(preparing, paths.ztrack);
|
|
287
285
|
say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
|
|
288
286
|
return 0;
|
|
@@ -298,7 +296,6 @@ export async function runWorkflow(argv) {
|
|
|
298
296
|
|
|
299
297
|
case 'create': {
|
|
300
298
|
need(1, 'a title');
|
|
301
|
-
const { lanes, settings } = await laneState(root);
|
|
302
299
|
const maxRetries = args['max-retries'] !== undefined ? Number(args['max-retries']) : null;
|
|
303
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`);
|
|
304
301
|
const meta = boardMeta(root, slug);
|
|
@@ -310,22 +307,19 @@ export async function runWorkflow(argv) {
|
|
|
310
307
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
311
308
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
312
309
|
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
|
|
313
|
-
noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
|
|
310
|
+
noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
|
|
314
311
|
every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
315
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,
|
|
316
313
|
}), true);
|
|
317
314
|
let answer = { id: created.id, existing: created.existing };
|
|
318
315
|
if (args.adopt) {
|
|
319
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);
|
|
320
318
|
const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
|
|
321
319
|
answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
|
|
322
|
-
} else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
|
|
323
|
-
const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
|
|
324
|
-
const started = report.started.find(([t]) => t === created.id);
|
|
325
|
-
const queued = report.queued.find(([t]) => t === created.id);
|
|
326
|
-
const failed = report.failed.find(([t]) => t === created.id);
|
|
327
|
-
answer = { ...answer, ...(started ? { started: true, session: started[1], mode: started[2] } : {}), ...(queued ? { started: false, waits: queued[1] } : {}), ...(failed ? { started: false, failed: failed[1] } : {}) };
|
|
328
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.
|
|
329
323
|
// the creator hears what happens from here on; a start answered above is not told twice
|
|
330
324
|
const address = args['no-notify'] ? null : args.notify ?? callerAddress();
|
|
331
325
|
if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
|
|
@@ -552,7 +546,7 @@ export async function runWorkflow(argv) {
|
|
|
552
546
|
case 'complete': {
|
|
553
547
|
need(1, 'a card id');
|
|
554
548
|
const metadata = metadataOf(args.metadata);
|
|
555
|
-
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 }));
|
|
556
550
|
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
557
551
|
}
|
|
558
552
|
case 'edit': {
|
|
@@ -637,8 +631,8 @@ export async function runWorkflow(argv) {
|
|
|
637
631
|
if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
|
|
638
632
|
const results = [];
|
|
639
633
|
for (const t of targets) {
|
|
640
|
-
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined
|
|
641
|
-
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() }));
|
|
642
636
|
results.push({ id: t, specified: true, status: moved.to });
|
|
643
637
|
} else if (verb === 'decompose' && args.child?.length) {
|
|
644
638
|
const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
|
|
@@ -1223,6 +1217,10 @@ async function serve(root, args = {}) {
|
|
|
1223
1217
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1224
1218
|
continue;
|
|
1225
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; }
|
|
1226
1224
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
1227
1225
|
let interval = 60;
|
|
1228
1226
|
let nextMailAt = Infinity;
|
package/board/dispatch.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Every side effect follows a committed transition: a card is claimed before its session is launched, and a
|
|
16
16
|
// subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
|
|
17
17
|
// launch and a crash between the two steps is answered by the next tick.
|
|
18
|
-
import { inspectCompletionEvidence,
|
|
18
|
+
import { inspectCompletionEvidence, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
21
21
|
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
@@ -333,13 +333,13 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
333
333
|
return;
|
|
334
334
|
}
|
|
335
335
|
// a run past its runtime limit (the status's `run.limits.runtime`), as Hermes times its workers out
|
|
336
|
-
if (limits.runtime && runRow &&
|
|
336
|
+
if (limits.runtime && runRow && runWorkingSeconds(runRow, t) > Number(limits.runtime)) {
|
|
337
337
|
observe('timed_out', { limit_seconds: limits.runtime, error: `ran past max_runtime ${limits.runtime}s` }, 'timed_out');
|
|
338
338
|
return;
|
|
339
339
|
}
|
|
340
340
|
if (session?.surface === 'headless') {
|
|
341
341
|
// Hermes's stale check: a worker running past the limit with no heartbeat in the last hour is stopped
|
|
342
|
-
if (limits.stale && runRow &&
|
|
342
|
+
if (limits.stale && runRow && runWorkingSeconds(runRow, t) >= Number(limits.stale) && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
|
|
343
343
|
observe('stale', { timeout_seconds: limits.stale, pid: session.pid, error: `no heartbeat after ${t - Number(runRow.started_at)}s running` }, 'reclaimed');
|
|
344
344
|
return;
|
|
345
345
|
}
|
|
@@ -690,7 +690,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
690
690
|
if (reading.alive) {
|
|
691
691
|
const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
|
|
692
692
|
if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
|
|
693
|
-
if (session
|
|
693
|
+
if (noticeHoldsClose(session, null)) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been filed`);
|
|
694
694
|
stopSession(board, session);
|
|
695
695
|
}
|
|
696
696
|
} else {
|
|
@@ -703,7 +703,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
703
703
|
if (observed.error) throw new Error(observed.error);
|
|
704
704
|
if (observed.live.has(session.pane)) {
|
|
705
705
|
const pendingNotice = view(board, (db) => db.prepare("SELECT id FROM supercode_effects WHERE json_extract(effect, '$.kind')='session_notice' AND json_extract(effect, '$.runId')=? LIMIT 1").get(row.id));
|
|
706
|
-
if (pendingNotice
|
|
706
|
+
if (noticeHoldsClose(session, pendingNotice)) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been filed: ${session.lifecycle_notice_error ?? 'pending'}`);
|
|
707
707
|
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
|
|
708
708
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
709
709
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
@@ -744,7 +744,6 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
744
744
|
if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
|
|
745
745
|
const runRow = currentRun(db, id);
|
|
746
746
|
const session = runRow?.metadata?.supercode;
|
|
747
|
-
if (session?.wait_started_at != null) return;
|
|
748
747
|
if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
|
|
749
748
|
if (session.surface === 'native' && fleet.native?.get(addressOf(session))?.alive !== true) return;
|
|
750
749
|
const live = fleet.sessions.find((x) => x.address === addressOf(session));
|
|
@@ -814,26 +813,31 @@ export async function sessionNotice(board, e) {
|
|
|
814
813
|
try {
|
|
815
814
|
if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
816
815
|
receipt = await send(boardAddress(board.label), board.label, e.address, e.text, effectMessageKey(e));
|
|
817
|
-
|
|
816
|
+
// A notice filed in the session's mailbox is done: the session reads its mail at its next
|
|
817
|
+
// turn, and a resumed session first. Only a notice no mailbox took is retried.
|
|
818
|
+
if (!receipt.delivered && !receipt.queued) error = receipt.reason ?? 'notice was not filed'; }
|
|
818
819
|
catch (failure) { error = String(failure.message).slice(-500); }
|
|
819
820
|
tx(board, (db) => {
|
|
820
821
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
821
822
|
if (session) setRunSession(db, e.runId, { ...session,
|
|
822
|
-
...(error ? { lifecycle_notice_error: error
|
|
823
|
-
|
|
823
|
+
...(error ? { lifecycle_notice_error: error, lifecycle_notice_failures: Number(session.lifecycle_notice_failures ?? 0) + 1 }
|
|
824
|
+
: { lifecycle_notice_error: null, lifecycle_notice_failures: 0, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
|
|
825
|
+
if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : receipt?.delivered ? 'session_notice_delivered' : 'session_notice_queued', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
|
|
824
826
|
});
|
|
825
827
|
return !error;
|
|
826
828
|
}
|
|
827
829
|
|
|
830
|
+
// A close waits for its run's lifecycle notice to be filed, but not past this many failed
|
|
831
|
+
// attempts: an unreachable session must not hold its pane, and its slot's next launch, forever.
|
|
832
|
+
const NOTICE_ATTEMPTS_BEFORE_CLOSE = 3;
|
|
833
|
+
const noticeHoldsClose = (session, pending) => Boolean(pending || session.lifecycle_notice_pending)
|
|
834
|
+
&& Number(session.lifecycle_notice_failures ?? 0) < NOTICE_ATTEMPTS_BEFORE_CLOSE;
|
|
835
|
+
|
|
828
836
|
/** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
|
|
829
837
|
export async function sessionEffect(board, e) {
|
|
830
838
|
if (e.kind === 'send_to_actor') {
|
|
831
839
|
const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
|
|
832
840
|
const session = json(row?.metadata, {})?.supercode;
|
|
833
|
-
if (session?.wait_started_at != null) {
|
|
834
|
-
tx(board, db => event(db, e.id, 'actor_notice_suppressed', { notice: e.durableId, reason: 'run is waiting', run_id: e.runId }, e.runId));
|
|
835
|
-
return true;
|
|
836
|
-
}
|
|
837
841
|
const address = addressOf(session);
|
|
838
842
|
let receipt;
|
|
839
843
|
try {
|
|
@@ -1078,6 +1082,10 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1078
1082
|
|
|
1079
1083
|
function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
|
|
1080
1084
|
const machine = normalMachine(task.machine);
|
|
1085
|
+
// Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
|
|
1086
|
+
// no machine default decides it.
|
|
1087
|
+
const window = workflowFor(board.root).params.session_window;
|
|
1088
|
+
if (typeof window !== 'boolean') throw unavailable('the workflow declares no params.session_window (true or false): whether a board session opens a window on its machine is the workflow\'s choice');
|
|
1081
1089
|
{
|
|
1082
1090
|
const made = resolveLocalWorkspace(board, task);
|
|
1083
1091
|
tx(board, (db) => {
|
|
@@ -1090,7 +1098,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1090
1098
|
if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
|
|
1091
1099
|
const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
|
|
1092
1100
|
const how = HARNESSES[harness];
|
|
1093
|
-
const base = ['open', '--new', how.program, '--detach', '--on', task.machine, '--cwd', task.workspace_path];
|
|
1101
|
+
const base = ['open', '--new', how.program, '--detach', `--window=${window}`, '--on', task.machine, '--cwd', task.workspace_path];
|
|
1094
1102
|
// the slot's last session: kept when live, resumed when lost, never another slot's (a reviewer's is its own)
|
|
1095
1103
|
// a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
|
|
1096
1104
|
// session starts (never two processes on one session)
|
|
@@ -1778,36 +1786,43 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
1778
1786
|
}
|
|
1779
1787
|
|
|
1780
1788
|
/** An opened PR asks for the declared review transition once the arc's tasks are closed. */
|
|
1789
|
+
// A task written in the card's own Tasks section that did not become a subtask holds its review too.
|
|
1781
1790
|
async function activatePullRequestReviews(board, entry, report, effects, { only = null } = {}) {
|
|
1782
1791
|
const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
1783
|
-
WHERE c.subtask_of IS NULL AND
|
|
1792
|
+
WHERE c.subtask_of IS NULL AND c.every IS NULL
|
|
1784
1793
|
AND t.workspace_kind!='scratch' ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
|
|
1785
1794
|
for (const { id } of candidates) {
|
|
1786
1795
|
const eligible = db => {
|
|
1787
1796
|
const task = getTask(db, id);
|
|
1788
1797
|
if (!task) return false;
|
|
1789
1798
|
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1799
|
+
if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform')) ||
|
|
1800
|
+
scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
|
|
1801
|
+
// The card's Tasks are its subtasks; an open one holds the review, and says so.
|
|
1802
|
+
const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status));
|
|
1803
|
+
return open.length ? open : true;
|
|
1793
1804
|
};
|
|
1794
|
-
|
|
1805
|
+
const ready = view(board, eligible);
|
|
1806
|
+
if (!ready) continue;
|
|
1807
|
+
if (ready !== true) { report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
|
|
1795
1808
|
try {
|
|
1796
|
-
const payload =
|
|
1797
|
-
|
|
1809
|
+
const payload = openReviewPullRequests(board, id);
|
|
1810
|
+
// The arc's open PRs ask for one review only while some head has no verdict: after changes are
|
|
1811
|
+
// requested the next run is the implementer's, until a new head is pushed.
|
|
1812
|
+
if (!payload || !reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) continue;
|
|
1798
1813
|
const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
1799
1814
|
if (!evidence.ready) throw new Error(evidence.reason);
|
|
1800
1815
|
refreshCardInputs(board, id, { commits: true });
|
|
1801
1816
|
const moved = tx(board, db => {
|
|
1802
1817
|
const task = getTask(db, id);
|
|
1803
|
-
if (
|
|
1818
|
+
if (eligible(db) !== true) return null;
|
|
1804
1819
|
return withCompletionEvidence(board, id, evidence, () => fire(db, board, entry, id, 'request_review', {
|
|
1805
|
-
roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, summary: `Opened
|
|
1820
|
+
roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, summary: `Opened ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}`, ci: evidence }
|
|
1806
1821
|
}));
|
|
1807
1822
|
});
|
|
1808
1823
|
if (moved) {
|
|
1809
1824
|
effects.push(...moved.effects);
|
|
1810
|
-
(report.review_requested ??= []).push({ id,
|
|
1825
|
+
(report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
|
|
1811
1826
|
}
|
|
1812
1827
|
} catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
|
|
1813
1828
|
}
|