@volter/supercode-orchestrator 0.5.201 → 0.5.203
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/README.md +6 -0
- package/board/ci.mjs +68 -26
- package/board/cli.mjs +7 -2
- package/board/dispatch.mjs +31 -8
- package/board/engine.mjs +4 -1
- package/board/expr.mjs +39 -0
- package/board/workflow.mjs +24 -9
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -51,6 +51,12 @@ groups retained wire attempts by caller and endpoint, includes their purposes
|
|
|
51
51
|
and outcomes, and shows the latest 32 response-header receipts. Unknown transport
|
|
52
52
|
completion remains counted. Tokens and response bodies are not retained.
|
|
53
53
|
|
|
54
|
+
Board actions check their local role, guard and validator rules before GitHub.
|
|
55
|
+
Fresh source observations are reused only within that action, with at most eight
|
|
56
|
+
concurrent REST operations per process. Owner receipts retain the return code and
|
|
57
|
+
redacted refusal even when a failed call is fast. See
|
|
58
|
+
[acceptance latency readings](../../docs/evidence/board-acceptance-latency-2026-10-14.md).
|
|
59
|
+
|
|
54
60
|
Board discovery caches advisory reads while waiting. Review requests, verdicts
|
|
55
61
|
and acceptance refresh the complete source set, including previously draft or
|
|
56
62
|
closed declared sources, and validate its current heads. An unread set defers
|
package/board/ci.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
|
|
|
4
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 { AsyncLocalStorage } from 'node:async_hooks';
|
|
7
8
|
import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
8
9
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
9
10
|
import { githubFetch } from './github-rest.mjs';
|
|
@@ -21,17 +22,51 @@ async function githubToken() {
|
|
|
21
22
|
}
|
|
22
23
|
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
|
|
23
24
|
* read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
|
|
25
|
+
const actionReads = new AsyncLocalStorage();
|
|
26
|
+
/** Fresh observations belong to one action only; callers and later actions never share this snapshot. */
|
|
27
|
+
export const withFreshReviewReads = (fn) => actionReads.getStore() ? fn() : actionReads.run(new Map(), fn);
|
|
24
28
|
async function github(path, all = false, { purpose = 'validation' } = {}) {
|
|
29
|
+
const reads = purpose === 'validation' && actionReads.getStore();
|
|
30
|
+
if (!reads) return githubUncached(path, all, { purpose });
|
|
31
|
+
const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${all}\0${path}`;
|
|
32
|
+
if (!reads.has(key)) reads.set(key, githubUncached(path, all, { purpose }));
|
|
33
|
+
return reads.get(key);
|
|
34
|
+
}
|
|
35
|
+
// Bound actual HTTP/body I/O across all concurrent actions and discovery in this process.
|
|
36
|
+
let activeReads = 0;
|
|
37
|
+
const waitingReads = [];
|
|
38
|
+
async function wireRead(fn) {
|
|
39
|
+
if (activeReads >= 8) await new Promise(resolve => waitingReads.push(resolve)); else activeReads++;
|
|
40
|
+
try { return await fn(); }
|
|
41
|
+
finally { const next = waitingReads.shift(); if (next) next(); else activeReads--; }
|
|
42
|
+
}
|
|
43
|
+
/** Preserve input order; after a failure drain active reads and issue no more work from this batch. */
|
|
44
|
+
async function readTogether(items, read) {
|
|
45
|
+
const out = new Array(items.length); let cursor = 0, failure;
|
|
46
|
+
await Promise.all(Array.from({ length: Math.min(8, items.length) }, async () => {
|
|
47
|
+
while (!failure && cursor < items.length) {
|
|
48
|
+
const i = cursor++;
|
|
49
|
+
try { out[i] = await read(items[i], i); } catch (error) { failure ??= error; }
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
if (failure) throw failure;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
async function githubUncached(path, all = false, { purpose = 'validation' } = {}) {
|
|
25
56
|
const read = async () => {
|
|
26
57
|
const token = await githubToken();
|
|
27
58
|
let url = githubBase() + path, out = [];
|
|
28
59
|
for (let page = 0; url && page < 10; page += 1) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
const pageRead = await wireRead(async () => {
|
|
61
|
+
const r = await githubFetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) }, { purpose });
|
|
62
|
+
if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
|
|
63
|
+
return { body: await r.json(), next: /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null };
|
|
64
|
+
});
|
|
65
|
+
if (pageRead.error) return pageRead;
|
|
66
|
+
const body = pageRead.body;
|
|
32
67
|
if (!all) return { body };
|
|
33
68
|
out = out.concat(body);
|
|
34
|
-
url =
|
|
69
|
+
url = pageRead.next;
|
|
35
70
|
}
|
|
36
71
|
return { body: out };
|
|
37
72
|
};
|
|
@@ -80,9 +115,11 @@ export async function mergeReviewedPullRequest(url, reviewed) {
|
|
|
80
115
|
const token = await githubToken();
|
|
81
116
|
let answer;
|
|
82
117
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
answer = await wireRead(async () => {
|
|
119
|
+
const r = await githubFetch(`${githubBase()}/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, { method: 'PUT', headers: githubHeaders(token, { 'content-type': 'application/json' }), body: JSON.stringify({ sha: reviewed, merge_method: 'merge' }), signal: AbortSignal.timeout(60_000) }, { purpose: 'mutation' });
|
|
120
|
+
const text = await r.text();
|
|
121
|
+
return { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
|
|
122
|
+
});
|
|
86
123
|
} catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
|
|
87
124
|
if (answer.status === 200 && answer.body?.merged) return { merged: true, sha: answer.body.sha };
|
|
88
125
|
if (answer.status === 409) return { moved: true, head: null, message: answer.body?.message ?? null };
|
|
@@ -267,8 +304,9 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
267
304
|
let branch = task?.branch_name ?? (current || null);
|
|
268
305
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
269
306
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
270
|
-
const
|
|
271
|
-
|
|
307
|
+
const repositoriesToRead = [...new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))];
|
|
308
|
+
const groups = await readTogether(repositoriesToRead, async repository => {
|
|
309
|
+
const prs = [];
|
|
272
310
|
// Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
|
|
273
311
|
let list;
|
|
274
312
|
try {
|
|
@@ -280,7 +318,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
280
318
|
list = fresh ? await read() : await discoveryList(pullRequestLists, repository, 300_000, read);
|
|
281
319
|
} catch (error) {
|
|
282
320
|
if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
283
|
-
|
|
321
|
+
return []; // An unread sibling stays unread: do not cache an empty successful result.
|
|
284
322
|
}
|
|
285
323
|
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
286
324
|
if (repository === ownRepository && branch) {
|
|
@@ -296,25 +334,26 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
296
334
|
...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
|
|
297
335
|
...registered.filter(r => r.repository === repository).map(r => r.branch),
|
|
298
336
|
])];
|
|
299
|
-
|
|
300
|
-
if (headBranch === list.defaultBranch)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
337
|
+
const histories = await readTogether(historical, async headBranch => {
|
|
338
|
+
if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
|
|
339
|
+
return (await mergedBranchPullRequests(repository, headBranch, { fresh })).filter(pr =>
|
|
340
|
+
!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
|
|
341
|
+
&& pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid));
|
|
342
|
+
});
|
|
343
|
+
for (const history of histories) for (const pr of history) {
|
|
344
|
+
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
307
345
|
}
|
|
308
|
-
|
|
346
|
+
return prs;
|
|
347
|
+
});
|
|
348
|
+
const prs = groups.flat();
|
|
309
349
|
// A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
|
|
310
350
|
// discovery cannot see; it never hides what discovery found. Only repositories the card's workspace or registered
|
|
311
351
|
// branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
|
|
312
352
|
// declared one, so a push after the declaration is reviewed instead of failing every tick.
|
|
313
353
|
const declaration = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
314
354
|
const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const pr = await (fresh ? readPullRequest(source.url) : discoverPullRequest(source.url));
|
|
355
|
+
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
|
|
356
|
+
for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
|
|
318
357
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
319
358
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
320
359
|
}
|
|
@@ -423,6 +462,9 @@ export function judgedHead(board, id, head) {
|
|
|
423
462
|
}
|
|
424
463
|
|
|
425
464
|
export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
465
|
+
return withFreshReviewReads(() => inspectCurrentCompletionEvidence(board, id, entry, payload, stage));
|
|
466
|
+
}
|
|
467
|
+
async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage) {
|
|
426
468
|
const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
|
|
427
469
|
try {
|
|
428
470
|
if (stage === 'completion' || stage === 'acceptance') {
|
|
@@ -442,10 +484,10 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
442
484
|
const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
|
|
443
485
|
const wanted = candidatePullRequests(candidate);
|
|
444
486
|
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.');
|
|
445
|
-
|
|
487
|
+
await readTogether(wanted, async w => {
|
|
446
488
|
const pr = await readPullRequest(w.pr.url);
|
|
447
489
|
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.`);
|
|
448
|
-
}
|
|
490
|
+
});
|
|
449
491
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
450
492
|
}
|
|
451
493
|
return state;
|
|
@@ -458,7 +500,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
458
500
|
// Merged source is immutable and needs no surviving workspace. Resolve an
|
|
459
501
|
// unmerged candidate's checkout as before; a broken legacy path never
|
|
460
502
|
// prevents a verdict on already merged commits.
|
|
461
|
-
const read = await
|
|
503
|
+
const read = await readTogether(urls, readPullRequest);
|
|
462
504
|
const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
|
|
463
505
|
const own = mergedOnly ? null : await repositoryWorkspace(board, id);
|
|
464
506
|
if (!own && !urls.length) {
|
|
@@ -482,7 +524,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
482
524
|
: payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
483
525
|
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.');
|
|
484
526
|
if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
|
|
485
|
-
const prs = await
|
|
527
|
+
const prs = await readTogether(urls, (url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
|
|
486
528
|
if (stage === 'request_review') {
|
|
487
529
|
// A review is asked for only while some PR has a head no verdict has judged.
|
|
488
530
|
if (!await 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.');
|
package/board/cli.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { taskStatusOf } from './published.mjs';
|
|
|
14
14
|
import { executionOf } from './session-work.mjs';
|
|
15
15
|
import { listCardsInTurns, writeCardList } from './listing.mjs';
|
|
16
16
|
import { writeOwnerAnswer } from './reply.mjs';
|
|
17
|
+
import { redactText } from '@volter/teams/redact';
|
|
17
18
|
import { createServer } from 'node:net';
|
|
18
19
|
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
19
20
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
@@ -1611,10 +1612,14 @@ async function answerOne(root, line) {
|
|
|
1611
1612
|
const began = performance.now();
|
|
1612
1613
|
const before = slowLog.processCounters?.() ?? null;
|
|
1613
1614
|
let named = null, by = null;
|
|
1614
|
-
|
|
1615
|
+
let answer;
|
|
1616
|
+
try { answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); return answer; }
|
|
1615
1617
|
finally {
|
|
1616
1618
|
const ms = performance.now() - began;
|
|
1617
|
-
if (named
|
|
1619
|
+
if (named) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, force: answer?.code !== 0,
|
|
1620
|
+
outcome: answer ? answer.code === 0 ? 'ok' : `exit ${answer.code}` : 'unknown', slowest: null,
|
|
1621
|
+
extra: { caller: by, response: { code: answer?.code ?? null, stderr: redactText([].concat(answer?.stderr ?? []).join('')).slice(0, 2000) },
|
|
1622
|
+
steps: slowLog.stepsWithin?.(began, performance.now()) ?? null, whole_process: before ? slowLog.processUse(before) : null } });
|
|
1618
1623
|
}
|
|
1619
1624
|
}
|
|
1620
1625
|
|
package/board/dispatch.mjs
CHANGED
|
@@ -28,7 +28,8 @@ import { localSessionDelegations } from '../agents.mjs';
|
|
|
28
28
|
import { creatorOf, sessionProgress } from './workflow.mjs';
|
|
29
29
|
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
30
30
|
let fleetRoot = null;
|
|
31
|
-
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
31
|
+
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
32
|
+
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
32
33
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
33
34
|
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
34
35
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
@@ -1936,6 +1937,7 @@ async function synchronizeAgents(board, report) {
|
|
|
1936
1937
|
}
|
|
1937
1938
|
}
|
|
1938
1939
|
for(const assignment of cardAgentAssignments(board.root)){
|
|
1940
|
+
await yieldToOwner();
|
|
1939
1941
|
const task=getTaskNow(board,assignment.target);
|
|
1940
1942
|
if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
|
|
1941
1943
|
else if(task)recordCardAgentAssignment(board,assignment);
|
|
@@ -2351,8 +2353,10 @@ const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_
|
|
|
2351
2353
|
* only while that claim is the card's latest.
|
|
2352
2354
|
*/
|
|
2353
2355
|
export async function completeAdoptions(board, fleet, settings, report, lanes = null) {
|
|
2354
|
-
const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at
|
|
2355
|
-
|
|
2356
|
+
const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at
|
|
2357
|
+
FROM (SELECT DISTINCT task_id FROM task_events) cards CROSS JOIN task_events e ON e.task_id=cards.task_id
|
|
2358
|
+
WHERE e.kind IN ('dispatch_held','adoption_claimed')
|
|
2359
|
+
AND ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
|
|
2356
2360
|
AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ${ADOPTION_ANSWERS})`).all());
|
|
2357
2361
|
for (const row of pending) {
|
|
2358
2362
|
// a request another round has claimed is that round's while its claim holds; a claim whose round died is open again
|
|
@@ -2788,6 +2792,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
2788
2792
|
await endWorkerSubscriptions(board, fleet, report);
|
|
2789
2793
|
const record = view(board, (db) => sessionRecord(db));
|
|
2790
2794
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
|
|
2795
|
+
await yieldToOwner();
|
|
2791
2796
|
if (stopping) break;
|
|
2792
2797
|
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
2793
2798
|
const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
|
|
@@ -2803,7 +2808,8 @@ async function notify(board, fleet, report, entry) {
|
|
|
2803
2808
|
} else if (ended) continue; // no fleet reading: nothing is filed unread to a session recorded as ended
|
|
2804
2809
|
const kinds = heardBy(entry, role);
|
|
2805
2810
|
if (!kinds.length) continue;
|
|
2806
|
-
|
|
2811
|
+
// The retained cursor resumes the next page; one native read never materializes the whole unread history.
|
|
2812
|
+
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
|
|
2807
2813
|
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
2808
2814
|
if (!events.length) continue;
|
|
2809
2815
|
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
|
|
@@ -2857,7 +2863,17 @@ async function notify(board, fleet, report, entry) {
|
|
|
2857
2863
|
// a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
|
|
2858
2864
|
// work is over, and its session may never run again), anyone's when it is archived
|
|
2859
2865
|
const over = task && (task.stored_as === 'archived' || (role === 'worker' && task.stored_as === 'done'));
|
|
2860
|
-
if (over
|
|
2866
|
+
if (over) tx(board, (db) => {
|
|
2867
|
+
const durable = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
|
|
2868
|
+
if (!durable) return;
|
|
2869
|
+
// A page's final event is not the end of the backlog. Check past the persisted cursor in the same
|
|
2870
|
+
// transaction as retirement, so another page (including the card's completion) is never abandoned.
|
|
2871
|
+
const remaining = db.prepare(`SELECT 1 FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) LIMIT 1`)
|
|
2872
|
+
.get(sub.task_id, durable.last_event_id, ...kinds);
|
|
2873
|
+
const current = getTask(db, sub.task_id);
|
|
2874
|
+
if (!remaining && current && (current.status === 'archived' || (role === 'worker' && current.status === 'done')))
|
|
2875
|
+
db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
|
|
2876
|
+
});
|
|
2861
2877
|
}
|
|
2862
2878
|
}
|
|
2863
2879
|
|
|
@@ -3184,10 +3200,17 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3184
3200
|
}
|
|
3185
3201
|
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3186
3202
|
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3187
|
-
|
|
3188
|
-
|
|
3203
|
+
const observed = await withFreshReviewReads(async () => {
|
|
3204
|
+
const candidate = await openReviewPullRequests(board, id, { fresh: true })
|
|
3205
|
+
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id) : null);
|
|
3206
|
+
const evidence = !candidate || candidate.waits ? null : candidate.no_code
|
|
3207
|
+
? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
|
|
3208
|
+
: await inspectCompletionEvidence(board, id, entry, candidate, 'request_review');
|
|
3209
|
+
return { candidate, evidence };
|
|
3210
|
+
});
|
|
3211
|
+
payload = observed.candidate;
|
|
3189
3212
|
if (!payload || payload.waits) { report.queued.push([id, `review waits: ${payload?.waits ?? 'the current source set has no review candidate'}`]); payload = null; continue; }
|
|
3190
|
-
const evidence =
|
|
3213
|
+
const evidence = observed.evidence;
|
|
3191
3214
|
if (!evidence.ready) throw new Error(evidence.reason);
|
|
3192
3215
|
// A dry run names the complete validated review a tick would request and writes nothing.
|
|
3193
3216
|
if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
|
package/board/engine.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspac
|
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
17
|
import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
18
18
|
import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
19
|
-
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
19
|
+
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
20
20
|
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
21
21
|
import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
|
|
22
22
|
|
|
@@ -432,6 +432,8 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
432
432
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
433
433
|
refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
|
|
434
434
|
await refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
435
|
+
if (['complete', 'request_review', 'request_state'].includes(name)) view(board, db => validateEvent(db, board, entry, id, name,
|
|
436
|
+
{ roles: roles ?? callerRoles(db, id), payload, extra, localOnly: true }));
|
|
435
437
|
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
436
438
|
if (evidencePhase) payload = { ...payload, ci: await observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
437
439
|
if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
|
|
@@ -459,6 +461,7 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
459
461
|
const prepared = new Map(await Promise.all(targets.map(async id => {
|
|
460
462
|
const roles = callerRoles(board.db, id);
|
|
461
463
|
const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
|
|
464
|
+
view(board, db => validateEvent(db, board, entry, id, 'request_state', { roles, payload, localOnly: true }));
|
|
462
465
|
const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
463
466
|
// An ordinary request mails a decision to the manager, not a completion.
|
|
464
467
|
// Keep a failed source observation in that mail. A reviewer recording an
|
package/board/expr.mjs
CHANGED
|
@@ -129,6 +129,45 @@ function compiled(text) {
|
|
|
129
129
|
|
|
130
130
|
const own = (o, k) => o !== null && o !== undefined && (typeof o === 'object' || typeof o === 'function') && Object.prototype.hasOwnProperty.call(o, k);
|
|
131
131
|
|
|
132
|
+
/** Whether an expression may read observed sources, including through derived card fields. Unknown access waits. */
|
|
133
|
+
export function needsSources(text, derived = {}) {
|
|
134
|
+
const visiting = new Set();
|
|
135
|
+
const field = (root, key) => {
|
|
136
|
+
if (key === 'ci') return true;
|
|
137
|
+
if (root !== 'card' || !own(derived, key)) return false;
|
|
138
|
+
if (visiting.has(key)) return true;
|
|
139
|
+
visiting.add(key);
|
|
140
|
+
const dependent = expression(derived[key]);
|
|
141
|
+
visiting.delete(key);
|
|
142
|
+
return dependent;
|
|
143
|
+
};
|
|
144
|
+
const root = e => e.k === 'name' && ['card', 'event'].includes(e.v);
|
|
145
|
+
const visit = e => {
|
|
146
|
+
switch (e.k) {
|
|
147
|
+
case 'lit': return false;
|
|
148
|
+
case 'name': return root(e); // passing the whole object can expose its sources
|
|
149
|
+
case 'get': return root(e.on)
|
|
150
|
+
? e.key.k !== 'lit' || field(e.on.v, e.key.v)
|
|
151
|
+
: visit(e.on) || visit(e.key);
|
|
152
|
+
case 'method': return (root(e.on)
|
|
153
|
+
? e.m === 'size' || field(e.on.v, e.m)
|
|
154
|
+
: visit(e.on)) || e.args.some(visit);
|
|
155
|
+
case 'list': return e.items.some(visit);
|
|
156
|
+
case 'call': return e.args.some(visit);
|
|
157
|
+
case 'macro': return visit(e.on) || visit(e.body);
|
|
158
|
+
case 'not': case 'neg': return visit(e.e);
|
|
159
|
+
case 'bin': return visit(e.l) || visit(e.r);
|
|
160
|
+
case 'if': return visit(e.c) || visit(e.a) || visit(e.b);
|
|
161
|
+
default: return true;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const expression = value => {
|
|
165
|
+
if (value === null || value === undefined || String(value).trim() === '') return false;
|
|
166
|
+
try { return visit(compiled(String(value))); } catch { return true; }
|
|
167
|
+
};
|
|
168
|
+
return expression(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
132
171
|
function run(e, scope) {
|
|
133
172
|
switch (e.k) {
|
|
134
173
|
case 'lit': return e.v;
|
package/board/workflow.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
14
14
|
import { randomBytes } from 'node:crypto';
|
|
15
15
|
import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
|
|
16
16
|
import { releaseAllSideSessions } from './side-sessions.mjs';
|
|
17
|
-
import { evaluate, holds, render } from './expr.mjs';
|
|
17
|
+
import { evaluate, holds, render, needsSources } from './expr.mjs';
|
|
18
18
|
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
19
19
|
import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
|
|
20
20
|
import { identifyNotice } from './notices.mjs';
|
|
@@ -383,7 +383,7 @@ function timeOf(db, task) {
|
|
|
383
383
|
function askedOf(id) { return mailboxAnswers.get(id)?.askedOf ?? null; }
|
|
384
384
|
|
|
385
385
|
/** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
|
|
386
|
-
export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy = false } = {}) {
|
|
386
|
+
export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy = false, deferSources = false } = {}) {
|
|
387
387
|
// Only read-only guards opt into demand reads. Mutation scopes materialize before any write,
|
|
388
388
|
// keeping `before` and run-history fields pinned to their original snapshot.
|
|
389
389
|
const once = (read) => { let done = false, value; return () => { if (!done) { value = read(); done = true; } return value; }; };
|
|
@@ -486,8 +486,10 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
486
486
|
Object.defineProperty(card, key, { get: read, enumerable: true, configurable: true });
|
|
487
487
|
if (!lazy) read();
|
|
488
488
|
}
|
|
489
|
-
|
|
490
|
-
|
|
489
|
+
const derived = loaded.get(board.root)?.workflow?.derived ?? {};
|
|
490
|
+
for (const [key, expr] of Object.entries(derived)) {
|
|
491
|
+
// Preflight neither evaluates nor exposes a derived source value from a stale/missing observation.
|
|
492
|
+
const value = deferSources && needsSources(expr, derived) ? undefined : evaluate(expr, { card, event, params, now: now() });
|
|
491
493
|
Object.defineProperty(card, key, { value, writable: true, enumerable: true, configurable: true });
|
|
492
494
|
}
|
|
493
495
|
return {
|
|
@@ -594,7 +596,7 @@ function declaredStatus(db, board, entry, task) {
|
|
|
594
596
|
* takes only a sender in one of its roles; `platform` for the dispatcher's own observations). Answers
|
|
595
597
|
* `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
|
|
596
598
|
*/
|
|
597
|
-
export function
|
|
599
|
+
export function validateEvent(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, localOnly = false } = {}) {
|
|
598
600
|
// A card ends with its reason: a cancel or archive that says nothing why is refused, whoever sends it, the manager
|
|
599
601
|
// included. The reason is all the next reader of an ended card has (an empty-reason cancel once ended an arc that had
|
|
600
602
|
// shipped half, and nothing on the card said so).
|
|
@@ -603,14 +605,14 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
603
605
|
// archive: that ends it the way the document already has, and is the one way a manager clears it.
|
|
604
606
|
if (db.boardErrors?.has(id) && !(/cannot change card removal/.test(db.boardErrors.get(id)) && ['cancel', 'archive'].includes(name) && roles.includes('manager'))) throw new Error(`${id}: ${db.boardErrors.get(id)}`);
|
|
605
607
|
const { workflow, params } = entry;
|
|
606
|
-
if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return
|
|
608
|
+
if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return null;
|
|
607
609
|
const task = cardOf(db, id);
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
+
const from = localOnly ? statusOf(db, task) : declaredStatus(db, board, entry, task);
|
|
611
|
+
if (localOnly && !workflow.statuses[from]) return null; // only fire adopts an imported status
|
|
610
612
|
const candidates = transitionsFor(workflow, from, name);
|
|
611
613
|
if (!candidates.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there`);
|
|
612
614
|
// `sender`: the roles the event was sent with (a validator can ask who is asking)
|
|
613
|
-
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
|
|
615
|
+
const scope = scopeOf(db, board, task, { params, event: payload, lazy: localOnly, deferSources: localOnly, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
|
|
614
616
|
const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
|
|
615
617
|
if (!allowed.length) {
|
|
616
618
|
const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
|
|
@@ -618,6 +620,10 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
618
620
|
? ` Ask the manager with workflow request-state ${id} --state ${state} --reason <why> --evidence <verdict-and-pointers>.` : '';
|
|
619
621
|
throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}${request}`);
|
|
620
622
|
}
|
|
623
|
+
// A source-dependent branch is undecidable before observation; preflight must not choose its fallback.
|
|
624
|
+
const needsSource = expr => needsSources(expr, workflow.derived);
|
|
625
|
+
// Other roles' guards also determine the refusal below; none may decide it using stale sources.
|
|
626
|
+
if (localOnly && candidates.some(t => needsSource(t.guard))) return null;
|
|
621
627
|
const taken = allowed.find((t) => holds(t.guard ?? null, scope));
|
|
622
628
|
// none of the sender's rules holds, but another role's does (one verb with a rule per case, as `set` has one per
|
|
623
629
|
// field): it is that role's to send
|
|
@@ -633,8 +639,17 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
633
639
|
none.noTransition = true; throw none;
|
|
634
640
|
}
|
|
635
641
|
for (const v of taken.validate ?? []) {
|
|
642
|
+
if (localOnly && needsSource(v.expr)) continue;
|
|
636
643
|
if (!holds(v.expr, scope)) { const reason = render(v.message, scope); const refusal = new Error(reason); refusal.refusal = reason; throw refusal; }
|
|
637
644
|
}
|
|
645
|
+
return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
649
|
+
const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
|
|
650
|
+
if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
|
|
651
|
+
const { task, from, taken, scope, beforeRun } = checked;
|
|
652
|
+
const { params } = entry;
|
|
638
653
|
const effects = [];
|
|
639
654
|
const to = take(db, board, entry, task, from, taken, scope, effects);
|
|
640
655
|
settle(db, board, entry, id, effects);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.203",
|
|
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": {
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@volter/supercode-teams": "^0.3.
|
|
31
|
+
"@volter/supercode-teams": "^0.3.335",
|
|
32
32
|
"@volter/supercode-terminal": "^0.3.8",
|
|
33
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
34
|
-
"@volter/teams": "0.2.
|
|
33
|
+
"@volter/supercode-harness-sdk": "^0.3.102",
|
|
34
|
+
"@volter/teams": "0.2.94",
|
|
35
35
|
"@volter/ztrack": "^2.4.23",
|
|
36
36
|
"yaml": "^2.9.0"
|
|
37
37
|
},
|