@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 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 = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft'])); }
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. Before any verdict: while some head has none.
126
- * 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
127
- * closed, does not by itself bring the arc back: after changes are requested the next run is the implementer's. */
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.some((p) => !judgedHead(board, id, p.head));
130
- const last = board.db.prepare(`SELECT r.payload FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
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 unjudged;
175
+ if (!last) return true;
134
176
  const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
135
- return unjudged && judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit));
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 = JSON.parse(run('gh', ['pr', 'view', w.pr.url, '--repo', w.repository, '--json', 'state,headRefOid']));
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 = JSON.parse(run('gh', ['pr', 'view', url, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft']));
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.`);
@@ -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
- if (JSON.stringify(json(previous?.payload, {})) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
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 || !reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) continue;
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.150",
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": {