@volter/supercode-orchestrator 0.5.218 → 0.5.220
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 +84 -23
- package/board/cli.mjs +6 -2
- package/board/dispatch.mjs +56 -5
- package/board/engine.mjs +20 -3
- package/board/generation-owner.mjs +29 -7
- package/board/github-rest.mjs +1 -1
- package/board/workflow.mjs +64 -9
- package/package.json +3 -3
package/board/ci.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from
|
|
|
5
5
|
import { dirname, join, relative } from 'node:path';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
|
-
import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
8
|
+
import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
9
9
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
10
|
import { githubFetch, githubDiscovery } from './github-rest.mjs';
|
|
11
11
|
import { cardHandleOf } from './store.mjs';
|
|
@@ -189,7 +189,8 @@ async function githubUncached(path, all = false, { purpose = 'validation' } = {}
|
|
|
189
189
|
throw Object.assign(new Error(`GitHub: ${result.error.trim().slice(-500)}${nativeCode ? ` (${nativeCode})` : ''}`, { cause: result.cause }), result.rateLimit
|
|
190
190
|
? { code: 'github_rate_limited', status: result.status, retry_at: result.rateLimit.retryAt, deferred: true }
|
|
191
191
|
: result.code ? { code: result.code, deferred: result.code === 'github_read_admission_full' }
|
|
192
|
-
: result.cause?.code ? { code: result.cause.code, deferred: result.cause.code === 'GITHUB_BUDGET' }
|
|
192
|
+
: result.cause?.code ? { code: result.cause.code, deferred: result.cause.code === 'GITHUB_BUDGET' }
|
|
193
|
+
: result.status ? { status: result.status } : {});
|
|
193
194
|
}
|
|
194
195
|
return result.body;
|
|
195
196
|
}
|
|
@@ -295,13 +296,19 @@ export const candidatePullRequests = (ci) => ci?.prs ?? (ci?.pr ? [{ repository:
|
|
|
295
296
|
// The repository a checkout declares (remote.origin.url), not where a URL rewrite sends its transport.
|
|
296
297
|
const originOf = async (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(await run('git', ['config', '--get', 'remote.origin.url'], path))?.[1] ?? null;
|
|
297
298
|
|
|
298
|
-
/** The card
|
|
299
|
-
|
|
300
|
-
async function repositoryWorkspace(board, id) {
|
|
299
|
+
/** The card whose workspace is the card's: the card itself, or a scratch subtask's arc; null for a scratch arc. */
|
|
300
|
+
function workspaceOwner(board, id) {
|
|
301
301
|
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);
|
|
302
302
|
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);
|
|
303
303
|
const owner = task?.workspace_kind === 'scratch' ? parent : task;
|
|
304
|
-
|
|
304
|
+
return !owner || owner.workspace_kind === 'scratch' ? null : owner;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The card's checkout as its machine answers for it (path, repository, branch), and whether it is on this machine:
|
|
308
|
+
* git runs only in a checkout here, and another machine's is known by what its door said. */
|
|
309
|
+
async function repositoryWorkspace(board, id) {
|
|
310
|
+
const owner = workspaceOwner(board, id);
|
|
311
|
+
if (!owner) return null;
|
|
305
312
|
// the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
|
|
306
313
|
const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
|
|
307
314
|
const here = isLocalWorkspace(workspace);
|
|
@@ -380,7 +387,7 @@ function checkoutPresent(path) {
|
|
|
380
387
|
/** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
|
|
381
388
|
* workspace and a branch, e.g. `create "<what>" --subtask-of <arc> --workspace worktree:<repository> --branch <name>`.
|
|
382
389
|
* Their PRs are the arc's whatever the branch is named. */
|
|
383
|
-
function registeredBranches(board, id) {
|
|
390
|
+
function registeredBranches(board, id, sources = arcSources(board.db, id)) {
|
|
384
391
|
// Keep the two keys in their own lookups. An OR across the joined tables makes SQLite scan every task.
|
|
385
392
|
// Exclude the arc from the second arm even if a malformed record names itself as its subtask.
|
|
386
393
|
const rows = board.db.prepare(`SELECT t.branch_name, t.workspace_kind, c.workspace_anchor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
@@ -392,8 +399,8 @@ function registeredBranches(board, id) {
|
|
|
392
399
|
if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
|
|
393
400
|
out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
|
|
394
401
|
}
|
|
395
|
-
// and each repository and branch the arc declared as its own (arcSources)
|
|
396
|
-
for (const declared of
|
|
402
|
+
// and each repository and branch the arc declared as its own (arcSources, or the declaration about to replace it)
|
|
403
|
+
for (const declared of sources) if (!out.some((r) => r.repository.toLowerCase() === declared.repository.toLowerCase() && r.branch === declared.branch)) out.push(declared);
|
|
397
404
|
return out;
|
|
398
405
|
}
|
|
399
406
|
|
|
@@ -407,6 +414,30 @@ export function arcSources(db, id) {
|
|
|
407
414
|
return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
|
|
408
415
|
}
|
|
409
416
|
|
|
417
|
+
/** The arc's repositories, the only ones its `--pr` declarations may name (ADR 0003): the card's own workspace's and each
|
|
418
|
+
* registered branch's (its tasks' and its `--source` declaration; `sources`, a declaration about to replace that one,
|
|
419
|
+
* stands in for it). Answers them lower-cased, with the workspace and registered branches they were read from. */
|
|
420
|
+
export async function arcRepositories(board, id, sources = arcSources(board.db, id)) {
|
|
421
|
+
const own = await repositoryWorkspace(board, id);
|
|
422
|
+
const registered = registeredBranches(board, id, sources).map(r => ({ ...r, repository: r.repository.toLowerCase() }));
|
|
423
|
+
const ownRepository = (own?.repository ?? (own?.here ? await originOf(own.path) : null))?.toLowerCase();
|
|
424
|
+
return { own, registered, ownRepository, repositories: new Set([ownRepository, ...registered.map(r => r.repository)].filter(Boolean)) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The arc's repositories the board's own records name, read without asking any machine or git: each registered branch's
|
|
428
|
+
* and the card's workspace anchor when it names a repository (not a path). A subset of arcRepositories, so a URL in it
|
|
429
|
+
* needs no workspace resolved (engine.comment); one outside it is checked against arcRepositories. */
|
|
430
|
+
export function recordedArcRepositories(board, id, sources = arcSources(board.db, id)) {
|
|
431
|
+
const owner = workspaceOwner(board, id), anchor = owner?.workspace_anchor;
|
|
432
|
+
let anchored = null;
|
|
433
|
+
try { anchored = anchor && !isWorkspacePath(anchor) ? plainWorkspace(owner.workspace_kind, anchor).repository : null; } catch { /* an unreadable anchor is resolved by its machine */ }
|
|
434
|
+
return new Set([anchored, ...registeredBranches(board, id, sources).map(r => r.repository)].filter(Boolean).map(r => r.toLowerCase()));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Each PR URL whose repository is not one of `repositories` (arcRepositories), with that repository as the URL names it. */
|
|
438
|
+
export const outsideArcRepositories = (urls, repositories) => urls.map(url => ({ url, repository: repositoryOf(url) }))
|
|
439
|
+
.filter(source => !repositories.has(source.repository.toLowerCase()));
|
|
440
|
+
|
|
410
441
|
/** Legacy branches carry the globally unique native id. Keyed handles are local to a board, so their branches
|
|
411
442
|
* count only by exact recorded repository/branch identity (openReviewPullRequests), never by a handle prefix. */
|
|
412
443
|
export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
|
|
@@ -440,12 +471,25 @@ export async function openReviewPullRequests(board, id, { fresh = false, checkWo
|
|
|
440
471
|
}
|
|
441
472
|
|
|
442
473
|
async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, refuseStacked = false, repositories = [], strict = false } = {}, scope = null) {
|
|
443
|
-
const own = await
|
|
444
|
-
|
|
445
|
-
|
|
474
|
+
const { own, registered, ownRepository, repositories: arcScope } = await arcRepositories(board, id);
|
|
475
|
+
// A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
|
|
476
|
+
// discovery cannot see; it never hides what discovery found. Its PRs are bounded by the arc's repositories (the card's
|
|
477
|
+
// workspace, its declared sources, its tasks' registered branches) and, here, each repository where the board finds the
|
|
478
|
+
// arc's own work branch (a wt/<card> PR in a sibling repository, discovered or declared). engine.comment refuses a PR
|
|
479
|
+
// outside the first three when it is declared; a declaration still outside all of them (stored before that refusal,
|
|
480
|
+
// or left outside later by a --source change, a cancelled or retargeted task, or a changed workspace) is refused here,
|
|
481
|
+
// by every review request, verdict and acceptance, as `undeclared`, naming the PR and its repository, never dropped
|
|
482
|
+
// (volter-ai/volter#134 left four t_ef2aa6ce review requests with no word). A PR the review already includes is never refused.
|
|
483
|
+
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);
|
|
484
|
+
const declared = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => typeof source?.url === 'string');
|
|
485
|
+
const undeclared = (outside) => refusalOf(outside.map(({ url, repository }) => refused('undeclared', url, `${url} is declared as a source of ${id}, but ${repository} is not one of the arc's repositories (its workspace, its declared sources, its tasks', or one holding its wt/${id} branch), so a declaration cannot name it: declare the repository on the arc: comment ${id} "<why>" --source ${repository}:<branch> (with every other source the arc changes), or declare the arc's PRs again without it (--pr none declares none).`)));
|
|
486
|
+
// A card with no repository of its own and none registered (scratch) has no discovery: every declared PR is outside.
|
|
487
|
+
if (!ownRepository && !registered.length) {
|
|
488
|
+
const outside = outsideArcRepositories(declared.map(source => source.url), arcScope);
|
|
489
|
+
if (outside.length) throw undeclared(outside);
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
446
492
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
447
|
-
const ownRepository = (own?.repository ?? (own?.here ? await originOf(own.path) : null))?.toLowerCase();
|
|
448
|
-
if (!ownRepository && !registered.length) return null;
|
|
449
493
|
const current = task?.branch_name ? null : own?.branch ?? (own?.here ? await run('git', ['branch', '--show-current'], own.path) : null);
|
|
450
494
|
let branch = task?.branch_name ?? (current || null);
|
|
451
495
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
@@ -462,7 +506,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
462
506
|
const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
|
|
463
507
|
.filter(Boolean).map(r => r.toLowerCase()));
|
|
464
508
|
const answered = answeredChanges(board, id);
|
|
465
|
-
const stacked = [];
|
|
509
|
+
const stacked = [], workRepositories = new Set();
|
|
466
510
|
const groups = await readTogether(repositoriesToRead, async repository => {
|
|
467
511
|
const prs = [];
|
|
468
512
|
const hasWorkBranches = repository === ownRepository || registered.some(r => r.repository === repository);
|
|
@@ -485,6 +529,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
485
529
|
// Include excluded drafts/other bases in the fresh scope: becoming ready
|
|
486
530
|
// must not let a sibling source escape the complete-set check.
|
|
487
531
|
if (list.items.some(pr => mine(repository, pr.headRefName))) reviewRepositories.add(repository);
|
|
532
|
+
// A repository holding the arc's own work branch is the arc's: a declared PR there is in scope.
|
|
533
|
+
if (list.items.some(pr => !pr.isCrossRepository && mine(repository, pr.headRefName))) workRepositories.add(repository);
|
|
488
534
|
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
489
535
|
if (repository === ownRepository && branch) {
|
|
490
536
|
if (branch === list.defaultBranch) branch = null;
|
|
@@ -516,14 +562,29 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
516
562
|
return prs;
|
|
517
563
|
});
|
|
518
564
|
const prs = groups.flat();
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
565
|
+
// Each declared PR discovery did not find is read and followed at its current head (GitHub's), never pinned to the
|
|
566
|
+
// declared one, so a push after the declaration is reviewed instead of failing every tick; one from a fork, closed, or
|
|
567
|
+
// an open draft is left out. One outside the arc's repositories is in scope only when it is itself on the arc's work
|
|
568
|
+
// branch in its own repository. One GitHub says is absent or denied (404, 403, 410) stays outside and is refused
|
|
569
|
+
// with the rest, unless another declared PR brings its repository into scope, where its read error is thrown as it
|
|
570
|
+
// is. Any other read failure (5xx, reset, timeout) is thrown as a transient failure, never refused as undeclared.
|
|
571
|
+
const inScope = new Set([...arcScope, ...workRepositories]);
|
|
572
|
+
const sources = declared.filter(source => !prs.some(known => known.url === source.url));
|
|
573
|
+
const read = await readTogether(sources, async source => {
|
|
574
|
+
const reading = fresh ? readPullRequest(source.url) : discoverPullRequest(source.url);
|
|
575
|
+
if (inScope.has(repositoryOf(source.url).toLowerCase())) return reading;
|
|
576
|
+
try { return await reading; } catch (error) {
|
|
577
|
+
if (error?.deferred || ![403, 404, 410].includes(error?.status)) throw error;
|
|
578
|
+
return { unread: error };
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
read.forEach((pr, i) => { if (!pr.unread && !pr.isCrossRepository && mine(repositoryOf(sources[i].url).toLowerCase(), pr.headRefName)) inScope.add(repositoryOf(sources[i].url).toLowerCase()); });
|
|
582
|
+
const outside = outsideArcRepositories(sources.map(source => source.url), inScope);
|
|
583
|
+
if (outside.length) throw undeclared(outside);
|
|
584
|
+
// Every source left unread is now in scope: its own read error is the answer.
|
|
585
|
+
const unread = read.find(pr => pr.unread);
|
|
586
|
+
if (unread) throw unread.unread;
|
|
587
|
+
for (const pr of read) {
|
|
527
588
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
528
589
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
529
590
|
}
|
package/board/cli.mjs
CHANGED
|
@@ -116,7 +116,7 @@ The board's verbs, with their flags:
|
|
|
116
116
|
set <id> <assignee|machine|model|every|reviewer> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
117
117
|
reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
|
|
118
118
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
119
|
-
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] [--source owner/repo:branch]... attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
119
|
+
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch]... attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
120
120
|
attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
|
|
121
121
|
complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
|
|
122
122
|
finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
|
|
@@ -164,7 +164,7 @@ And:
|
|
|
164
164
|
|
|
165
165
|
specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
|
|
166
166
|
specify <id> --workspace … --machine M: declares the workspace on M and moves the card there, never asking the machine it leaves.
|
|
167
|
-
comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; the latest declaration replaces the previous one. This records evidence; only the board starts its review.
|
|
167
|
+
comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; the latest declaration replaces the previous one, and --pr none alone replaces it with none. This records evidence; only the board starts its review.
|
|
168
168
|
comment <arc> TEXT --source owner/repo:branch...: the arc's implementer or the manager declares every repository and branch the arc changes, on the arc itself; its review discovers PRs from them and accepts --pr declarations in them, with no task registered there. The latest declaration is the complete set.
|
|
169
169
|
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.
|
|
170
170
|
request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
|
|
@@ -1962,6 +1962,7 @@ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--
|
|
|
1962
1962
|
say('activation: registered with the machine daemon; the first clean round follows');
|
|
1963
1963
|
// The loop's next admitted round supplies the serving receipt, also for a resumed predecessor.
|
|
1964
1964
|
generationServingDue = true;
|
|
1965
|
+
tellOwner({ type: 'routable', pid: process.pid, boards: listBoards(root) });
|
|
1965
1966
|
wakeGeneration();
|
|
1966
1967
|
}
|
|
1967
1968
|
}
|
|
@@ -2124,6 +2125,9 @@ async function serve(root, args = {}) {
|
|
|
2124
2125
|
await registerOwner();
|
|
2125
2126
|
onLocalReopen(registerOwner);
|
|
2126
2127
|
activationSaid('registered with the machine daemon');
|
|
2128
|
+
// Its writer epoch taken, work admitted and the home registered, this generation answers verbs: its owner routes
|
|
2129
|
+
// public requests to it now, and its first clean round below gates the activation, never the routing.
|
|
2130
|
+
if (inBoardGeneration && generationServingDue) tellOwner({ type: 'routable', pid: process.pid, boards: listBoards(root) });
|
|
2127
2131
|
let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
|
|
2128
2132
|
const listenerErrors = new Set();
|
|
2129
2133
|
const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
|
package/board/dispatch.mjs
CHANGED
|
@@ -52,11 +52,11 @@ import { basename, dirname, join } from 'node:path';
|
|
|
52
52
|
import { createHash, randomUUID } from 'node:crypto';
|
|
53
53
|
import { fileURLToPath } from 'node:url';
|
|
54
54
|
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
55
|
-
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
55
|
+
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
56
56
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
57
57
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
58
58
|
import { evaluate, holds, render } from './expr.mjs';
|
|
59
|
-
import { identifyNotice, numbered } from './notices.mjs';
|
|
59
|
+
import { cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
60
60
|
import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
|
|
61
61
|
import { pendingRequestMail } from './request-mail.mjs';
|
|
62
62
|
import {
|
|
@@ -1939,8 +1939,10 @@ export async function sessionEffect(board, e) {
|
|
|
1939
1939
|
if (e.kind === 'send_to_actor') {
|
|
1940
1940
|
const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
|
|
1941
1941
|
const session = json(row?.metadata, {})?.supercode;
|
|
1942
|
-
|
|
1943
|
-
|
|
1942
|
+
// an effect may name its recipient (a state request's requester, settleStateRequestAnswers); otherwise it is the
|
|
1943
|
+
// run's actor. A recipient in a person's custody is filed nothing, as the run's own detached session is not.
|
|
1944
|
+
if (view(board, db => e.address ? Boolean(personCustody(db, e.address)) || (addressOf(session) === e.address && detachedSession(db, session)) : detachedSession(db, session))) return true;
|
|
1945
|
+
const address = e.address ?? addressOf(session);
|
|
1944
1946
|
if (!address) throw new Error('actor session identity unavailable');
|
|
1945
1947
|
// filed in the actor's mailbox, which delivers it
|
|
1946
1948
|
const card = view(board, (db) => getTask(db, e.id));
|
|
@@ -3171,6 +3173,55 @@ function ensureSubscriptions(board, entry) {
|
|
|
3171
3173
|
});
|
|
3172
3174
|
}
|
|
3173
3175
|
|
|
3176
|
+
/**
|
|
3177
|
+
* Whether the session at `address` is known to have ended: one of card `taskId`'s runs carried it, and no run (of any
|
|
3178
|
+
* card) carrying it is unended or keeps it open after its run (retain_after_run, not since closed or handed on). One
|
|
3179
|
+
* session carries many runs and a kept session goes on asking after its run ends, so an ended run alone says nothing.
|
|
3180
|
+
*/
|
|
3181
|
+
function sessionEnded(db, taskId, address) {
|
|
3182
|
+
const runs = db.prepare("SELECT task_id, ended_at, metadata FROM task_runs WHERE json_extract(metadata, '$.supercode.address') = ? OR json_extract(metadata, '$.supercode.session_id') = ?")
|
|
3183
|
+
.all(address, address.split(':').at(-1)).map((r) => ({ ...r, session: json(r.metadata, {})?.supercode })).filter((r) => addressOf(r.session) === address);
|
|
3184
|
+
return runs.some((r) => r.task_id === taskId) && !runs.some((r) => r.ended_at == null
|
|
3185
|
+
|| (r.session.retain_after_run && !r.session.close_requested && !r.session.closed_at && !r.session.close_transferred_to));
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
/**
|
|
3189
|
+
* The manager's answer to a state request (its reply to the board's "State requests" question), granted or denied,
|
|
3190
|
+
* reaches the session that asked and stays on the card. A denial changes no card state, so before this the answer was
|
|
3191
|
+
* read from the mailbox only into the stall guard (card.state_request) and the requester went on reading its request
|
|
3192
|
+
* as pending. Each answer is recorded once as a `state_request_answered` event (the request, the answering message,
|
|
3193
|
+
* who sent it, its text and the state act the card took after the request, if any) and told to the requester, or the
|
|
3194
|
+
* card's live session (its current, unended run) when the request names none or the requester's session is known to
|
|
3195
|
+
* have ended (sessionEnded: run N asked and died, and the board relaunched the card as N+1), as an actor notice
|
|
3196
|
+
* through the effect queue (sessionEffect files it in that mailbox under a fixed key), queued in the transaction that
|
|
3197
|
+
* records it so neither lands without the other. A closed card with no live session gets the record alone. An answer
|
|
3198
|
+
* older than the workflow's notice_max_age_seconds, or whose age is unknown (no limit set, no reply time read), is
|
|
3199
|
+
* recorded, not mailed.
|
|
3200
|
+
*/
|
|
3201
|
+
function settleStateRequestAnswers(board, entry, report) {
|
|
3202
|
+
const maxAge = Number(entry?.params?.notice_max_age_seconds) || null;
|
|
3203
|
+
const self = boardAddress(board.label);
|
|
3204
|
+
tx(board, (db) => {
|
|
3205
|
+
for (const a of unrelayedStateAnswers(db, entry?.params?.notice_max_age_seconds)) {
|
|
3206
|
+
const outcome = a.act ? `the card took ${a.act} after the request` : 'the card\'s state is unchanged';
|
|
3207
|
+
const id = event(db, a.task_id, 'state_request_answered', { request_event: a.request_event, state: a.state, requester: a.requester,
|
|
3208
|
+
question: a.question, answer: a.answer, answered_by: a.answered_by, act: a.act, outcome, decision: String(a.text ?? '').slice(0, 4000) }, a.run_id);
|
|
3209
|
+
(report.state_answers ??= []).push([a.task_id, a.answer]);
|
|
3210
|
+
if (!maxAge || !Number(a.answered_at_ms) || Date.now() - Number(a.answered_at_ms) > maxAge * 1000) continue;
|
|
3211
|
+
const run = a.run_id ? db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ?').get(a.run_id) : null;
|
|
3212
|
+
const live = run && run.ended_at == null ? addressOf(json(run.metadata, {})?.supercode) : null;
|
|
3213
|
+
const requester = String(a.requester ?? '').startsWith('sc:') ? a.requester : null;
|
|
3214
|
+
const to = requester && (requester === live || !sessionEnded(db, a.task_id, requester)) ? requester : live;
|
|
3215
|
+
if (!to || to === self || to === a.answered_by) continue;
|
|
3216
|
+
const text = `${a.task_id}: your request-state ${a.state ?? ''} (${a.question}) was answered by ${a.answered_by ?? 'the manager'} in ${a.answer}; ${outcome}.\n${String(a.text ?? '').trim()}\nRead the card: supercode workflow show ${a.task_id} --root ${board.root}`;
|
|
3217
|
+
const effect = { kind: 'send_to_actor', id: a.task_id, runId: a.run_id, address: to, sourceEvent: id, verb: 'state_request_answered',
|
|
3218
|
+
messageKey: `board-state-answer-${a.task_id}-${id}`,
|
|
3219
|
+
text: identifyNotice({ id, kind: 'state_request_answered', run_id: a.run_id, created_at: now() }, numbered(boardKey(board.root, board.slug), cardNumber(db, a.task_id), text)) };
|
|
3220
|
+
db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
3221
|
+
}
|
|
3222
|
+
});
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3174
3225
|
/** The event kinds an audience hears (the workflow's notify). */
|
|
3175
3226
|
const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
|
|
3176
3227
|
|
|
@@ -4040,7 +4091,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
4040
4091
|
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
4041
4092
|
|
|
4042
4093
|
await readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
4043
|
-
await refreshRelationAnswers(board); phase('answers');
|
|
4094
|
+
await refreshRelationAnswers(board); settleStateRequestAnswers(board, entry, report); phase('answers');
|
|
4044
4095
|
const effects = [];
|
|
4045
4096
|
await completeAdoptions(board, fleet, settings, report, lanes); await reconnectContinuedSessions(board, fleet, entry, report); await reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); await recordBounces(board, fleet, entry, report); await supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); await closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
|
|
4046
4097
|
report.unsettled = [];
|
package/board/engine.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspac
|
|
|
19
19
|
import { DatabaseSync } from 'node:sqlite';
|
|
20
20
|
import { CARD_VISIBILITY_SQL } from './visibility.mjs';
|
|
21
21
|
import { view, actorOf, principalOf, boardKey, cardBranch, cardHandle, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
22
|
-
import { assertSourceReceipt, completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
22
|
+
import { arcRepositories, arcSources, assertSourceReceipt, completionPhase, outsideArcRepositories, inspectCompletionEvidence, observeCompletionEvidence, recordedArcRepositories, withCompletionEvidence } from './ci.mjs';
|
|
23
23
|
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
24
24
|
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
25
25
|
import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
|
|
@@ -803,14 +803,31 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
|
|
|
803
803
|
}
|
|
804
804
|
if (pr != null || pr_head != null) {
|
|
805
805
|
const urls = [].concat(pr ?? []), heads = [].concat(pr_head ?? []);
|
|
806
|
-
|
|
806
|
+
// `--pr none` alone declares no PR: the arc's declaration becomes the empty set (a stored declaration no longer in
|
|
807
|
+
// the arc's repositories is cleared this way when no PR in them remains to declare in its place).
|
|
808
|
+
const none = urls.length === 1 && urls[0] === 'none' && !heads.length;
|
|
809
|
+
if (!none && (!urls.length || urls.length !== heads.length)) throw new Error('Record every PR with --pr URL --pr-head SHA, one full head per URL, or declare none with --pr none alone.');
|
|
807
810
|
if (new Set(urls).size !== urls.length) throw new Error('Record each PR once.');
|
|
808
|
-
sources = urls.map((url, i) => {
|
|
811
|
+
sources = none ? [] : urls.map((url, i) => {
|
|
809
812
|
if (!/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url) || !/^[a-f0-9]{40}$/.test(heads[i])) throw new Error('A source declaration needs a GitHub PR URL and its full head SHA.');
|
|
810
813
|
return { url: url.replace(/\/$/, ''), head: heads[i] };
|
|
811
814
|
});
|
|
812
815
|
// Only the card's own implementer or the board's manager declares its sources; the author text is not identity.
|
|
813
816
|
if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager records its PR sources.`);
|
|
817
|
+
// A declared PR is in one of the arc's repositories (its workspace, its declared sources, its tasks'; a `--source`
|
|
818
|
+
// in this same comment replaces the arc's declared sources): the review reads no other, so one outside them is
|
|
819
|
+
// refused here and nothing is stored, never kept to be dropped from every review request (t_ef2aa6ce). The board's
|
|
820
|
+
// own records are read first; the workspace is resolved (its daemon, its machine, git) only for a URL still outside.
|
|
821
|
+
const declaredSources = branches ?? arcSources(board.db, id);
|
|
822
|
+
const urlsOf = (list) => list.map((s) => s.url);
|
|
823
|
+
let outside = outsideArcRepositories(urlsOf(sources), recordedArcRepositories(board, id, declaredSources));
|
|
824
|
+
if (outside.length) {
|
|
825
|
+
let repositories;
|
|
826
|
+
try { ({ repositories } = await arcRepositories(board, id, declaredSources)); }
|
|
827
|
+
catch (error) { throw new Error(`${id}'s workspace could not be read to check the repository of ${outside.map((o) => o.url).join(', ')} (a --pr declaration is checked against the arc's repositories): ${error.message}. Nothing was recorded.`); }
|
|
828
|
+
outside = outsideArcRepositories(urlsOf(outside), repositories);
|
|
829
|
+
}
|
|
830
|
+
if (outside.length) throw new Error(outside.map(({ url, repository }) => `${url} is in ${repository}, which is not one of ${id}'s repositories (its workspace, its declared sources, its tasks'): declare ${repository} first with comment ${id} "<why>" --source ${repository}:<branch> (with every other source the arc changes), then record its PRs. Nothing was recorded.`).join(' '));
|
|
814
831
|
}
|
|
815
832
|
const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}), ...(branches ? { branches } : {}) } });
|
|
816
833
|
return result.commentId;
|
|
@@ -128,13 +128,26 @@ export async function serveBoardOwner(root, args) {
|
|
|
128
128
|
const timer = setTimeout(() => generation.process.kill('SIGKILL'), 2000);
|
|
129
129
|
try { await generation.exited; } finally { clearTimeout(timer); }
|
|
130
130
|
};
|
|
131
|
+
// Whether every request already sent to a generation was answered (or the generation ended) within the bound.
|
|
132
|
+
// New requests are held at the door meanwhile, so nothing more is sent to it.
|
|
133
|
+
const drained = (generation, ms = 8000) => {
|
|
134
|
+
let timer;
|
|
135
|
+
return Promise.race([new Promise(done => {
|
|
136
|
+
const inspect = () => { if (!generation.calls.size || generation.ended) done(true); else waiting.add(inspect); }; inspect();
|
|
137
|
+
}), new Promise(done => { timer = setTimeout(() => done(false), ms); })]).finally(() => clearTimeout(timer));
|
|
138
|
+
};
|
|
131
139
|
const activate = async generation => {
|
|
132
140
|
if (stopping || generation.ended) throw new Error('cannot activate an ended board generation');
|
|
133
141
|
record(generation);
|
|
134
|
-
generation.events.delete('serving');
|
|
142
|
+
generation.events.delete('routable'); generation.events.delete('serving');
|
|
135
143
|
generation.tell({ type: 'activate' });
|
|
136
144
|
log(`generation ${generation.id} (pid ${generation.process.pid}) activates`);
|
|
137
|
-
|
|
145
|
+
// Public requests are routed once the successor holds its writer and answers its inventory and stats. Its first
|
|
146
|
+
// clean round, a full tick that reads other machines' doors, gates this activation's answer (and so the
|
|
147
|
+
// transaction's commit), never the routing. A generation that never says it is routable is routed when it serves.
|
|
148
|
+
const routable = generation.wait('routable'), served = generation.wait('serving');
|
|
149
|
+
routable.catch(() => {}); served.catch(() => {});
|
|
150
|
+
const serving = await Promise.any([routable, served]).catch(error => { throw error.errors?.at(-1) ?? error; });
|
|
138
151
|
const read = async argv => {
|
|
139
152
|
const proof = await generation.ask(JSON.stringify({ v: 1, argv, cwd: root,
|
|
140
153
|
facts: { SUPERCODE_CONTEXT: process.env.SUPERCODE_CONTEXT || 'local' } }), 8000);
|
|
@@ -154,6 +167,19 @@ export async function serveBoardOwner(root, args) {
|
|
|
154
167
|
if (generation.health) holdOwnerHealth(generation.health.boards, generation.health.round);
|
|
155
168
|
for (const [at, value] of generation.workflows) holdWorkflow(at, value);
|
|
156
169
|
changed();
|
|
170
|
+
log(`generation ${generation.id} routed (pid ${generation.process.pid})`);
|
|
171
|
+
// A first round that is not clean ends this activation. Requests are held from here, and those already sent to the
|
|
172
|
+
// child are answered by it, within the predecessor's drain bound, before the caller's rollback ends it.
|
|
173
|
+
try { await served; }
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (routing === generation) {
|
|
176
|
+
routing = null; changed();
|
|
177
|
+
const owed = generation.calls.size;
|
|
178
|
+
if (!await drained(generation)) log(`generation ${generation.id}: ${generation.calls.size} of ${owed} routed request(s) unanswered 8 s after its first round failed; their outcome is unknown`);
|
|
179
|
+
else log(`generation ${generation.id}: first round not clean; requests held, and ${owed} routed request(s) in flight answered before it is ended`);
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
157
183
|
log(`generation ${generation.id} serves (pid ${generation.process.pid})`);
|
|
158
184
|
return generation;
|
|
159
185
|
};
|
|
@@ -205,11 +231,7 @@ export async function serveBoardOwner(root, args) {
|
|
|
205
231
|
};
|
|
206
232
|
requireCandidate();
|
|
207
233
|
routing = null;
|
|
208
|
-
|
|
209
|
-
try { await Promise.race([new Promise(done => {
|
|
210
|
-
const inspect = () => { if (!held.predecessor.calls.size || held.predecessor.ended) done(); else waiting.add(inspect); }; inspect();
|
|
211
|
-
}), new Promise((_, reject) => { drainTimer = setTimeout(() => reject(new Error('board requests did not drain; predecessor retained')), 8000); })]); }
|
|
212
|
-
finally { clearTimeout(drainTimer); }
|
|
234
|
+
if (!await drained(held.predecessor)) throw new Error('board requests did not drain; predecessor retained');
|
|
213
235
|
requireCandidate();
|
|
214
236
|
held.phase = 'parking';
|
|
215
237
|
held.predecessor.events.delete('parked'); held.predecessor.tell({ type: 'park' });
|
package/board/github-rest.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlO
|
|
|
82
82
|
retain(JSON.stringify(value), null, ttlOf(value));
|
|
83
83
|
return value;
|
|
84
84
|
} catch (error) {
|
|
85
|
-
if (!fresh && cacheErrors) retain(null, JSON.stringify({ message: String(error.message).slice(0, 1000), code: error.code, deferred: error.deferred, retry_at: error.retry_at }));
|
|
85
|
+
if (!fresh && cacheErrors) retain(null, JSON.stringify({ message: String(error.message).slice(0, 1000), code: error.code, status: error.status, deferred: error.deferred, retry_at: error.retry_at }));
|
|
86
86
|
throw error;
|
|
87
87
|
} finally {
|
|
88
88
|
db.prepare('UPDATE github_discovery SET claim=NULL,claim_until=0 WHERE key=? AND claim=?').run(key, claim);
|
package/board/workflow.mjs
CHANGED
|
@@ -342,11 +342,15 @@ export async function refreshRelationAnswers(board) {
|
|
|
342
342
|
for (const row of board.db.prepare("SELECT e.payload FROM task_events e JOIN (SELECT task_id, MAX(id) id FROM task_events WHERE kind IN ('blocked','unblocked') GROUP BY task_id) last ON e.id=last.id JOIN tasks t ON t.id=e.task_id WHERE e.kind='blocked' AND t.status='blocked'").all()) {
|
|
343
343
|
const id = json(row.payload, {})?.waiting_on; if (id) ids.add(id);
|
|
344
344
|
}
|
|
345
|
-
// State requests stay live while the card runs, independently of a blocker
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
345
|
+
// State requests stay live while the card runs, independently of a blocker, and a card's latest request is read
|
|
346
|
+
// until its answer has been relayed (granted or denied, settleStateRequestAnswers), on a closed card too while the
|
|
347
|
+
// request is recent (stateRequestCards). Read the ids the questions were mailed under through the same mailbox
|
|
348
|
+
// receipt door.
|
|
349
|
+
for (const row of stateRequestCards(board.db, entry?.params?.notice_max_age_seconds)) {
|
|
350
|
+
const request = row.current_run_id != null && !row.closed ? stateRequestOf(board.db, row.id) : null;
|
|
349
351
|
if (request?.message_id) ids.add(request.message_id);
|
|
352
|
+
const latest = latestStateRequest(board.db, row.id);
|
|
353
|
+
if (latest?.question && !latest.relayed) ids.add(latest.question);
|
|
350
354
|
}
|
|
351
355
|
const list = [...ids];
|
|
352
356
|
if (!list.length) return;
|
|
@@ -356,8 +360,9 @@ export async function refreshRelationAnswers(board) {
|
|
|
356
360
|
if (!Array.isArray(rows)) throw new Error('the mail door returned no message list');
|
|
357
361
|
for (const id of list) {
|
|
358
362
|
const row = rows.find((item) => item?.id === id);
|
|
363
|
+
const reply = row?.replies?.[0] ?? null;
|
|
359
364
|
mailboxAnswers.set(id, row?.found === true
|
|
360
|
-
? { answer:
|
|
365
|
+
? { answer: reply?.id ?? null, askedOf: row.session ?? null, by: reply?.from ?? null, at_ms: reply?.created_at_ms ?? null, text: reply?.body ?? null }
|
|
361
366
|
: { error: `the mailbox did not find question ${id}; its answer is unknown` });
|
|
362
367
|
}
|
|
363
368
|
} catch (error) { for (const id of list) mailboxAnswers.set(id, { error: `the answer to ${id} could not be read: ${String(error.message).slice(0, 200)}` }); }
|
|
@@ -370,16 +375,66 @@ function answerTo(id) {
|
|
|
370
375
|
|
|
371
376
|
// A manager's state act settles the request; a mailbox reply answers it without
|
|
372
377
|
// changing the card. Unknown mailbox reads never assert an unanswered question.
|
|
373
|
-
|
|
378
|
+
const STATE_ACTS = "('paused','resumed','blocked','unblocked','completed','cancelled','review_pass','reopened')";
|
|
379
|
+
// A card's latest state request and the id its question was mailed under. Requests asked within one batch window are
|
|
380
|
+
// mailed as one message under the batch's first id (request-mail.mjs), so a later member's own id was never sent and
|
|
381
|
+
// its answer lands under the batch's: board_message_sent records which (`request_id` the member's, `message_id` the
|
|
382
|
+
// sent one). `relayed` once its answer is on the card (state_request_answered).
|
|
383
|
+
function latestStateRequest(db, id) {
|
|
374
384
|
const row = one(db, "SELECT id,payload,created_at FROM task_events WHERE task_id=? AND kind='state_requested' ORDER BY id DESC LIMIT 1", id);
|
|
375
|
-
if (!row
|
|
385
|
+
if (!row) return null;
|
|
376
386
|
const payload = json(row.payload, {}) ?? {};
|
|
377
|
-
const
|
|
387
|
+
const asked = payload.message_id ?? json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND id<? AND kind='board_message' AND json_extract(payload,'$.batch') IS NOT NULL ORDER BY id DESC LIMIT 1", id, row.id)?.payload, {})?.message_id;
|
|
388
|
+
const sent = asked ? one(db, "SELECT json_extract(payload,'$.message_id') AS id FROM task_events WHERE task_id=? AND id>? AND kind='board_message_sent' AND json_extract(payload,'$.request_id')=? ORDER BY id DESC LIMIT 1", id, row.id, asked)?.id : null;
|
|
389
|
+
const relayed = Boolean(one(db, "SELECT 1 FROM task_events WHERE task_id=? AND id>? AND kind='state_request_answered' AND json_extract(payload,'$.request_event')=? LIMIT 1", id, row.id, row.id));
|
|
390
|
+
return { row, payload, question: sent ?? asked ?? null, relayed };
|
|
391
|
+
}
|
|
392
|
+
function stateRequestOf(db, id) {
|
|
393
|
+
const latest = latestStateRequest(db, id);
|
|
394
|
+
if (!latest || one(db, `SELECT 1 FROM task_events WHERE task_id=? AND id>? AND kind IN ${STATE_ACTS} LIMIT 1`, id, latest.row.id)) return null;
|
|
395
|
+
const { row, payload, question } = latest;
|
|
378
396
|
const receipt = question ? mailboxAnswers.get(question) : null;
|
|
379
|
-
return { id: row.id, created_at: row.created_at, state: payload.state, reason: payload.reason, message_id: question
|
|
397
|
+
return { id: row.id, created_at: row.created_at, state: payload.state, reason: payload.reason, message_id: question,
|
|
380
398
|
answer_known: Boolean(receipt && !receipt.error), answer: receipt?.answer ?? null, error: receipt?.error ?? null };
|
|
381
399
|
}
|
|
382
400
|
|
|
401
|
+
/**
|
|
402
|
+
* The cards whose latest state request is still read: every open card, and a closed card (done, cancelled or archived:
|
|
403
|
+
* the manager may close it before a round reads the reply) whose latest request is younger than the workflow's
|
|
404
|
+
* notice_max_age_seconds, or STATE_ANSWER_WINDOW_SECONDS when it sets none, until its answer is recorded. A closed
|
|
405
|
+
* card's request older than that is no longer read, so an unanswered one is not read every round forever.
|
|
406
|
+
*/
|
|
407
|
+
const STATE_ANSWER_WINDOW_SECONDS = 3600;
|
|
408
|
+
function stateRequestCards(db, maxAgeSeconds) {
|
|
409
|
+
const since = now() - (Number(maxAgeSeconds) || STATE_ANSWER_WINDOW_SECONDS);
|
|
410
|
+
return all(db, `SELECT t.id, t.current_run_id, t.status IN ('done','cancelled','archived') AS closed FROM tasks t
|
|
411
|
+
LEFT JOIN (SELECT task_id, MAX(id) id, MAX(created_at) at FROM task_events WHERE kind = 'state_requested' GROUP BY task_id) r ON r.task_id = t.id
|
|
412
|
+
WHERE t.status NOT IN ('done','cancelled','archived')
|
|
413
|
+
OR (r.id IS NOT NULL AND r.at >= ?
|
|
414
|
+
AND NOT EXISTS (SELECT 1 FROM task_events a WHERE a.task_id = t.id AND a.id > r.id AND a.kind = 'state_request_answered' AND json_extract(a.payload,'$.request_event') = r.id))`, since);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Each card's latest state request (stateRequestCards) whose answer the mailbox has read and the card does not yet
|
|
419
|
+
* record: the manager's reply to the board's question, granted or denied, with who sent it, its text and the state
|
|
420
|
+
* act (if any) the card took after the request. The dispatcher records it and tells the requester (dispatch.mjs
|
|
421
|
+
* settleStateRequestAnswers); a denial changes no card state, so this reply is the only trace it leaves.
|
|
422
|
+
*/
|
|
423
|
+
export function unrelayedStateAnswers(db, maxAgeSeconds) {
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const task of stateRequestCards(db, maxAgeSeconds)) {
|
|
426
|
+
const latest = latestStateRequest(db, task.id);
|
|
427
|
+
if (!latest?.question || latest.relayed) continue;
|
|
428
|
+
const receipt = mailboxAnswers.get(latest.question);
|
|
429
|
+
if (!receipt?.answer) continue;
|
|
430
|
+
const act = one(db, `SELECT kind FROM task_events WHERE task_id=? AND id>? AND kind IN ${STATE_ACTS} ORDER BY id LIMIT 1`, task.id, latest.row.id)?.kind ?? null;
|
|
431
|
+
out.push({ task_id: task.id, run_id: task.current_run_id ?? null, request_event: latest.row.id,
|
|
432
|
+
state: latest.payload.state ?? null, requester: latest.payload.requester ?? null, question: latest.question,
|
|
433
|
+
answer: receipt.answer, answered_by: receipt.by ?? null, answered_at_ms: receipt.at_ms ?? null, text: receipt.text ?? null, act });
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
|
|
383
438
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
384
439
|
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
385
440
|
/** Card bodies use Markdown headings and standalone labels such as `Acceptance:` and `Tasks:`. Every label ends
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.220",
|
|
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": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@volter/supercode-teams": "^0.3.
|
|
39
|
+
"@volter/supercode-teams": "^0.3.376",
|
|
40
40
|
"@volter/supercode-terminal": "^0.3.12",
|
|
41
41
|
"@volter/supercode-harness-sdk": "^0.3.106",
|
|
42
|
-
"@volter/teams": "0.2.
|
|
42
|
+
"@volter/teams": "0.2.104",
|
|
43
43
|
"@volter/ztrack": "^2.4.23",
|
|
44
44
|
"yaml": "^2.9.0"
|
|
45
45
|
},
|