@volter/supercode-orchestrator 0.4.150 → 0.4.151
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 +65 -10
- package/board/dispatch.mjs +23 -3
- package/board/store.mjs +17 -1
- package/package.json +1 -1
package/board/ci.mjs
CHANGED
|
@@ -6,6 +6,43 @@ import { dirname, join, relative } from 'node:path';
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
|
|
8
8
|
|
|
9
|
+
// GitHub is read through its REST API with Node's own fetch, not the gh binary: Node takes a World's trust bundle and
|
|
10
|
+
// injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a review World's GitHub twin answers it, where gh (Go) trusts only
|
|
11
|
+
// the system keychain. The read runs in a child Node so the callers stay synchronous.
|
|
12
|
+
const GITHUB_READ = `
|
|
13
|
+
const [path, all] = process.argv.slice(1);
|
|
14
|
+
const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
|
|
15
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
16
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) };
|
|
17
|
+
let url = base + path, out = [];
|
|
18
|
+
for (let page = 0; url && page < 10; page += 1) {
|
|
19
|
+
const r = await fetch(url, { headers });
|
|
20
|
+
if (!r.ok) { process.stderr.write('GitHub ' + r.status + ' for ' + path + ': ' + (await r.text()).slice(0, 300)); process.exit(1); }
|
|
21
|
+
const body = await r.json();
|
|
22
|
+
if (all !== '1') { process.stdout.write(JSON.stringify(body)); process.exit(0); }
|
|
23
|
+
out = out.concat(body);
|
|
24
|
+
url = /<([^>]+)>;\\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(JSON.stringify(out));`;
|
|
27
|
+
let githubToken;
|
|
28
|
+
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login. */
|
|
29
|
+
function github(path, all = false) {
|
|
30
|
+
if (githubToken === undefined) {
|
|
31
|
+
githubToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
|
|
32
|
+
if (!githubToken) try { githubToken = run('gh', ['auth', 'token']) || null; } catch { githubToken = null; }
|
|
33
|
+
}
|
|
34
|
+
const env = githubToken ? { ...process.env, GH_TOKEN: githubToken } : process.env;
|
|
35
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_READ, path, all ? '1' : '0'], { env, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
36
|
+
if (result.status !== 0) throw new Error(`GitHub: ${(result.stderr || result.error?.message || 'read failed').trim().slice(-500)}`);
|
|
37
|
+
return JSON.parse(result.stdout);
|
|
38
|
+
}
|
|
39
|
+
const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
40
|
+
const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
41
|
+
/** A PR as the board reads it (gh's field names). */
|
|
42
|
+
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
43
|
+
headRefName: p.head.ref, baseRefName: p.base.ref, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
|
|
44
|
+
const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
45
|
+
|
|
9
46
|
const observations = new Map();
|
|
10
47
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
11
48
|
const run = (program, args, cwd) => {
|
|
@@ -104,7 +141,7 @@ export function openReviewPullRequests(board, id) {
|
|
|
104
141
|
let list = pullRequestLists.get(repository);
|
|
105
142
|
if (!list || Date.now() - list.at >= 60_000) {
|
|
106
143
|
let items = [];
|
|
107
|
-
try { items =
|
|
144
|
+
try { items = github(`/repos/${repository}/pulls?state=open&per_page=100`, true).map((p) => pullRequest(repository, p)); }
|
|
108
145
|
catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
|
|
109
146
|
list = { at: Date.now(), items };
|
|
110
147
|
if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
|
|
@@ -122,17 +159,35 @@ function knownRepositories() {
|
|
|
122
159
|
try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
|
|
123
160
|
}
|
|
124
161
|
|
|
125
|
-
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review
|
|
126
|
-
*
|
|
127
|
-
*
|
|
162
|
+
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
|
|
163
|
+
* Before any verdict every unjudged head is. After a verdict, a PR it judged with a new head is, and so is a head
|
|
164
|
+
* committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). A PR that
|
|
165
|
+
* joins with a head committed before the verdict is not: after changes are requested the next run is the
|
|
166
|
+
* implementer's until it pushes. */
|
|
128
167
|
export function reviewDue(board, id, prs) {
|
|
129
|
-
const unjudged = prs.
|
|
130
|
-
|
|
168
|
+
const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
|
|
169
|
+
if (!unjudged.length) return false;
|
|
170
|
+
const last = board.db.prepare(`SELECT r.payload, (SELECT MIN(v.created_at) FROM task_events v WHERE v.task_id = r.task_id
|
|
171
|
+
AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at
|
|
172
|
+
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
|
|
131
173
|
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)
|
|
132
174
|
ORDER BY r.id DESC LIMIT 1`).get(id);
|
|
133
|
-
if (!last) return
|
|
175
|
+
if (!last) return true;
|
|
134
176
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
135
|
-
|
|
177
|
+
if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
|
|
178
|
+
return unjudged.some((p) => (commitTime(p) ?? 0) > Number(last.verdict_at));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const commitTimes = new Map();
|
|
182
|
+
/** When a PR's head was committed (seconds), read from GitHub once per head; null when it cannot be read. */
|
|
183
|
+
function commitTime({ url, head }) {
|
|
184
|
+
if (commitTimes.has(head)) return commitTimes.get(head);
|
|
185
|
+
let at = null;
|
|
186
|
+
try {
|
|
187
|
+
at = Math.floor(Date.parse(github(`/repos/${repositoryOf(url)}/commits/${head}`).commit.committer.date) / 1000) || null;
|
|
188
|
+
} catch { /* unreadable: not counted as new */ }
|
|
189
|
+
commitTimes.set(head, at);
|
|
190
|
+
return at;
|
|
136
191
|
}
|
|
137
192
|
|
|
138
193
|
/** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
|
|
@@ -163,7 +218,7 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
|
|
|
163
218
|
const wanted = candidatePullRequests(candidate);
|
|
164
219
|
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.');
|
|
165
220
|
for (const w of wanted) {
|
|
166
|
-
const pr =
|
|
221
|
+
const pr = readPullRequest(w.pr.url);
|
|
167
222
|
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.`);
|
|
168
223
|
}
|
|
169
224
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
@@ -211,7 +266,7 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
211
266
|
// An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
|
|
212
267
|
// card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
|
|
213
268
|
// with nothing uncommitted.
|
|
214
|
-
const observed =
|
|
269
|
+
const observed = readPullRequest(url);
|
|
215
270
|
const path = originOf(own) === repository && run('git', ['branch', '--show-current'], own) === observed.headRefName ? own : branchCheckout(board, id, repository, observed.headRefName);
|
|
216
271
|
if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
|
|
217
272
|
if (observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { hostname, tmpdir, totalmem } from 'node:os';
|
|
|
23
23
|
import { basename, dirname, join } from 'node:path';
|
|
24
24
|
import { createHash, randomUUID } from 'node:crypto';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
|
-
import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
26
|
+
import { view, boardCopyOf, boardPath, claimer, copyRefusal, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
27
27
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
|
|
28
28
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
29
29
|
import { evaluate, holds, render } from './expr.mjs';
|
|
@@ -428,6 +428,7 @@ function limitsOf(db, board, entry, task, session) {
|
|
|
428
428
|
|
|
429
429
|
function stopSession(board, session) {
|
|
430
430
|
if (!session) return;
|
|
431
|
+
if (boardCopyOf()) throw copyRefusal('closes no session');
|
|
431
432
|
if (session.surface === 'headless' && pidAlive(session.pid)) {
|
|
432
433
|
// SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
|
|
433
434
|
const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
|
|
@@ -1354,6 +1355,7 @@ function resolveLocalWorkspace(board, task) {
|
|
|
1354
1355
|
*/
|
|
1355
1356
|
export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
|
|
1356
1357
|
if (!who) throw new Error('name the session to adopt');
|
|
1358
|
+
if (boardCopyOf()) throw copyRefusal('adopts no session');
|
|
1357
1359
|
tx(board, db => {
|
|
1358
1360
|
if (!getTask(db, id)) throw new Error(`no card ${id}`);
|
|
1359
1361
|
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
|
|
@@ -1442,6 +1444,7 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1442
1444
|
|
|
1443
1445
|
// a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
|
|
1444
1446
|
function deliverNow(board, to, body, key) {
|
|
1447
|
+
if (boardCopyOf()) throw copyRefusal('sends no notice to sessions');
|
|
1445
1448
|
const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key, subject: String(body).split('\n')[0].slice(0, 160),
|
|
1446
1449
|
body: `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)` };
|
|
1447
1450
|
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
|
|
@@ -1451,6 +1454,7 @@ function deliverNow(board, to, body, key) {
|
|
|
1451
1454
|
}
|
|
1452
1455
|
|
|
1453
1456
|
async function send(from, label, to, body, key, { queue = false, ask = false, subject } = {}) {
|
|
1457
|
+
if (boardCopyOf()) throw copyRefusal('sends no mail to sessions or managers');
|
|
1454
1458
|
const request = {
|
|
1455
1459
|
op: 'send', from, from_name: `${label} board`, to, queue, id: key, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
|
|
1456
1460
|
// a question the board waits on is answered by a reply; a notice is not
|
|
@@ -1563,11 +1567,20 @@ async function notify(board, fleet, report, entry) {
|
|
|
1563
1567
|
// a worker whose run has ended is not woken by a notice (a second session would start working the card beside
|
|
1564
1568
|
// the one the next run gets): the notice is filed in its mailbox, read when it next works
|
|
1565
1569
|
const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
|
|
1570
|
+
// An idle Claude session cannot hold a notice in its mailbox (it starts a turn for every message it is given), so
|
|
1571
|
+
// a notice that must not wake it waits, unsent, for the session's next work.
|
|
1572
|
+
if (!working && fleet.ok && fleet.sessions.some((s) => s.address === sub.chat_id && s.harness === 'claude-code' && s.status === 'idle')) {
|
|
1573
|
+
report.notices_waiting = (report.notices_waiting ?? 0) + 1;
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1566
1576
|
let cursor = Number(sub.last_event_id);
|
|
1567
1577
|
let failed = null;
|
|
1568
1578
|
try {
|
|
1579
|
+
// an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never mailed
|
|
1580
|
+
const maxAge = Number(entry.params.notice_max_age_seconds) || null;
|
|
1569
1581
|
for (const ev of events) {
|
|
1570
1582
|
const p = json(ev.payload, {}) ?? {};
|
|
1583
|
+
if (maxAge && now() - Number(ev.created_at) > maxAge) { cursor = ev.id; (report.stale_notices_passed ??= 0); report.stale_notices_passed += 1; continue; }
|
|
1571
1584
|
// nobody is told of their own act
|
|
1572
1585
|
if (p.by && p.by === sub.chat_id) { cursor = ev.id; continue; }
|
|
1573
1586
|
let text;
|
|
@@ -1593,7 +1606,8 @@ async function notify(board, fleet, report, entry) {
|
|
|
1593
1606
|
const kind = 'notice_failed';
|
|
1594
1607
|
const previous = db.prepare('SELECT payload FROM task_events WHERE task_id=? AND kind=? ORDER BY id DESC LIMIT 1').get(sub.task_id, kind);
|
|
1595
1608
|
const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
|
|
1596
|
-
|
|
1609
|
+
const { by: _, ...said } = json(previous?.payload, {}) ?? {};
|
|
1610
|
+
if (JSON.stringify(said) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
|
|
1597
1611
|
});
|
|
1598
1612
|
report.undelivered.push([sub.task_id, sub.chat_id, failed]);
|
|
1599
1613
|
}
|
|
@@ -1865,7 +1879,11 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
1865
1879
|
payload = openReviewPullRequests(board, id);
|
|
1866
1880
|
// The arc's open PRs ask for one review only while some head has no verdict: after changes are
|
|
1867
1881
|
// requested the next run is the implementer's, until a new head is pushed.
|
|
1868
|
-
if (!payload
|
|
1882
|
+
if (!payload) { report.queued.push([id, 'review waits: no open PR on its branch, a wt/<card> branch or a registered checkout']); continue; }
|
|
1883
|
+
if (!reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
1884
|
+
report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1869
1887
|
const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
1870
1888
|
if (!evidence.ready) throw new Error(evidence.reason);
|
|
1871
1889
|
refreshCardInputs(board, id, { commits: true });
|
|
@@ -1912,6 +1930,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1912
1930
|
const path = boardPath(root, slug);
|
|
1913
1931
|
const marker = `${path}.corrupt-seen`;
|
|
1914
1932
|
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
|
|
1933
|
+
// a copy of a home dispatches nothing: its runs, sessions and managers are the original's
|
|
1934
|
+
if (boardCopyOf()) { report.error = `skipped: ${copyRefusal('starts, supervises, adopts and mails nothing').message}`; out.push(report); continue; }
|
|
1915
1935
|
let board;
|
|
1916
1936
|
try {
|
|
1917
1937
|
board = openBoard(root, slug);
|
package/board/store.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
|
|
11
11
|
// native transaction boundary and retries an optimistic conflict.
|
|
12
12
|
import { randomBytes } from 'node:crypto';
|
|
13
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { hostname } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -142,7 +142,23 @@ export function layOut(db, { fresh }) {
|
|
|
142
142
|
* Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
|
|
143
143
|
* SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
|
|
144
144
|
*/
|
|
145
|
+
// A home records where it was made (`.board-origin` at its root). A copy of the home carries that record elsewhere and
|
|
146
|
+
// is a copy: it may be read and written, but it addresses no session and no manager, which are the original's.
|
|
147
|
+
let copyOfHome = null;
|
|
148
|
+
/** The original home's path when the board opened last is a copy of it; null for a home of its own. */
|
|
149
|
+
export const boardCopyOf = () => copyOfHome;
|
|
150
|
+
export const copyRefusal = (what) => new Error(`this board is a copy of the home at ${copyOfHome}; it ${what}, which belong to the original`);
|
|
151
|
+
function noteOrigin(root) {
|
|
152
|
+
if (!existsSync(root)) return;
|
|
153
|
+
const here = realpathSync(root), file = join(root, '.board-origin');
|
|
154
|
+
let origin = null;
|
|
155
|
+
try { origin = JSON.parse(readFileSync(file, 'utf8')).root ?? null; } catch { /* none yet */ }
|
|
156
|
+
if (!origin) { writeFileSync(file, `${JSON.stringify({ root: here })}\n`); origin = here; }
|
|
157
|
+
copyOfHome = origin === here ? null : origin;
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
|
|
161
|
+
noteOrigin(root);
|
|
146
162
|
const kept = backingOf(root, slug);
|
|
147
163
|
if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
148
164
|
if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.151",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|