@volter/supercode-orchestrator 0.5.23 → 0.5.24
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/agents.mjs +15 -5
- package/board/ci.mjs +29 -0
- package/board/cli.mjs +79 -12
- package/board/dispatch.mjs +151 -16
- package/board/engine.mjs +5 -1
- package/board/event-fleet.mjs +1 -1
- package/board/git-document.mjs +3 -1
- package/board/store.mjs +1 -1
- package/board/workflow.mjs +26 -1
- package/board/workspace.mjs +7 -1
- package/board/ztrack.mjs +32 -222
- package/index.d.ts +1 -0
- package/package.json +2 -2
package/agents.mjs
CHANGED
|
@@ -31,6 +31,7 @@ function store(root){
|
|
|
31
31
|
mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'agent-state.sqlite'));
|
|
32
32
|
db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;
|
|
33
33
|
CREATE TABLE IF NOT EXISTS agent_intents(org TEXT NOT NULL,id TEXT NOT NULL,key TEXT NOT NULL,profile_ref TEXT NOT NULL,profile_json TEXT NOT NULL,declared INTEGER NOT NULL DEFAULT 0,declaration_json TEXT,created_at INTEGER NOT NULL,PRIMARY KEY(org,id),UNIQUE(org,key));
|
|
34
|
+
CREATE TABLE IF NOT EXISTS agent_run_retries(org TEXT NOT NULL,agent_id TEXT NOT NULL,session_ref TEXT NOT NULL,next_at INTEGER NOT NULL,wait_ms INTEGER NOT NULL,PRIMARY KEY(org,agent_id,session_ref));
|
|
34
35
|
CREATE TABLE IF NOT EXISTS agent_declaration_heads(org TEXT NOT NULL,key TEXT NOT NULL,agent_id TEXT NOT NULL,generation INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(org,key));
|
|
35
36
|
CREATE TABLE IF NOT EXISTS agent_assignments(org TEXT NOT NULL,id TEXT NOT NULL,agent_id TEXT NOT NULL,kind TEXT NOT NULL,target TEXT NOT NULL,role TEXT NOT NULL,state TEXT NOT NULL,started_at INTEGER NOT NULL,ended_at INTEGER,PRIMARY KEY(org,id));
|
|
36
37
|
CREATE INDEX IF NOT EXISTS agent_active_assignments ON agent_assignments(org,agent_id,state);
|
|
@@ -371,15 +372,22 @@ export function recordNativeAgentSession(root,runtimeRef,session) {
|
|
|
371
372
|
// is asked again after a growing wait (two minutes, doubling to an hour), not every round: each ask spends the shared
|
|
372
373
|
// credential's allowance. Anything else (the rate limit, a timeout, a 5xx, the local daemon's bind of a session just
|
|
373
374
|
// spawned) is not the run's own and is asked again next round.
|
|
374
|
-
|
|
375
|
-
|
|
375
|
+
// The wait is kept in this home's agent store, not in the asking process: a one-shot `workflow dispatch` asked again
|
|
376
|
+
// on every run when each process kept its own, and every ask spends the shared credential's allowance.
|
|
377
|
+
const retryOf=(root,org,row)=>{const db=store(root);try{return db.prepare('SELECT next_at,wait_ms FROM agent_run_retries WHERE org=? AND agent_id=? AND session_ref=?').get(org,row.agent_id,row.session_ref)??null;}finally{db.close();}};
|
|
376
378
|
const refused=error=>error?.status>=400&&error.status<500&&error.status!==408&&error.status!==429;
|
|
377
379
|
export function synchronizeAgentRun(root,org,row,{context,intended}={}) {
|
|
378
|
-
|
|
380
|
+
let answer;
|
|
381
|
+
try{answer=joinAgentRun(root,org,row,{context,intended});}
|
|
379
382
|
catch(error){
|
|
380
|
-
if(error?.claimRefused){
|
|
383
|
+
if(error?.claimRefused){
|
|
384
|
+
const wait=Math.min((retryOf(root,org,row)?.wait_ms??60000)*2,3600000),db=store(root);
|
|
385
|
+
try{db.prepare('INSERT INTO agent_run_retries(org,agent_id,session_ref,next_at,wait_ms) VALUES (?,?,?,?,?) ON CONFLICT(org,agent_id,session_ref) DO UPDATE SET next_at=excluded.next_at,wait_ms=excluded.wait_ms').run(org,row.agent_id,row.session_ref,Date.now()+wait,wait);}finally{db.close();}
|
|
386
|
+
}
|
|
381
387
|
throw error;
|
|
382
388
|
}
|
|
389
|
+
const db=store(root);try{db.prepare('DELETE FROM agent_run_retries WHERE org=? AND agent_id=? AND session_ref=?').run(org,row.agent_id,row.session_ref);}finally{db.close();}
|
|
390
|
+
return answer;
|
|
383
391
|
}
|
|
384
392
|
function joinAgentRun(root,org,row,{context,intended}) {
|
|
385
393
|
const claim=(...args)=>{try{return agentDoor(...args);}catch(error){throw Object.assign(error,{claimRefused:refused(error)});}};
|
|
@@ -398,7 +406,7 @@ export function recordAgentRun(root,org,id,{session,machine=null,run,profile=nul
|
|
|
398
406
|
db.exec('COMMIT');
|
|
399
407
|
}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
|
|
400
408
|
}
|
|
401
|
-
export function pendingAgentRuns(root,org){const db=store(root);try{return db.prepare('SELECT * FROM agent_runs WHERE org=? AND published=0 ORDER BY rowid LIMIT 100').all(org).filter(row=>!(
|
|
409
|
+
export function pendingAgentRuns(root,org){const db=store(root);try{return db.prepare('SELECT * FROM agent_runs WHERE org=? AND published=0 ORDER BY rowid LIMIT 100').all(org).filter(row=>!(retryOf(root,org,row)?.next_at>Date.now()));}finally{db.close();}}
|
|
402
410
|
/** Session references come from the bound runtime, never from a model's requested agent id. */
|
|
403
411
|
export function agentRunForSession(root, references) {
|
|
404
412
|
const db = store(root);
|
|
@@ -444,6 +452,8 @@ export function runBudgetRefusal(root,agent,budgets,now){
|
|
|
444
452
|
}
|
|
445
453
|
return null;
|
|
446
454
|
}
|
|
455
|
+
/** A run whose session Teams never had and that no longer runs here can never be owned there: it leaves the sync. */
|
|
456
|
+
export function abandonAgentRun(root,org,session){const db=store(root);try{db.prepare('UPDATE agent_runs SET published=2 WHERE org=? AND session_ref=? AND published=0').run(org,session);db.prepare('DELETE FROM agent_run_retries WHERE org=? AND session_ref=?').run(org,session);}finally{db.close();}}
|
|
447
457
|
export function publishAgentRun(root,org,session){const db=store(root);try{db.prepare('UPDATE agent_runs SET published=1 WHERE org=? AND session_ref=?').run(org,session);}finally{db.close();}}
|
|
448
458
|
export function agentPreamble(agent,{main=true,previous=[]}={}){
|
|
449
459
|
const {identity:a,intended,assignments=[]}=agent;
|
package/board/ci.mjs
CHANGED
|
@@ -47,6 +47,35 @@ const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, sta
|
|
|
47
47
|
headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
|
|
48
48
|
const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
49
49
|
|
|
50
|
+
const GITHUB_MERGE = `
|
|
51
|
+
const [path, sha] = process.argv.slice(1);
|
|
52
|
+
const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
|
|
53
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
54
|
+
const r = await fetch(base + path, { method: 'PUT', headers: { accept: 'application/vnd.github+json', 'content-type': 'application/json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) }, body: JSON.stringify({ sha, merge_method: 'merge' }) });
|
|
55
|
+
const text = await r.text();
|
|
56
|
+
process.stdout.write(JSON.stringify({ status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() }));`;
|
|
57
|
+
/**
|
|
58
|
+
* Merge a reviewed PR at the head its review judged, and only there: GitHub refuses the merge when the PR's head is no
|
|
59
|
+
* longer that commit (409), so a head pushed after the verdict is never merged unreviewed. Answers
|
|
60
|
+
* `{ merged, sha }` (already merged answers its merge commit), `{ moved, head }` when the head is not the reviewed one,
|
|
61
|
+
* or throws on anything GitHub did not decide (the caller tries again).
|
|
62
|
+
*/
|
|
63
|
+
export function mergeReviewedPullRequest(url, reviewed) {
|
|
64
|
+
const pr = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`);
|
|
65
|
+
if (pr.merged_at) return { merged: true, sha: pr.merge_commit_sha, already: true };
|
|
66
|
+
if (pr.head?.sha !== reviewed) return { moved: true, head: pr.head?.sha ?? null };
|
|
67
|
+
if (String(pr.state).toLowerCase() !== 'open') return { closed: true };
|
|
68
|
+
const fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
|
|
69
|
+
if (!fixed && !loginToken) try { loginToken = run('gh', ['auth', 'token']) || null; } catch { loginToken = null; }
|
|
70
|
+
const token = fixed ?? loginToken;
|
|
71
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_MERGE, `/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, reviewed], { env: token ? { ...process.env, GH_TOKEN: token } : process.env, encoding: 'utf8', timeout: 60_000 });
|
|
72
|
+
if (result.status !== 0) throw new Error(`GitHub merge: ${(result.stderr || result.error?.message || 'failed').trim().slice(-300)}`);
|
|
73
|
+
const answer = JSON.parse(result.stdout);
|
|
74
|
+
if (answer.status === 200 && answer.body?.merged) return { merged: true, sha: answer.body.sha };
|
|
75
|
+
if (answer.status === 409) return { moved: true, head: null, message: answer.body?.message ?? null };
|
|
76
|
+
throw new Error(`GitHub merge ${answer.status}: ${answer.body?.message ?? 'not merged'}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
const observations = new Map();
|
|
51
80
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
52
81
|
const run = (program, args, cwd) => {
|
package/board/cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath,
|
|
|
14
14
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
15
15
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
16
16
|
import * as engine from './engine.mjs';
|
|
17
|
-
import { requestAdoption, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
17
|
+
import { requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
18
18
|
import { watchBoard } from './wake.mjs';
|
|
19
19
|
import { streamBoardPublications } from './event-stream.mjs';
|
|
20
20
|
import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
@@ -23,7 +23,7 @@ import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,lo
|
|
|
23
23
|
|
|
24
24
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The board's verbs, with their flags:
|
|
27
27
|
init [--backing sqlite|files|ztrack] [--document <arcs.md>]
|
|
28
28
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
29
29
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
@@ -38,28 +38,28 @@ Hermes Kanban's verbs, with their flags:
|
|
|
38
38
|
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
39
39
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
40
40
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
41
|
-
pause <id> --reason <why> resume <id> cancel <id>
|
|
41
|
+
pause <id> --reason <why> resume <id> cancel <id> --reason <why>
|
|
42
42
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
43
43
|
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
44
|
-
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
44
|
+
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
45
45
|
specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
46
46
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
47
47
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
48
48
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
49
49
|
capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
|
|
50
50
|
dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
|
|
51
|
-
pause [reason] · resume (
|
|
51
|
+
pause [reason] · resume (the board's emergency stop: new starts stop, running sessions go on)
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
And:
|
|
54
54
|
events [--after <cursor>] [--once] --json
|
|
55
55
|
Native retained card snapshots, then committed changes; a changed source requests resynchronization.
|
|
56
56
|
usage [--agent AGENT] [--sponsor PERSON] [--json]
|
|
57
57
|
Tokens each agent's ended card sessions recorded (the harness's own records), per model, summed on its sponsor.
|
|
58
58
|
init --backing sqlite|files · boards create <slug>
|
|
59
|
-
Where the Org's one task store is kept: sqlite is
|
|
59
|
+
Where the Org's one task store is kept: sqlite is one database file, kanban.db (the default); files is one JSON file per
|
|
60
60
|
card. A board is a saved view over that store: the cards carrying its slug as a tag (create --tag, tag/untag).
|
|
61
61
|
create … --tag T… --sponsor PERSON | --requested-by <platform>:<user> [--request <message ref>]
|
|
62
|
-
Tags are ordered (
|
|
62
|
+
Tags are ordered (the first is the tenant). A proven requester sponsors the card with the request as
|
|
63
63
|
evidence; an unproven one is recorded as requested by (unverified) and the sponsor stays inherited.
|
|
64
64
|
tag <id> <tag>… untag <id> <tag>…
|
|
65
65
|
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
@@ -297,11 +297,40 @@ const metadataOf = (v) => {
|
|
|
297
297
|
return got;
|
|
298
298
|
};
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Every workflow call says when this orchestrator is not the published release: a build no release names (a hand
|
|
302
|
+
* install of a branch) or one older than npm's latest. Action is required until it is installed; the latest is read
|
|
303
|
+
* from the registry at most once an hour.
|
|
304
|
+
*/
|
|
305
|
+
function sayReleaseBehind() {
|
|
306
|
+
let running;
|
|
307
|
+
try { running = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return; }
|
|
308
|
+
const cache = join(tmpdir(), 'supercode-orchestrator-latest.json');
|
|
309
|
+
let latest = null;
|
|
310
|
+
let fresh = false;
|
|
311
|
+
try { const seen = JSON.parse(readFileSync(cache, 'utf8')); if (Date.now() - seen.at < 3_600_000) { fresh = true; latest = seen.latest; } } catch { /* read below */ }
|
|
312
|
+
if (!fresh) {
|
|
313
|
+
// a failed read is kept for the hour too: offline, no call waits on the registry more than once an hour
|
|
314
|
+
const r = spawnSync('npm', ['view', '@volter/supercode-orchestrator', 'version'], { encoding: 'utf8', timeout: 5000 });
|
|
315
|
+
latest = r.status === 0 ? r.stdout.trim() || null : null;
|
|
316
|
+
try { writeFileSync(cache, JSON.stringify({ at: Date.now(), latest })); } catch { /* read again next call */ }
|
|
317
|
+
}
|
|
318
|
+
if (!latest || running === latest) return;
|
|
319
|
+
const parts = (v) => v.split('-')[0].split('.').map(Number);
|
|
320
|
+
const [a, b] = [parts(running), parts(latest)];
|
|
321
|
+
const older = a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
322
|
+
const fix = `npm install -g @volter/supercode-orchestrator@${latest} (and each install's pinned copy)`;
|
|
323
|
+
if (running.includes('-')) process.stderr.write(`board: error: orchestrator ${running} is not a published release (latest ${latest}); action required: ${fix}\n`);
|
|
324
|
+
else if (older < 0) process.stderr.write(`board: error: orchestrator ${running} is behind the published ${latest}; action required: ${fix}\n`);
|
|
325
|
+
}
|
|
326
|
+
|
|
300
327
|
export async function runWorkflow(argv) {
|
|
301
328
|
const args = parse(argv);
|
|
302
329
|
const [verb, ...rest] = args._;
|
|
303
330
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
304
331
|
const root = homeOf(args);
|
|
332
|
+
// the dispatcher's own verbs run every round; its serve says it once when it starts
|
|
333
|
+
if (!['dispatch', 'events'].includes(verb)) sayReleaseBehind();
|
|
305
334
|
if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
|
|
306
335
|
// C4: one task store per Org. A named board without a store of its own is a saved view over it: its tag selects
|
|
307
336
|
// the cards it shows, and a card filed through it carries that tag.
|
|
@@ -346,12 +375,14 @@ export async function runWorkflow(argv) {
|
|
|
346
375
|
const mapping = JSON.parse(readFileSync(args['status-map'], 'utf8'));
|
|
347
376
|
const source = openBoard(root, slug);
|
|
348
377
|
try {
|
|
349
|
-
const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug
|
|
378
|
+
const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug });
|
|
350
379
|
migrated.close();
|
|
351
380
|
} finally { source.close(); }
|
|
352
381
|
renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
|
|
353
382
|
renameSync(preparing, paths.ztrack);
|
|
354
|
-
|
|
383
|
+
// The board's tables were seeded from the old store, which nothing reads now.
|
|
384
|
+
for (const file of [paths.sqlite, `${paths.sqlite}-wal`, `${paths.sqlite}-shm`]) rmSync(file, { force: true });
|
|
385
|
+
say(args, `Board migrated to ${args.document}`, { ok: true, backing: 'ztrack', document: args.document });
|
|
355
386
|
return 0;
|
|
356
387
|
}
|
|
357
388
|
case 'init': {
|
|
@@ -742,7 +773,7 @@ export async function runWorkflow(argv) {
|
|
|
742
773
|
say(args, targets.map((t) => `Promoted ${t} → ready`).join('\n'), { ok: true }); return 0;
|
|
743
774
|
}
|
|
744
775
|
case 'archive': {
|
|
745
|
-
for (const t of rest) await withBoardAsync((b) => engine.archive(b, t));
|
|
776
|
+
for (const t of rest) await withBoardAsync((b) => engine.archive(b, t, { reason: args.reason }));
|
|
746
777
|
const purged = [];
|
|
747
778
|
for (const t of args.rm ?? []) {
|
|
748
779
|
withBoard((b) => tx(b, (db) => {
|
|
@@ -1355,6 +1386,38 @@ async function serve(root, args = {}) {
|
|
|
1355
1386
|
// the orchestrator this serve runs, as installed: a newer install ends the serve after its tick, so whatever keeps it
|
|
1356
1387
|
// running starts it again on the new code (a fix reaches the board with no hand relaunch)
|
|
1357
1388
|
const installed = () => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? null; } catch { return null; } };
|
|
1389
|
+
// The dispatcher's own health, readable from outside it: after every round in which no board failed it writes its
|
|
1390
|
+
// heartbeat (the time, the round, what each board reported), so a watcher that finds it old knows the rounds stopped
|
|
1391
|
+
// succeeding, whatever the dispatcher can still say. A board whose rounds keep failing is also told to the workflow's
|
|
1392
|
+
// managers after FAILED_ROUNDS_ALERT in a row, and again each half hour while it lasts.
|
|
1393
|
+
const heartbeat = join(root, 'dispatcher-heartbeat.json');
|
|
1394
|
+
const FAILED_ROUNDS_ALERT = 5;
|
|
1395
|
+
const failing = new Map();
|
|
1396
|
+
let round = 0;
|
|
1397
|
+
const watchRounds = async (reports) => {
|
|
1398
|
+
if (reports.every((r) => !r.error)) {
|
|
1399
|
+
const temporary = `${heartbeat}.${process.pid}.tmp`;
|
|
1400
|
+
try {
|
|
1401
|
+
writeFileSync(temporary, `${JSON.stringify({ at: new Date().toISOString(), round, pid: process.pid, version: installed(), boards: reports.map((r) => ({ board: r.board, started: r.started?.length ?? 0, queued: r.queued?.length ?? 0, failed: r.failed?.length ?? 0, ms: r.ms ?? null })), errors: reports.flatMap((r) => [...(r.failed ?? []), ...(r.unsettled ?? [])].map(([t, why]) => `${r.board} ${t}: ${String(why).split('\n')[0].slice(0, 300)}`)).slice(0, 20) })}\n`);
|
|
1402
|
+
renameSync(temporary, heartbeat);
|
|
1403
|
+
} catch { /* the next round writes it */ }
|
|
1404
|
+
}
|
|
1405
|
+
for (const r of reports) {
|
|
1406
|
+
const run = failing.get(r.board) ?? { count: 0, since: Date.now(), told: 0 };
|
|
1407
|
+
if (!r.error) {
|
|
1408
|
+
if (run.count >= FAILED_ROUNDS_ALERT) await alertManagers(root, r.board === '*' ? 'default' : r.board, `The dispatcher's rounds on board ${r.board} succeed again after ${run.count} failed rounds since ${new Date(run.since).toISOString()}.`, `dispatcher-recovered-${r.board}-${run.since}`);
|
|
1409
|
+
failing.delete(r.board);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
run.count += 1; run.last = String(r.error).split('\n')[0].slice(0, 300);
|
|
1413
|
+
failing.set(r.board, run);
|
|
1414
|
+
if (run.count >= FAILED_ROUNDS_ALERT && Date.now() - run.told >= 1_800_000) {
|
|
1415
|
+
run.told = Date.now();
|
|
1416
|
+
const told = await alertManagers(root, r.board === '*' ? 'default' : r.board, `The dispatcher's rounds on board ${r.board} have failed ${run.count} times in a row since ${new Date(run.since).toISOString()}: ${run.last}\nNothing starts, moves or is supervised on it until a round succeeds. Its heartbeat (${heartbeat}) stopped at the last good round.`, `dispatcher-failing-${r.board}-${run.since}-${run.count}`);
|
|
1417
|
+
log(`rounds on board ${r.board} failing ${run.count} in a row; ${told} manager(s) told`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1358
1421
|
const running = installed();
|
|
1359
1422
|
log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
|
|
1360
1423
|
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
@@ -1402,7 +1465,10 @@ async function serve(root, args = {}) {
|
|
|
1402
1465
|
const state = await laneState(root);
|
|
1403
1466
|
const settings = { ...state.settings, ...override };
|
|
1404
1467
|
interval = settings.interval;
|
|
1405
|
-
|
|
1468
|
+
const reports = await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true });
|
|
1469
|
+
round += 1;
|
|
1470
|
+
await watchRounds(reports);
|
|
1471
|
+
for (const r of reports) {
|
|
1406
1472
|
nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
|
|
1407
1473
|
const said = summary(r);
|
|
1408
1474
|
if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
|
|
@@ -1420,6 +1486,7 @@ async function serve(root, args = {}) {
|
|
|
1420
1486
|
}
|
|
1421
1487
|
} catch (error) {
|
|
1422
1488
|
log(`tick failed: ${error.message}`);
|
|
1489
|
+
await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }]);
|
|
1423
1490
|
}
|
|
1424
1491
|
// a newer install is looked for after each tick and through the wait before the next
|
|
1425
1492
|
const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
|
package/board/dispatch.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { readEventFleet } from './event-fleet.mjs';
|
|
|
20
20
|
let eventFleetRoot = null;
|
|
21
21
|
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
22
22
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
23
|
-
import { agentDoor, agentScope, teamsUnavailable, ensureCardAgent, localCardAgent, localAgentSessions, synchronizeLocalAgents, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, agentPreamble, agentEnvironment, recordAgentRun, pendingAgentRuns, synchronizeAgentRun,agentProfileMismatch,activeAgentAssignments,finishAgentAssignment,synchronizeAgentEnds } from '../agents.mjs';
|
|
23
|
+
import { agentDoor, agentScope, teamsUnavailable, ensureCardAgent, localCardAgent, localAgentSessions, synchronizeLocalAgents, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, agentPreamble, agentEnvironment, recordAgentRun, pendingAgentRuns, synchronizeAgentRun, abandonAgentRun,agentProfileMismatch,activeAgentAssignments,finishAgentAssignment,synchronizeAgentEnds, agentRunForSession } from '../agents.mjs';
|
|
24
24
|
import { materializeAgentHome } from '../agent-home.mjs';
|
|
25
25
|
import { memorySnapshotSync } from '../hermes-cli.mjs';
|
|
26
26
|
import { createRequire } from 'node:module';
|
|
@@ -130,6 +130,12 @@ const HARNESSES = {
|
|
|
130
130
|
codex: { program: 'codex', fresh: () => [], resume: (sid) => ['resume', sid], namesItself: true },
|
|
131
131
|
};
|
|
132
132
|
/** A recorded session's fleet address (older records carry no harness: those were Claude Code's). */
|
|
133
|
+
// A session the board launched (a pane by its launch key, or a headless process) keeps that whatever its mode: a kept
|
|
134
|
+
// worker is marked 'adopt' when its card's next run takes it over, and is still the board's to close.
|
|
135
|
+
// A door call that timed out, or whose machine or daemon could not be reached (a daemon between restarts answers
|
|
136
|
+
// machine_offline; a stream that timed out, 504), says nothing about what it was asked: it is tried again next round.
|
|
137
|
+
const doorTransient = (r) => r.code === null || ['unavailable', 'machine_offline', 'rate_limited', 504, '504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'].includes(json(String(r.stderr ?? '').trim().split('\n').pop(), null)?.code);
|
|
138
|
+
const adoptedOnly = (s) => s?.mode === 'adopt' && !s.launch_key && s.surface !== 'headless';
|
|
133
139
|
const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.machine}:${s.harness ?? 'claude-code'}:${s.session_id}` : null);
|
|
134
140
|
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
135
141
|
|
|
@@ -151,8 +157,17 @@ function unpublishedAlive(session) {
|
|
|
151
157
|
return entry.panes ? entry.panes.some((p) => p.pane === session.pane && !p.dead) : null;
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
/** Native activity comes from the machine's harness discovery, not catalogue age.
|
|
160
|
+
/** Native activity comes from the machine's harness discovery, not catalogue age. One discovery (up to 2000
|
|
161
|
+
* sessions, seconds each) serves a round's every native session on that machine, as paneLists serves its panes. */
|
|
162
|
+
const discoveries = new Map();
|
|
155
163
|
function nativeSessions(machine, harness) {
|
|
164
|
+
const key = `${normalMachine(machine)}\u0000${harness}`, seen = discoveries.get(key);
|
|
165
|
+
if (seen && Date.now() - seen.at <= 5000) return seen.sessions;
|
|
166
|
+
const sessions = discoverNative(machine, harness);
|
|
167
|
+
discoveries.set(key, { at: Date.now(), sessions });
|
|
168
|
+
return sessions;
|
|
169
|
+
}
|
|
170
|
+
function discoverNative(machine, harness) {
|
|
156
171
|
if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
|
|
157
172
|
// This machine's discovery is its harness door, as every other harness read here is; another machine's is supercode's fleet arm
|
|
158
173
|
const result = normalMachine(machine) === localMachine()
|
|
@@ -427,14 +442,18 @@ function stopSession(board, session) {
|
|
|
427
442
|
if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
|
|
428
443
|
if (!session.pane && session.surface === 'native') {
|
|
429
444
|
const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
|
|
430
|
-
if (r.code !== 0) throw new Error(
|
|
445
|
+
if (r.code !== 0) throw new Error(`${doorTransient(r) ? 'close deferred: ' : ''}closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
446
|
+
// the close's receipt is a discovery taken after it, never the round's cached one from before
|
|
447
|
+
discoveries.delete(`${normalMachine(session.machine)}\u0000${session.harness}`);
|
|
431
448
|
const reading = nativeReading(nativeSessions(session.machine, session.harness), session);
|
|
432
449
|
if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
|
|
433
450
|
return;
|
|
434
451
|
}
|
|
435
452
|
if (session.pane) {
|
|
436
453
|
const r = runtimeDoor(normalMachine(session.machine??localMachine())===localMachine()?null:session.machine,'harness.v1.panes.close',{pane:session.pane},{env:commandEnv(),timeout:60_000,as:boardAddress(board.label)});
|
|
437
|
-
|
|
454
|
+
// a door that timed out or could not be reached says nothing about the pane: the close waits for the next round
|
|
455
|
+
const transient = doorTransient(r);
|
|
456
|
+
if (r.code !== 0 || json(r.stdout.trim(), null)?.closed !== session.pane) throw new Error(`${transient ? 'close deferred: ' : ''}closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
|
|
438
457
|
}
|
|
439
458
|
}
|
|
440
459
|
|
|
@@ -796,6 +815,23 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
796
815
|
if (session?.monitor_held && !session.close_requested) continue; // only an explicit close action ends this process
|
|
797
816
|
if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
|
|
798
817
|
if (caller && session.session_id === caller) continue; // let the command that ended its own run return
|
|
818
|
+
// The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
|
|
819
|
+
// given) is released instead: left running, its slot freed. Closing one failed on every round for a session no close
|
|
820
|
+
// could name, and could have ended the owner's own thread.
|
|
821
|
+
if (adoptedOnly(session)) {
|
|
822
|
+
tx(board, (db) => {
|
|
823
|
+
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
824
|
+
if (!current || current.session_id !== session.session_id || current.close_confirmed_at) return;
|
|
825
|
+
const at = now();
|
|
826
|
+
setRunSession(db, row.id, { ...current, close_requested: current.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null, released: true });
|
|
827
|
+
event(db, row.task_id, 'session_released', { session_id: session.session_id, pane: session.pane ?? null, machine: session.machine, reason: 'the board did not launch it; it is left running' }, row.id);
|
|
828
|
+
});
|
|
829
|
+
(report.closed ??= []).push({ task_id: row.task_id, ...session, released: true });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
// A close that failed for a reason another attempt cannot change is tried once and raised as an incident, not tried
|
|
833
|
+
// every round (each attempt is a process). A deferred close (an offline machine, a run that still owns the pane) waits.
|
|
834
|
+
if (session.close_failed_at && session.close_error && !/^close deferred/.test(session.close_error)) continue;
|
|
799
835
|
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all());
|
|
800
836
|
// Persist retries even for historical ends that did not have a close_session action.
|
|
801
837
|
tx(board, (db) => requestSessionClose(db, row.task_id, { runId: row.id }));
|
|
@@ -817,7 +853,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
817
853
|
panes.set(machine, Array.isArray(list) ? { live: new Set(list.filter(p => !p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
|
|
818
854
|
}
|
|
819
855
|
const observed = panes.get(machine);
|
|
820
|
-
if (observed.error) throw new Error(observed.error);
|
|
856
|
+
if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
|
|
821
857
|
if (observed.live.has(session.pane)) {
|
|
822
858
|
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
|
|
823
859
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
@@ -825,15 +861,22 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
825
861
|
stopSession(board, session); observed.live.delete(session.pane);
|
|
826
862
|
}
|
|
827
863
|
}
|
|
828
|
-
} catch (e) {
|
|
864
|
+
} catch (e) {
|
|
865
|
+
// a deferred close (an unreachable door, an unread pane list or session discovery) waits for the next round;
|
|
866
|
+
// the prefix is decided on the whole message, before it is cut to size
|
|
867
|
+
const message = String(e.message), deferred = Boolean(e.launchDeferred) || /^close deferred: /.test(message);
|
|
868
|
+
error = `${deferred ? 'close deferred: ' : ''}${message.replace(/^close deferred: /, '').slice(-480)}`;
|
|
869
|
+
}
|
|
829
870
|
tx(board, (db) => {
|
|
830
871
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
831
872
|
if (!current || current.pane !== session.pane || current.session_id !== session.session_id) return;
|
|
832
873
|
const at = now();
|
|
833
874
|
if (error) {
|
|
834
|
-
|
|
875
|
+
const final = !/^close deferred/.test(error);
|
|
876
|
+
setRunSession(db, row.id, { ...current, close_attempted_at: at, close_attempts: Number(current.close_attempts ?? 0) + 1, close_error: error, ...(final ? { close_failed_at: at } : {}) });
|
|
835
877
|
// Retain every attempt's latest fact, but avoid an unbounded thread of identical failures.
|
|
836
|
-
if (current.close_error !== error) event(db, row.task_id, 'session_close_failed', { session_id: session.session_id, pane: session.pane, error, retry:
|
|
878
|
+
if (current.close_error !== error) event(db, row.task_id, 'session_close_failed', { session_id: session.session_id, pane: session.pane, error, retry: !final }, row.id);
|
|
879
|
+
if (final) event(db, row.task_id, 'run_incident', { observation: 'close_failed', run: row.id, reason: `the board could not close ${addressOf(session)} and will not try again: ${error}`, session: session.session_id, machine: session.machine, pane: session.pane ?? null, evidence: { error } }, row.id);
|
|
837
880
|
} else {
|
|
838
881
|
const { close_error: _error, ...rest } = current;
|
|
839
882
|
setRunSession(db, row.id, { ...rest, close_error: null, closed_at: at, close_confirmed_at: at });
|
|
@@ -852,6 +895,16 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
852
895
|
* Keep bounded readings on the run so an ordinary quiet stretch can be reviewed later. */
|
|
853
896
|
function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
854
897
|
if (!fleet.ok) return;
|
|
898
|
+
// The published fleet says whether a session runs and its turn, not when it last wrote: that is the session's own
|
|
899
|
+
// record, read from its machine's discovery (one read per machine and harness a round, only when a card needs it).
|
|
900
|
+
// Without it every reading was 'no_timestamp' and no session was ever found idle.
|
|
901
|
+
const discovered = new Map();
|
|
902
|
+
const updatedAt = (session) => {
|
|
903
|
+
const key = `${session.machine}|${session.harness ?? 'claude-code'}`;
|
|
904
|
+
if (!discovered.has(key)) { try { discovered.set(key, nativeSessions(session.machine ?? localMachine(), session.harness ?? 'claude-code')); } catch { discovered.set(key, null); } }
|
|
905
|
+
return Number(discovered.get(key)?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
|
|
906
|
+
};
|
|
907
|
+
const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
|
|
855
908
|
for (const { id } of board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all()) {
|
|
856
909
|
tx(board, (db) => {
|
|
857
910
|
const task = getTask(db, id);
|
|
@@ -863,12 +916,13 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
863
916
|
if (session.surface === 'native' && fleet.native?.get(addressOf(session))?.alive !== true) return;
|
|
864
917
|
const live = fleet.sessions.find((x) => x.address === addressOf(session));
|
|
865
918
|
const observedAt = now();
|
|
866
|
-
const
|
|
919
|
+
const lastAtMs = Number(live?.last_message_at_ms) || updatedAt(session);
|
|
920
|
+
const lastAt = Math.floor(Number(lastAtMs) / 1000);
|
|
867
921
|
const reading = {
|
|
868
922
|
session_id: session.session_id, observed_at: observedAt,
|
|
869
923
|
last_message_at: lastAt > 0 ? lastAt : null,
|
|
870
|
-
last_message_at_ms: lastAt > 0 ? Number(
|
|
871
|
-
status: live?.status ?? null, quiet_seconds: lastAt > 0 ? observedAt - lastAt : null,
|
|
924
|
+
last_message_at_ms: lastAt > 0 ? Number(lastAtMs) : null,
|
|
925
|
+
status: live?.status ?? null, turn: live?.turn ?? null, quiet_seconds: lastAt > 0 ? observedAt - lastAt : null,
|
|
872
926
|
threshold_seconds: settings.idleQuiet, turns_before: Number(session.goal_turns ?? 0),
|
|
873
927
|
};
|
|
874
928
|
const retain = (decision, extra = {}) => {
|
|
@@ -882,6 +936,23 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
882
936
|
setRunSession(db, runRow.id, patch);
|
|
883
937
|
return observation;
|
|
884
938
|
};
|
|
939
|
+
// A session its harness holds (a question or permission prompt, a dialog such as the usage limit, a hang) is told to
|
|
940
|
+
// the manager with what its screen shows, once per state: a reminder typed into a dialog goes nowhere. Waiting on
|
|
941
|
+
// its user is known at once; working without writing past the quiet threshold is held.
|
|
942
|
+
const held = live?.turn === 'needs_input' ? 'waiting_on_user' : live?.turn === 'working' && lastAt > 0 && observedAt - lastAt >= settings.idleQuiet ? 'working_silent' : null;
|
|
943
|
+
if (held) {
|
|
944
|
+
if (session.idle_observation?.decision === held && session.idle_observation.last_message_at_ms === reading.last_message_at_ms) return;
|
|
945
|
+
let screen = null;
|
|
946
|
+
if (session.pane) { const r = doorOn(session.machine ?? localMachine(), 'panes.capture', { pane: session.pane, lines: 25 }, { timeout: 20_000 }); screen = r.code === 0 ? String(json(r.stdout.trim(), {})?.text ?? '').trim().split('\n').slice(-25).join('\n') : null; }
|
|
947
|
+
const observation = retain(held, { screen });
|
|
948
|
+
const why = held === 'waiting_on_user' ? 'is waiting on its user (a question or a permission prompt)' : `has reported working without writing anything for ${Math.round((observedAt - lastAt) / 60)} min`;
|
|
949
|
+
const text = `${id}: its session ${addressOf(session)} ${why}; nothing it is sent reaches it until that is answered.${screen ? `\nIts screen:\n${screen}` : ''}`;
|
|
950
|
+
event(db, id, 'session_held', observation, runRow.id);
|
|
951
|
+
const to = managers[0] ?? null;
|
|
952
|
+
if (to) event(db, id, 'board_message', { to, text }, null);
|
|
953
|
+
(report.held ??= []).push([id, held, addressOf(session)]);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
885
956
|
// No timestamp is no reading, and no reading gives no verdict.
|
|
886
957
|
if (!(lastAt > 0)) { retain('no_timestamp'); return; }
|
|
887
958
|
if (reading.quiet_seconds < settings.idleQuiet) { retain('below_threshold'); return; }
|
|
@@ -971,6 +1042,46 @@ export async function sessionEffect(board, e) {
|
|
|
971
1042
|
}
|
|
972
1043
|
}
|
|
973
1044
|
|
|
1045
|
+
/**
|
|
1046
|
+
* A review's pass merged by the board itself, not left for a coder session that may be gone or busy: every PR the pass
|
|
1047
|
+
* judged (its `review_pass` record's ci), merged at the head it judged. A head that moved after the verdict is never
|
|
1048
|
+
* merged; the card is sent `merge_refused` and its managers are told, so new work is new review, not a silent one.
|
|
1049
|
+
* Once every PR is merged the card is sent `reviewed_merged` with the merge commits. Answers false (kept, tried again
|
|
1050
|
+
* next round) on anything GitHub did not decide.
|
|
1051
|
+
*/
|
|
1052
|
+
export async function mergeReviewed(board, e) {
|
|
1053
|
+
const { candidatePullRequests, mergeReviewedPullRequest } = await import('./ci.mjs');
|
|
1054
|
+
const pass = view(board, (db) => db.prepare("SELECT id, payload FROM task_events WHERE task_id = ? AND kind = 'review_pass' ORDER BY id DESC LIMIT 1").get(e.id));
|
|
1055
|
+
const recorded = json(pass?.payload, {})?.ci ?? null;
|
|
1056
|
+
const ci = typeof recorded === 'string' ? json(recorded, null) : recorded;
|
|
1057
|
+
const prs = candidatePullRequests(ci).filter((p) => p?.pr?.url && p.commit);
|
|
1058
|
+
const merges = [];
|
|
1059
|
+
for (const p of prs) {
|
|
1060
|
+
let answer;
|
|
1061
|
+
try { answer = mergeReviewedPullRequest(p.pr.url, p.commit); } catch (error) {
|
|
1062
|
+
tx(board, (db) => event(db, e.id, 'merge_pending', { pr: p.pr.url, reviewed: p.commit, reason: String(error.message).slice(-300) }));
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
if (answer.merged) { merges.push({ pr: p.pr.url, reviewed: p.commit, merge: answer.sha }); continue; }
|
|
1066
|
+
const reason = answer.moved ? `${p.pr.url} is no longer at its reviewed head ${p.commit}${answer.head ? ` (now ${answer.head})` : ''}; what was pushed after the verdict needs its own review` : `${p.pr.url} was closed without merging`;
|
|
1067
|
+
const entry = await loadWorkflow(board.root);
|
|
1068
|
+
tx(board, (db) => {
|
|
1069
|
+
event(db, e.id, 'merge_refused', { pr: p.pr.url, reviewed: p.commit, head: answer.head ?? null, reason, merged: merges });
|
|
1070
|
+
try { const r = fire(db, board, entry, e.id, 'merge_refused', { roles: ['platform'], payload: { reason, pr: p.pr.url } }); for (const eff of r.effects) db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(eff)); } catch { /* a workflow without merge_refused keeps the card where it is */ }
|
|
1071
|
+
const to = [].concat(entry.params.managers ?? []).find(Boolean);
|
|
1072
|
+
if (to) event(db, e.id, 'board_message', { to, text: `${e.id}: the passed review was not merged: ${reason}. Merged so far: ${merges.map((m) => m.pr).join(', ') || 'none'}.` }, null);
|
|
1073
|
+
});
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
const entry = await loadWorkflow(board.root);
|
|
1077
|
+
tx(board, (db) => {
|
|
1078
|
+
event(db, e.id, 'reviewed_merged', { merges });
|
|
1079
|
+
const r = fire(db, board, entry, e.id, 'reviewed_merged', { roles: ['platform'], payload: { merges, summary: merges.length ? `Merged at the reviewed heads: ${merges.map((m) => `${m.pr} (${m.reviewed.slice(0, 8)} → ${String(m.merge).slice(0, 8)})`).join(', ')}` : 'The pass named no PR to merge.' } });
|
|
1080
|
+
for (const eff of r.effects) db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(eff));
|
|
1081
|
+
});
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
974
1085
|
// ------------------------------------------------------------------ start
|
|
975
1086
|
|
|
976
1087
|
/** What the launcher itself needs to start this card (the caps are the workflow's `dispatch.capacity`). */
|
|
@@ -1154,7 +1265,9 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1154
1265
|
event(db,row.task_id,mismatch.differences.length?'agent_profile_drift':'agent_profile_recorded',{agent:session.agent_id,session:session.session_id,comparison:mismatch},row.id);
|
|
1155
1266
|
});
|
|
1156
1267
|
}
|
|
1157
|
-
|
|
1268
|
+
// One run whose session another agent already owns is that card's problem, said on it; the round goes on for the rest.
|
|
1269
|
+
try{recordAgentRun(board.root,session.agent_org,session.agent_id,{session:`${session.harness}:${session.session_id}`,machine:session.machine,run:row.id});}
|
|
1270
|
+
catch(error){report.queued.push([row.task_id,`run ${row.id}: ${error.message}`]);continue;}
|
|
1158
1271
|
orgs.set(session.agent_org,session.agent_context);
|
|
1159
1272
|
}
|
|
1160
1273
|
// Identities minted while Teams was absent are declared under their ids before their sessions are joined.
|
|
@@ -1189,7 +1302,7 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1189
1302
|
for(const [org,context] of orgs)for(const row of pendingAgentRuns(board.root,org)){
|
|
1190
1303
|
const launch=agentRuns.find(run=>String(run.id)===String(row.run_id));
|
|
1191
1304
|
try{synchronizeAgentRun(board.root,org,row,{context,intended:json(launch?.metadata,{})?.supercode?.agent_profile_intent});}
|
|
1192
|
-
catch(error){report.queued.push([row.run_id,`agent session sync pending: ${error.message}`]);if(error.
|
|
1305
|
+
catch(error){if(error.code==='rate_limited'){report.queued.push([row.run_id,`agent session sync pending: ${error.message}`]);break;}const gone=error.claimRefused&&error.status===404&&fleet.ok&&sessionAlive(fleet,{address:`sc:${row.machine||localMachine()}:${row.session_ref}`,machine:row.machine||localMachine()})===false;if(gone)abandonAgentRun(board.root,org,row.session_ref);report.failed.push([row.run_id,`agent session sync failed: ${error.message}${gone?`; ${row.session_ref} is stopped and Teams refuses it, so it leaves the sync`:''}`]);}
|
|
1193
1306
|
}
|
|
1194
1307
|
const deferred=view(board,db=>db.prepare("SELECT t.id,r.started_at FROM tasks t JOIN task_runs r ON r.id=t.current_run_id WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.launch_deferred')=1").all());
|
|
1195
1308
|
// A deferred launch was never issued (a reading or door was not yet available): it is the start its manager, creator
|
|
@@ -1338,7 +1451,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1338
1451
|
else if (prev && (prev.harness ?? 'claude-code') !== harness) {
|
|
1339
1452
|
// the card was reassigned to a lane of another harness: that harness starts it fresh (the card's text and thread
|
|
1340
1453
|
// are its context), and the last session, if still open, is closed so two harnesses never work one card
|
|
1341
|
-
if (prev.pane && !prev.closed_at) stopSession(board, prev);
|
|
1454
|
+
if (prev.pane && !prev.closed_at && !adoptedOnly(prev)) stopSession(board, prev); // an adopted session is released, never closed
|
|
1342
1455
|
replaced = { session_id: prev.session_id, why: `the card's lane runs ${harness}, not ${prev.harness ?? 'claude-code'}`, letGo: true };
|
|
1343
1456
|
} else if (prev && prev.machine === machine) {
|
|
1344
1457
|
// a session the board closed, or one whose close its own command asked for (the next tick's sweep closes that
|
|
@@ -1598,6 +1711,10 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1598
1711
|
try { agent = ensureCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: spec0?.role ?? 'implementer', sponsor: card0?.sponsor ?? null, machine: normalMachine(host) === localMachine() ? null : host, context }); }
|
|
1599
1712
|
catch (error) { if (!teamsUnavailable(error)) throw error; }
|
|
1600
1713
|
}
|
|
1714
|
+
// A session another agent already owns is refused before anything is recorded: the run would name one agent and the
|
|
1715
|
+
// agent store another, and every round after would trip on it.
|
|
1716
|
+
const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
|
|
1717
|
+
if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
|
|
1601
1718
|
const adopted = tx(board, (db) => {
|
|
1602
1719
|
const task = getTask(db, id);
|
|
1603
1720
|
if (!task) throw new Error(`no card ${id}`);
|
|
@@ -1714,6 +1831,21 @@ async function send(board, from, label, to, body, key, { queue = false, ask = fa
|
|
|
1714
1831
|
return answer.receipt ?? { delivered: false, reason: 'mail door returned no delivery receipt' };
|
|
1715
1832
|
}
|
|
1716
1833
|
|
|
1834
|
+
/**
|
|
1835
|
+
* Mail the workflow's managers (`params.managers`) about the dispatcher itself, not a card: its rounds failing, for one.
|
|
1836
|
+
* Answers how many took it; a manager that cannot be reached is skipped, never a reason to fail the caller.
|
|
1837
|
+
*/
|
|
1838
|
+
export async function alertManagers(root, slug, text, key) {
|
|
1839
|
+
let managers = [];
|
|
1840
|
+
try { const entry = await loadWorkflow(root, { fresh: true }); managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : []; } catch { /* a workflow that cannot load names nobody */ }
|
|
1841
|
+
const label = boardLabel(root, slug);
|
|
1842
|
+
let told = 0;
|
|
1843
|
+
for (const to of managers) {
|
|
1844
|
+
try { const receipt = await send({ root }, boardAddress(label), label, to, text, key, { subject: text.split('\n')[0] }); if (receipt.delivered || receipt.queued) told += 1; } catch { /* the next alert tries again */ }
|
|
1845
|
+
}
|
|
1846
|
+
return told;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1717
1849
|
/**
|
|
1718
1850
|
* The messages the workflow has the board send (`tell`, `ask`: board_message events) that it has not sent yet, sent now
|
|
1719
1851
|
* and marked board_message_sent. A question goes under the id its card waits on; a resend of a sent one is a no-op
|
|
@@ -1821,16 +1953,19 @@ async function notify(board, fleet, report, entry) {
|
|
|
1821
1953
|
// A backlog (a restart, a recipient that could not be reached) is told as it stands now: of one kind of news about
|
|
1822
1954
|
// the card, only the latest (a later adoption_failed, blocked or moved says what the earlier one did); comments
|
|
1823
1955
|
// and answers, each its own message, in full. A notice the board already recorded as taken by this address is never sent again.
|
|
1824
|
-
|
|
1956
|
+
// only events that would be told count: a stale one or the recipient's own act never supersedes one that would be
|
|
1957
|
+
const sendable = (ev) => { const p = json(ev.payload, {}) ?? {}; return !(maxAge && now() - Number(ev.created_at) > maxAge) && !(p.by && p.by === sub.chat_id); };
|
|
1958
|
+
const latest = new Map(events.filter((ev) => !['commented', 'answered'].includes(ev.kind) && sendable(ev)).map((ev) => [ev.kind, ev.id]));
|
|
1825
1959
|
const sentBefore = board.db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind IN ('notice_delivered', 'notice_filed') AND json_extract(payload, '$.source_event') = ? AND json_extract(payload, '$.address') = ? LIMIT 1");
|
|
1826
1960
|
const advance = (id) => tx(board, (db) => db.prepare("UPDATE kanban_notify_subs SET last_event_id = ? WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ? AND last_event_id < ?").run(id, sub.task_id, sub.chat_id, sub.thread_id ?? '', id));
|
|
1827
1961
|
for (const ev of events) {
|
|
1828
1962
|
const p = json(ev.payload, {}) ?? {};
|
|
1829
1963
|
if (maxAge && now() - Number(ev.created_at) > maxAge) { cursor = ev.id; (report.stale_notices_passed ??= 0); report.stale_notices_passed += 1; continue; }
|
|
1830
|
-
|
|
1964
|
+
|
|
1831
1965
|
if (sentBefore.get(sub.task_id, ev.id, sub.chat_id)) { cursor = ev.id; continue; }
|
|
1832
1966
|
// nobody is told of their own act
|
|
1833
1967
|
if (p.by && p.by === sub.chat_id) { cursor = ev.id; continue; }
|
|
1968
|
+
if (latest.has(ev.kind) && latest.get(ev.kind) !== ev.id) { cursor = ev.id; (report.superseded_notices_passed ??= 0); report.superseded_notices_passed += 1; continue; }
|
|
1834
1969
|
let text;
|
|
1835
1970
|
if (ev.kind === 'commented') {
|
|
1836
1971
|
if (p.author === sub.chat_id) { cursor = ev.id; continue; }
|
package/board/engine.mjs
CHANGED
|
@@ -485,6 +485,10 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
|
|
|
485
485
|
const { sendBoardMessages } = await import('./dispatch.mjs');
|
|
486
486
|
await sendBoardMessages(board, { undelivered: [] }, e.id);
|
|
487
487
|
}
|
|
488
|
+
else if (e.kind === 'merge_reviewed') {
|
|
489
|
+
const { mergeReviewed } = await import('./dispatch.mjs');
|
|
490
|
+
if (await mergeReviewed(board, e) === false) continue;
|
|
491
|
+
}
|
|
488
492
|
else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
|
|
489
493
|
else if (e.kind === 'stop_run') stopWorker(board, e.runId);
|
|
490
494
|
else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
|
|
@@ -566,7 +570,7 @@ export const reopenReview = (board, id, { roles, reason = null } = {}) => act(bo
|
|
|
566
570
|
export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
|
|
567
571
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
568
572
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
569
|
-
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
573
|
+
export const archive = (board, id, { roles, reason } = {}) => act(board, id, 'archive', { roles, payload: { reason } });
|
|
570
574
|
export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, machine = null, author = 'user', roles } = {}) {
|
|
571
575
|
let declaration = null;
|
|
572
576
|
if (workspace != null || branch != null || machine != null) {
|
package/board/event-fleet.mjs
CHANGED
|
@@ -52,7 +52,7 @@ export function readEventFleet(root){
|
|
|
52
52
|
const previous=native.get(address),states=[...(previous?.states??[]),state];
|
|
53
53
|
const combined=states.includes(true)?true:states.includes(null)?null:states.every(value=>value===false)?false:null;
|
|
54
54
|
native.set(address,{alive:combined,states});if(combined===true)alive.add(address);
|
|
55
|
-
sessions.push({address,machine,harness:binding.harness,session_id:binding.native_session_id,pane:binding.pane??null,name:binding.name??null,state:binding.state,identity:value.data.agent_identity??null});
|
|
55
|
+
sessions.push({address,machine,harness:binding.harness,session_id:binding.native_session_id,pane:binding.pane??null,name:binding.name??null,state:binding.state,turn:binding.turn??null,native_state:binding.evidence?.native_state??null,identity:value.data.agent_identity??null});
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
return {ok:!!cache.connected,presenceKnown:!!cache.connected,online,alive,native,sessions,error:cache.connected?null:'published session events disconnected'};
|
package/board/git-document.mjs
CHANGED
|
@@ -60,10 +60,12 @@ export class GitDocument {
|
|
|
60
60
|
// not land (refused: something was pushed meanwhile) takes what was pushed now and pushes before writing again.
|
|
61
61
|
pull() {
|
|
62
62
|
if (!this.usable) return;
|
|
63
|
+
// Every board transaction pulls (about a hundred a dispatcher round): inside the fetch interval nothing is read,
|
|
64
|
+
// not even the branch, so a round no longer spends seconds on git subprocesses that decide nothing.
|
|
65
|
+
if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
|
|
63
66
|
const branch = this.branch();
|
|
64
67
|
if (!branch) return this.detached();
|
|
65
68
|
if (this.unpushed(branch)) return this.pushing ? undefined : this.publish(branch, 'board');
|
|
66
|
-
if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
|
|
67
69
|
if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next write tries again
|
|
68
70
|
const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
|
|
69
71
|
if (behind === 0) return;
|
package/board/store.mjs
CHANGED
|
@@ -169,7 +169,7 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
|
|
|
169
169
|
const use = kept ?? backing ?? 'sqlite';
|
|
170
170
|
if (!BACKINGS.includes(use)) throw new Error(`a board's backing is ${BACKINGS.join(' | ')}, not ${use}`);
|
|
171
171
|
const path = backingPaths(root, slug)[use];
|
|
172
|
-
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, init: layOut, root, slug
|
|
172
|
+
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, init: layOut, root, slug }), root, slug, path, backing: use };
|
|
173
173
|
if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut }), root, slug, path, backing: use };
|
|
174
174
|
if (!kept) mkdirSync(dirname(path), { recursive: true });
|
|
175
175
|
const db = new DatabaseSync(path);
|
package/board/workflow.mjs
CHANGED
|
@@ -420,6 +420,9 @@ function declaredStatus(db, board, entry, task) {
|
|
|
420
420
|
record(db, task.id, 'status_adopted', { from, to: fits[0], reason: `${from} is how this board's workflow stores ${fits[0]} for this card` });
|
|
421
421
|
return fits[0];
|
|
422
422
|
}
|
|
423
|
+
// A card a board store marks stopped or held (`archived`, `blocked`) is never taken for a new one, whichever workflow
|
|
424
|
+
// reads it: entering at the start would dispatch it again.
|
|
425
|
+
if (['archived', 'blocked'].includes(from)) throw new Error(`${task.id} is ${from}, which the board's workflow does not declare; move it with the verb for the status it means`);
|
|
423
426
|
const scope = scopeOf(db, board, task, { params: entry.params, event: {}, extra: { lanes: lanesIn(entry) } });
|
|
424
427
|
const start = entry.workflow.initial?.find((x) => holds(x.guard ?? null, scope));
|
|
425
428
|
const to = start?.target ? String(templateValue(start.target, scope)) : null;
|
|
@@ -435,7 +438,13 @@ function declaredStatus(db, board, entry, task) {
|
|
|
435
438
|
* `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
|
|
436
439
|
*/
|
|
437
440
|
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
438
|
-
|
|
441
|
+
// A card ends with its reason: a cancel or archive that says nothing why is refused, whoever sends it, the manager
|
|
442
|
+
// included. The reason is all the next reader of an ended card has (an empty-reason cancel once ended an arc that had
|
|
443
|
+
// shipped half, and nothing on the card said so).
|
|
444
|
+
if ((name === 'cancel' || name === 'archive') && !String(payload?.reason ?? '').trim()) throw new Error(`${id}: ${name} needs a reason (--reason <why>)`);
|
|
445
|
+
// A card the document no longer lists (a removal the document cannot make) still takes the manager's cancel or
|
|
446
|
+
// archive: that ends it the way the document already has, and is the one way a manager clears it.
|
|
447
|
+
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)}`);
|
|
439
448
|
const { workflow, params } = entry;
|
|
440
449
|
if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return fireRun(db, board, entry, id, name, { roles, payload, extra });
|
|
441
450
|
const task = cardOf(db, id);
|
|
@@ -700,6 +709,10 @@ const ACTIONS = {
|
|
|
700
709
|
cardField(ctx, 'next_run_at', interval == null ? null : now());
|
|
701
710
|
},
|
|
702
711
|
card_event(ctx, { name, payload = {}, as = ['platform'] }) {
|
|
712
|
+
// a cancel or archive a workflow fires from another (an archive of a live card cancels it first) carries that
|
|
713
|
+
// event's reason when it names none of its own: the reason is required on every end
|
|
714
|
+
const reason = payload?.reason ?? ctx.scope.event?.reason;
|
|
715
|
+
if (['cancel', 'archive'].includes(name) && reason != null && payload?.reason == null) payload = { ...payload, reason };
|
|
703
716
|
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
|
|
704
717
|
ctx.effects.push(...result.effects);
|
|
705
718
|
},
|
|
@@ -867,6 +880,14 @@ const ACTIONS = {
|
|
|
867
880
|
},
|
|
868
881
|
cleanup_workspace(ctx) { ctx.effects.push({ kind: 'cleanup_workspace', id: ctx.id }); },
|
|
869
882
|
stop_run(ctx) { ctx.effects.push({ kind: 'stop_run', id: ctx.id, runId: ctx.runId }); },
|
|
883
|
+
// A review's pass is the work locked in: the dispatcher merges every PR the verdict judged, at the head it judged
|
|
884
|
+
// (dispatch.mjs mergeReviewed), and sends the card `reviewed_merged` once all are, or `merge_refused` when a head moved.
|
|
885
|
+
merge_reviewed(ctx) {
|
|
886
|
+
// kept until it is done: a merge GitHub did not answer is tried again next round
|
|
887
|
+
const effect = { kind: 'merge_reviewed', id: ctx.id };
|
|
888
|
+
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
889
|
+
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
890
|
+
},
|
|
870
891
|
// the run leaving the status is the one whose session closes, never a run started after it (a reclaim's successor)
|
|
871
892
|
close_session(ctx, slot) {
|
|
872
893
|
const through = ctx.runId ?? one(ctx.db, 'SELECT MAX(id) AS id FROM task_runs WHERE task_id = ?', ctx.id)?.id ?? 0;
|
|
@@ -1083,6 +1104,10 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1083
1104
|
// a block that has aged is told once, not every age interval for as long as it lasts
|
|
1084
1105
|
else if (policy.aged && policy.age_seconds != null && !block.aged_at) {
|
|
1085
1106
|
const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
|
|
1107
|
+
// The card's own history says whether this block was already told: `aged_at` lives in a row a board rebuilds
|
|
1108
|
+
// from its document (which does not carry it), so a restart forgot it and every unchanged block aged again.
|
|
1109
|
+
const told = one(db, `SELECT 1 FROM task_events WHERE task_id = ? AND kind IN (?, 'block_aged') AND json_extract(payload, '$.on') = ? AND created_at >= ? LIMIT 1`, id, policy.aged, block.id, Number(block.since ?? 0));
|
|
1110
|
+
if (told) { const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []); run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, aged_at: now() } : b)), id); continue; }
|
|
1086
1111
|
if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
|
|
1087
1112
|
const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
|
|
1088
1113
|
run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, aged_at: now() } : b)), id);
|
package/board/workspace.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
5
|
+
import { inspectCheckout } from '@volter/teams/checkout';
|
|
5
6
|
import { localMachine, normalMachine, commandEnv } from './dispatch.mjs';
|
|
6
7
|
|
|
7
8
|
// Repository identities are portable input values; the daemon owns their checkout resolution.
|
|
@@ -22,9 +23,14 @@ function workspaceDoor(machine, verb, value, options = {}) {
|
|
|
22
23
|
throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
|
|
23
24
|
}
|
|
24
25
|
const local = !machine || normalMachine(machine) === localMachine();
|
|
26
|
+
// A path on this machine is read by the process asking, with its own file access: the daemon (a launchd job) may have
|
|
27
|
+
// none on a volume macOS guards, where the session that named the path does, and a verdict waited on the daemon's
|
|
28
|
+
// EPERM. The daemon is asked when this process cannot read it.
|
|
29
|
+
let own = null;
|
|
30
|
+
if (local && verb === 'inspect') { try { return inspectCheckout(value); } catch (error) { own = error; } }
|
|
25
31
|
const params = verb === 'list' ? {} : verb === 'inspect' || verb === 'register' ? {path:value} : {repository:value,...options};
|
|
26
32
|
const r = runtimeDoor(local?null:machine,`harness.v1.workspaces.${verb}`,params,{env:commandEnv()});
|
|
27
|
-
if (r.code !== 0) throw new Error(`workspace on ${machine}: ${(r.stderr || r.stdout || r.error?.message).trim()}`);
|
|
33
|
+
if (r.code !== 0) throw new Error(`workspace on ${machine}: ${own ? `${own.message}; the machine daemon: ` : ''}${(r.stderr || r.stdout || r.error?.message).trim()}`);
|
|
28
34
|
return JSON.parse(r.stdout);
|
|
29
35
|
}
|
|
30
36
|
export function declareWorkspace(kind, value, machine, branch = null) {
|
package/board/ztrack.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The document is card state. One SQLite file beside it keeps the runtime tables the engine speaks
|
|
2
|
-
//
|
|
2
|
+
// and an index of the document's card spans. ADR 0001 and 0003.
|
|
3
3
|
// All markdown interpretation belongs to ztrack's public codec and kanban preset.
|
|
4
4
|
//
|
|
5
5
|
// A write is SQLite's own deferred transaction, retried whole when another writer committed first
|
|
@@ -10,16 +10,15 @@
|
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { createHash, randomBytes } from 'node:crypto';
|
|
12
12
|
import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
-
import { basename, dirname, resolve } from 'node:path';
|
|
13
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
|
-
import { actorOf, contention, event, json, now, principalOf
|
|
15
|
+
import { actorOf, contention, event, json, now, principalOf } from './store.mjs';
|
|
16
16
|
import { GitDocument } from './git-document.mjs';
|
|
17
17
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
18
18
|
|
|
19
19
|
/** Run `fn` as a named step of the running command (the slow log names its slowest step). */
|
|
20
20
|
const timedStep = (step, fn) => { const started = performance.now(); try { return fn(); } finally { noteStep(step, performance.now() - started); } };
|
|
21
|
-
import { fire,
|
|
22
|
-
import { completionPhase, inspectCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
21
|
+
import { fire, workflowFor } from './workflow.mjs';
|
|
23
22
|
|
|
24
23
|
const require = createRequire(import.meta.url);
|
|
25
24
|
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
@@ -53,30 +52,28 @@ CREATE TABLE IF NOT EXISTS board_chunks (cid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
53
52
|
CREATE TABLE IF NOT EXISTS board_tasks (id TEXT PRIMARY KEY, key TEXT NOT NULL, sources TEXT);
|
|
54
53
|
CREATE INDEX IF NOT EXISTS board_tasks_key ON board_tasks(key);
|
|
55
54
|
CREATE TABLE IF NOT EXISTS board_errors (id TEXT PRIMARY KEY, reason TEXT NOT NULL);
|
|
56
|
-
|
|
55
|
+
DROP TABLE IF EXISTS board_export;`;
|
|
57
56
|
// board_state: `published` (each document's hash as the tables describe it), `chunks:<role>` (the
|
|
58
57
|
// document's spans in order: chunk id, card id, heading level, length, and the hash of the card
|
|
59
58
|
// record the span reads as), `pending` and `pending_hashes` (a publication committed but not yet
|
|
60
|
-
// written to the document)
|
|
59
|
+
// written to the document). board_tasks: a subtask's key on its arc's Tasks list
|
|
61
60
|
// (`<arc>:c3`) and its `source:` lines, which live only in the document.
|
|
62
61
|
|
|
63
|
-
export function openZtrackBoard(marker, { create = false, document = null, seed = null, init, root, slug
|
|
62
|
+
export function openZtrackBoard(marker, { create = false, document = null, seed = null, init, root, slug }) {
|
|
64
63
|
if (!existsSync(marker)) {
|
|
65
64
|
if (!create) throw new Error(`no ztrack board ${marker}`);
|
|
66
65
|
mkdirSync(dirname(marker), { recursive: true });
|
|
67
66
|
atomic(marker, JSON.stringify({ format: 1, document: resolve(document ?? resolve(dirname(marker), 'arcs.md')) }) + '\n');
|
|
68
67
|
}
|
|
69
68
|
const config = JSON.parse(read(marker));
|
|
70
|
-
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug
|
|
71
|
-
// A board made here is in Hermes's kanban.db at once, not after its first write.
|
|
72
|
-
if (create) backing.exportFollowing();
|
|
69
|
+
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug });
|
|
73
70
|
return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), close: () => backing.close(), document: backing.path };
|
|
74
71
|
}
|
|
75
72
|
|
|
76
|
-
const behindSaid = new Set();
|
|
73
|
+
const behindSaid = new Set(), heartbeatSaid = new Set();
|
|
77
74
|
|
|
78
75
|
class ZtrackBoard {
|
|
79
|
-
constructor({ document, seed, marker, init, root, slug,
|
|
76
|
+
constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {} }) {
|
|
80
77
|
this.path = resolve(dirname(marker), document);
|
|
81
78
|
this.archive = `${this.path}.archive.md`;
|
|
82
79
|
this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
|
|
@@ -98,7 +95,18 @@ class ZtrackBoard {
|
|
|
98
95
|
// A push younger than a minute is still on its way.
|
|
99
96
|
if (late && Date.now() - late.since > 60_000) process.stderr.write(`board: warning: ${late.commits} board document commit${late.commits === 1 ? '' : 's'} not published since ${new Date(late.since).toISOString().slice(0, 16)}Z${late.error ? ` (last push: ${late.error})` : ''}; the dispatcher's next round retries\n`);
|
|
100
97
|
}
|
|
101
|
-
|
|
98
|
+
// The dispatcher's standing errors are said the same way, on every workflow call until a round clears them: a round
|
|
99
|
+
// that has not completed in five minutes, and each card or run the last round could not start, read or sync.
|
|
100
|
+
if (root && !heartbeatSaid.has(root)) {
|
|
101
|
+
heartbeatSaid.add(root);
|
|
102
|
+
try {
|
|
103
|
+
const beat = JSON.parse(readFileSync(join(root, 'dispatcher-heartbeat.json'), 'utf8'));
|
|
104
|
+
const age = Date.now() - Date.parse(beat.at);
|
|
105
|
+
if (age > 300_000) process.stderr.write(`board: error: the dispatcher has completed no round since ${String(beat.at).slice(0, 16)}Z; nothing starts, moves or is supervised\n`);
|
|
106
|
+
for (const error of beat.errors ?? []) process.stderr.write(`board: error: ${error}\n`);
|
|
107
|
+
} catch { /* a board no dispatcher serves yet has no heartbeat */ }
|
|
108
|
+
}
|
|
109
|
+
this.seed = seed; this.root = root; this.slug = slug; this.init = init;
|
|
102
110
|
this.file = `${marker}.board.db`;
|
|
103
111
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
104
112
|
const legacy = `${marker}.runtime.db`;
|
|
@@ -164,8 +172,7 @@ class ZtrackBoard {
|
|
|
164
172
|
try { this.init(this.conn, { fresh: true }); this.conn.exec(BOARD_TABLES); break; }
|
|
165
173
|
catch (error) { if (!sqliteContention(error)) throw error; backoff(attempt); }
|
|
166
174
|
}
|
|
167
|
-
// What a transaction touched, for the
|
|
168
|
-
// text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
|
|
175
|
+
// What a transaction touched, for the renderer (cards whose text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
|
|
169
176
|
// own conflict clause yields to the outer statement's, so duplicates are removed when read.
|
|
170
177
|
this.conn.exec('CREATE TEMP TABLE IF NOT EXISTS board_dirty (name TEXT NOT NULL, key TEXT NOT NULL); CREATE TEMP TABLE IF NOT EXISTS board_touched (id TEXT)');
|
|
171
178
|
this.tables = new Map();
|
|
@@ -267,9 +274,7 @@ class ZtrackBoard {
|
|
|
267
274
|
const snapshot = state('snapshot') ?? {};
|
|
268
275
|
for (const [key, id] of Object.entries(snapshot.taskIds ?? {})) db.prepare('INSERT OR REPLACE INTO board_tasks (id, key) VALUES (?, ?)').run(id, key);
|
|
269
276
|
for (const [id, reason] of snapshot.errors ?? []) db.prepare('INSERT OR REPLACE INTO board_errors VALUES (?, ?)').run(id, reason);
|
|
270
|
-
for (const change of state('export_pending') ?? []) if (change.source) db.prepare('INSERT OR IGNORE INTO board_export (name, key) VALUES (?, ?)').run(change.source.name, change.source.key);
|
|
271
277
|
const put = (key, value) => db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run(key, JSON.stringify(value));
|
|
272
|
-
put('hermes_cursor', state('hermes_cursor') ?? 0);
|
|
273
278
|
// The document as the tables last described it is indexed now, with each task's sources. A
|
|
274
279
|
// document edited since is left unindexed, so the first write reads all of it as an edit.
|
|
275
280
|
const published = {};
|
|
@@ -373,30 +378,26 @@ class ZtrackBoard {
|
|
|
373
378
|
try { timedStep('board git pull', () => this.git?.pull()); } catch (error) { process.stderr.write(`board: ${error.message}\n`); }
|
|
374
379
|
let txStarted = 0;
|
|
375
380
|
for (let attempt = 0; ; attempt++) {
|
|
376
|
-
let outcome
|
|
381
|
+
let outcome;
|
|
377
382
|
try {
|
|
378
383
|
txStarted = performance.now();
|
|
379
|
-
// Reading Hermes's export meets its writer (another process exporting) as contention, retried with the write.
|
|
380
|
-
hermes = this.seed ? null : this.prepareHermes();
|
|
381
384
|
this.dropOverlay();
|
|
382
385
|
this.conn.exec('BEGIN');
|
|
383
386
|
this.active = true; this.contended = false;
|
|
384
387
|
this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched');
|
|
385
388
|
this.refused = new Set();
|
|
386
|
-
this.loadErrors();
|
|
387
|
-
this.recover();
|
|
389
|
+
timedStep('board tx errors', () => this.loadErrors());
|
|
390
|
+
timedStep('board tx recover', () => this.recover());
|
|
388
391
|
if (this.seed) this.indexForSeed();
|
|
389
392
|
else {
|
|
390
|
-
const stale = this.staleDocuments({ pending: false });
|
|
391
|
-
if (stale.length) this.applyPlan(this.observe(stale), { overlay: false });
|
|
393
|
+
const stale = timedStep('board tx stale check', () => this.staleDocuments({ pending: false }));
|
|
394
|
+
if (stale.length) timedStep('board tx reread', () => this.applyPlan(this.observe(stale), { overlay: false }));
|
|
392
395
|
}
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
this.
|
|
396
|
-
const result = fn(this.db);
|
|
396
|
+
if (this.seed) this.seedRows();
|
|
397
|
+
timedStep('board tx snapshot', () => this.snapshotHeld());
|
|
398
|
+
const result = timedStep('board tx body', () => fn(this.db));
|
|
397
399
|
if (this.contended) throw RETRY;
|
|
398
|
-
const files = this.render();
|
|
399
|
-
this.statement('INSERT INTO main.board_export (name, key) SELECT DISTINCT name, key FROM temp.board_dirty WHERE true ON CONFLICT(name, key) DO UPDATE SET rev = rev + 1').run();
|
|
400
|
+
const files = timedStep('board tx render', () => this.render());
|
|
400
401
|
const intent = files.length ? randomBytes(16).toString('hex') : null;
|
|
401
402
|
if (intent) {
|
|
402
403
|
this.put('pending', { intent, files });
|
|
@@ -424,7 +425,6 @@ class ZtrackBoard {
|
|
|
424
425
|
if (!byManager) try { timedStep('board git commit and push', () => this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}`)); }
|
|
425
426
|
catch (error) { process.stderr.write(`board: ${error.message}\n`); }
|
|
426
427
|
}
|
|
427
|
-
this.exportFollowing();
|
|
428
428
|
return outcome.result;
|
|
429
429
|
}
|
|
430
430
|
}
|
|
@@ -1028,196 +1028,6 @@ class ZtrackBoard {
|
|
|
1028
1028
|
this.statement('UPDATE main.task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), task.current_run_id);
|
|
1029
1029
|
}
|
|
1030
1030
|
}
|
|
1031
|
-
this.put('hermes_cursor', Number(source.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// ------------------------------------------------------------------ Hermes compatibility
|
|
1035
|
-
|
|
1036
|
-
prepareHermes() {
|
|
1037
|
-
if (!this.compatLaidOut()) return null;
|
|
1038
|
-
let entry; try { entry = workflowFor(this.root); } catch { return null; }
|
|
1039
|
-
const compat = new DatabaseSync(this.compatPath, { readOnly: true });
|
|
1040
|
-
try {
|
|
1041
|
-
const cursor = this.get('hermes_cursor', 0);
|
|
1042
|
-
const prepared = { entry, evidence: new Map(), cursor };
|
|
1043
|
-
prepared.rows = compat.prepare('SELECT * FROM task_events WHERE id > ? ORDER BY id').all(cursor);
|
|
1044
|
-
for (const row of prepared.rows) {
|
|
1045
|
-
const payload = json(row.payload, {}) ?? {};
|
|
1046
|
-
if (row.kind !== 'completed' || payload.__supercode_export || !this.db.prepare('SELECT 1 FROM tasks WHERE id=?').get(row.task_id)) continue;
|
|
1047
|
-
prepared.evidence.set(row.id, inspectCompletionEvidence(this.board(), row.task_id, entry, payload, completionPhase(this.board(), row.task_id, entry, 'complete', payload, ['hermes'])));
|
|
1048
|
-
}
|
|
1049
|
-
return prepared;
|
|
1050
|
-
} finally { compat.close(); }
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
lift(prepared) {
|
|
1054
|
-
if (!prepared?.rows.length) return;
|
|
1055
|
-
const { entry, rows, evidence } = prepared;
|
|
1056
|
-
const before = actorOf();
|
|
1057
|
-
try {
|
|
1058
|
-
for (const row of rows) {
|
|
1059
|
-
const payload = json(row.payload, {}) ?? {};
|
|
1060
|
-
if (payload.__supercode_export) continue;
|
|
1061
|
-
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', row.task_id);
|
|
1062
|
-
if (!task) continue;
|
|
1063
|
-
this.conn.exec('SAVEPOINT hermes_card');
|
|
1064
|
-
try {
|
|
1065
|
-
const roles = ['manager', 'reviewer', 'platform', 'hermes'];
|
|
1066
|
-
setActor(payload.by ?? 'hermes');
|
|
1067
|
-
let name = null; let words = { ...payload, reason: payload.reason ?? 'unblocked in Hermes' };
|
|
1068
|
-
if (row.kind === 'unblocked') { name = 'unblock'; words = { ...words, reason: 'unblocked in Hermes', hermes: true }; }
|
|
1069
|
-
else if (row.kind === 'completed') { name = 'complete'; words.summary = payload.summary ?? task.result; words.ci = evidence.get(row.id); }
|
|
1070
|
-
else if (row.kind === 'blocked' || row.kind === 'dependency_wait') { name = 'block'; words.on = payload.on ?? payload.parent ?? null; words.hermes = true; }
|
|
1071
|
-
else if (row.kind === 'archived' && statusOf(this.db, task) !== 'done') name = 'cancel';
|
|
1072
|
-
if (row.kind === 'unblocked' && entry.workflow.verbs?.hermes_unpause) {
|
|
1073
|
-
const resumed = fire(this.db, this.board(), entry, task.id, 'hermes_unpause', { roles, payload: { hermes: true, reason: 'unblocked in Hermes' } });
|
|
1074
|
-
for (const effect of resumed.effects) this.statement('INSERT INTO main.supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
1075
|
-
}
|
|
1076
|
-
if (name) {
|
|
1077
|
-
const apply = () => fire(this.db, this.board(), entry, task.id, name, { roles, payload: words });
|
|
1078
|
-
const result = name === 'complete' ? withCompletionEvidence(this.board(), task.id, words.ci, apply) : apply();
|
|
1079
|
-
for (const effect of result.effects) this.statement('INSERT INTO main.supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
1080
|
-
}
|
|
1081
|
-
event(this.db, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
|
|
1082
|
-
this.conn.exec('RELEASE hermes_card');
|
|
1083
|
-
} catch (error) {
|
|
1084
|
-
this.conn.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
|
|
1085
|
-
event(this.db, task.id, 'hermes_refused', { by: 'hermes', source_event: row.id, kind: row.kind, reason: error.message });
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
} finally { setActor(before); }
|
|
1089
|
-
this.put('hermes_cursor', rows.at(-1).id);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// Changed rows reach kanban.db after their commit; a key stays queued until an export consumed
|
|
1093
|
-
// the revision it read.
|
|
1094
|
-
// The Hermes projection follows a committed write and never fails it. A failure keeps the queue for the next
|
|
1095
|
-
// write and is recorded as `export_error` (with a line on stderr) until an export succeeds.
|
|
1096
|
-
exportFollowing() {
|
|
1097
|
-
try {
|
|
1098
|
-
this.flushExport();
|
|
1099
|
-
if (this.get('export_error', null)) this.small(() => this.drop('export_error'));
|
|
1100
|
-
} catch (error) {
|
|
1101
|
-
process.stderr.write(`supercode workflow: kanban.db export deferred: ${error.message}\n`);
|
|
1102
|
-
try { this.small(() => this.put('export_error', { at: Date.now(), message: error.message })); } catch { /* the board itself is busy; the next write retries */ }
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
flushExport() {
|
|
1107
|
-
if (!this.compatPath) return;
|
|
1108
|
-
const queued = this.statement('SELECT name, key, rev FROM main.board_export').all();
|
|
1109
|
-
// A kanban.db that does not exist yet, or that Hermes has not laid out, is written whole: every card and row.
|
|
1110
|
-
const whole = !this.compatLaidOut();
|
|
1111
|
-
if (!whole && !queued.length) return;
|
|
1112
|
-
this.export(whole ? this.everyRow() : queued);
|
|
1113
|
-
if (queued.length) this.small(() => { for (const q of queued) this.statement('DELETE FROM main.board_export WHERE name = ? AND key = ? AND rev = ?').run(q.name, q.key, q.rev); });
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
// Whether kanban.db exists with Hermes's tables.
|
|
1117
|
-
compatLaidOut() {
|
|
1118
|
-
if (!this.compatPath || !existsSync(this.compatPath)) return false;
|
|
1119
|
-
let compat;
|
|
1120
|
-
try {
|
|
1121
|
-
compat = new DatabaseSync(this.compatPath, { readOnly: true });
|
|
1122
|
-
// a moment of another process's export is not an absent export
|
|
1123
|
-
return retryContention(() => compat.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('tasks', 'task_events')").get().n) === 2;
|
|
1124
|
-
} catch { return false; } finally { compat?.close(); }
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
everyRow() {
|
|
1128
|
-
const rows = [];
|
|
1129
|
-
for (const table of this.tables.values()) {
|
|
1130
|
-
for (const row of this.statement(`SELECT ${table.keys.map(quote).join(',')} FROM main.${quote(table.name)}`).all()) rows.push({ name: table.name, key: JSON.stringify(table.keys.map((k) => row[k])) });
|
|
1131
|
-
}
|
|
1132
|
-
return rows;
|
|
1133
1031
|
}
|
|
1134
1032
|
|
|
1135
|
-
rowOf(name, key) {
|
|
1136
|
-
const table = this.tables.get(name);
|
|
1137
|
-
if (!table) return null;
|
|
1138
|
-
return this.one(`SELECT * FROM main.${quote(name)} WHERE ${table.keys.map((k) => `${quote(k)} IS ?`).join(' AND ')}`, ...JSON.parse(key)) ?? null;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
export(queued) {
|
|
1142
|
-
let entry; try { entry = workflowFor(this.root); } catch { return; }
|
|
1143
|
-
const stored = (id) => storedStatus(this.db, this.board(), entry, id);
|
|
1144
|
-
const projected = new Set(queued.filter((q) => ['tasks', 'supercode_cards'].includes(q.name)).map((q) => JSON.parse(q.key)[0]));
|
|
1145
|
-
const compat = new DatabaseSync(this.compatPath);
|
|
1146
|
-
try {
|
|
1147
|
-
compat.exec('BEGIN'); this.init(compat, { fresh: true });
|
|
1148
|
-
// Native events and Hermes events share an export database, not an id sequence.
|
|
1149
|
-
// Bootstrap the old identity mapping once; new exports allocate independent Hermes ids.
|
|
1150
|
-
if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_export_events'").get()) {
|
|
1151
|
-
compat.exec('CREATE TABLE supercode_export_events (source_id INTEGER PRIMARY KEY, event_id INTEGER NOT NULL UNIQUE)');
|
|
1152
|
-
compat.exec("INSERT INTO supercode_export_events SELECT id, id FROM task_events WHERE json_extract(payload, '$.__supercode_export') = 1");
|
|
1153
|
-
// Repair existing lowered holds on upgrade, once; subsequent exports stay proportional to changes.
|
|
1154
|
-
for (const state of this.statement('SELECT task_id, paused, blocked_by FROM main.supercode_cards').all()) {
|
|
1155
|
-
if (json(state.paused, null) || json(state.blocked_by, []).some((b) => b.kind === 'message')) projected.add(state.task_id);
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
// Every import is event-based. Existing unconsumed acts must never be erased by an export race.
|
|
1159
|
-
const cursor = this.get('hermes_cursor', 0);
|
|
1160
|
-
const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
|
|
1161
|
-
if (unseen) throw new Error('Hermes wrote during board export; retained its events for the next read');
|
|
1162
|
-
const sources = new Map(queued.map((q) => [`${q.name}:${q.key}`, { name: q.name, key: q.key, row: this.rowOf(q.name, q.key) }]));
|
|
1163
|
-
for (const id of projected) {
|
|
1164
|
-
const row = this.rowOf('tasks', JSON.stringify([id]));
|
|
1165
|
-
const state = this.rowOf('supercode_cards', JSON.stringify([id]));
|
|
1166
|
-
const status = row ? stored(id) : null;
|
|
1167
|
-
sources.set(`tasks:${JSON.stringify([id])}`, { name: 'tasks', key: JSON.stringify([id]), row: row && { ...row, status: status.stored } });
|
|
1168
|
-
if (state || !row) sources.set(`supercode_cards:${JSON.stringify([id])}`, { name: 'supercode_cards', key: JSON.stringify([id]), row: state && { ...state, stage: status?.status ?? state.stage } });
|
|
1169
|
-
}
|
|
1170
|
-
for (const { name, key, row: source } of sources.values()) {
|
|
1171
|
-
if (name === 'sqlite_sequence') continue;
|
|
1172
|
-
if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
|
|
1173
|
-
const table = this.tables.get(name);
|
|
1174
|
-
if (name === 'task_events') {
|
|
1175
|
-
const sourceId = JSON.parse(key)[0];
|
|
1176
|
-
const mapped = compat.prepare('SELECT event_id FROM supercode_export_events WHERE source_id = ?').get(sourceId);
|
|
1177
|
-
if (!source) {
|
|
1178
|
-
if (mapped) compat.prepare('DELETE FROM task_events WHERE id = ?').run(mapped.event_id);
|
|
1179
|
-
compat.prepare('DELETE FROM supercode_export_events WHERE source_id = ?').run(sourceId);
|
|
1180
|
-
continue;
|
|
1181
|
-
}
|
|
1182
|
-
const row = { ...source, payload: JSON.stringify({ ...json(source.payload, {}), __supercode_export: true }) };
|
|
1183
|
-
delete row.id;
|
|
1184
|
-
const keys = Object.keys(row);
|
|
1185
|
-
if (mapped) {
|
|
1186
|
-
const previous = compat.prepare('SELECT * FROM task_events WHERE id = ?').get(mapped.event_id);
|
|
1187
|
-
if (previous && keys.every((k) => previous[k] === row[k])) continue;
|
|
1188
|
-
compat.prepare(`UPDATE task_events SET ${keys.map((k) => `${quote(k)} = ?`).join(',')} WHERE id = ?`).run(...keys.map((k) => row[k]), mapped.event_id);
|
|
1189
|
-
} else {
|
|
1190
|
-
const inserted = compat.prepare(`INSERT INTO task_events (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => row[k]));
|
|
1191
|
-
compat.prepare('INSERT INTO supercode_export_events (source_id, event_id) VALUES (?, ?)').run(sourceId, inserted.lastInsertRowid);
|
|
1192
|
-
}
|
|
1193
|
-
continue;
|
|
1194
|
-
}
|
|
1195
|
-
const where = table.keys.map((k) => `${quote(k)} IS ?`).join(' AND ');
|
|
1196
|
-
if (!source) { compat.prepare(`DELETE FROM ${quote(name)} WHERE ${where}`).run(...JSON.parse(key)); continue; }
|
|
1197
|
-
const columns = new Set(compat.prepare(`PRAGMA table_info(${quote(name)})`).all().map((c) => c.name));
|
|
1198
|
-
const keys = Object.keys(source).filter((k) => columns.has(k));
|
|
1199
|
-
const previous = compat.prepare(`SELECT * FROM ${quote(name)} WHERE ${where}`).get(...JSON.parse(key));
|
|
1200
|
-
if (previous && keys.every((k) => previous[k] === source[k])) continue;
|
|
1201
|
-
compat.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => source[k]));
|
|
1202
|
-
}
|
|
1203
|
-
// Hermes recomputes ready on read. Its sticky block is the last blocked/unblocked event,
|
|
1204
|
-
// so lowering the status alone cannot preserve a pause (including one made by a document edit).
|
|
1205
|
-
for (const id of projected) {
|
|
1206
|
-
const state = compat.prepare('SELECT t.status, c.stage, c.paused, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE t.id = ?').get(id);
|
|
1207
|
-
if (!state) continue;
|
|
1208
|
-
const sticky = state.status === 'blocked' && (json(state.paused, null) || json(state.blocked_by, []).some((b) => b.kind === 'message'));
|
|
1209
|
-
const last = compat.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked','unblocked') ORDER BY id DESC LIMIT 1").get(id);
|
|
1210
|
-
if (sticky) compat.prepare("UPDATE tasks SET block_kind = 'needs_input' WHERE id = ?").run(id);
|
|
1211
|
-
const cleared = !sticky && json(last?.payload, {})?.__supercode_sticky;
|
|
1212
|
-
if ((sticky && last?.kind !== 'blocked') || (cleared && last?.kind === 'blocked')) {
|
|
1213
|
-
const payload = { __supercode_export: true, __supercode_sticky: true, block_kind: 'needs_input', source_status: state.stage,
|
|
1214
|
-
reason: json(state.paused, null)?.reason ?? (sticky ? 'waiting on a message' : 'lowered hold cleared') };
|
|
1215
|
-
compat.prepare('INSERT INTO task_events (task_id, kind, payload, created_at) VALUES (?, ?, ?, ?)').run(id, sticky ? 'blocked' : 'unblocked', JSON.stringify(payload), now());
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
compat.exec('COMMIT');
|
|
1219
|
-
// Exported events carry an origin marker and never advance the Hermes input cursor.
|
|
1220
|
-
} catch (error) { try { compat.exec('ROLLBACK'); } catch { /* not begun */ } throw error; }
|
|
1221
|
-
finally { compat.close(); }
|
|
1222
|
-
}
|
|
1223
1033
|
}
|
package/index.d.ts
CHANGED
|
@@ -123,6 +123,7 @@ export function finishAgentAssignment(root: Path, org: string, assignment: strin
|
|
|
123
123
|
export function synchronizeAgentEnds(root: Path, org: string, options?: {context?: string}): Array<{agent: string; error: string}>;
|
|
124
124
|
export function recordAgentRun(root: Path, org: string, id: string, run: {session: string; machine?: string | null; run: string | number; profile?: unknown; main?: boolean; starterSession?: string | null}): void;
|
|
125
125
|
export function pendingAgentRuns(root: Path, org: string): Array<Record<string, unknown>>;
|
|
126
|
+
export function abandonAgentRun(root: Path, org: string, session: string): void;
|
|
126
127
|
export function synchronizeAgentRun(root: Path, org: string, row: {agent_id: string; session_ref: string; machine?: string | null; main?: boolean | number; starter_session?: string | null}, options?: {context?: string; intended?: unknown}): import('@volter/teams/http-client').SessionIdentity & {runtime: unknown};
|
|
127
128
|
export function agentRunForSession(root: Path, references: Array<string | null | undefined>): Record<string, unknown> | null;
|
|
128
129
|
export function agentPreamble(agent: ReturnType<typeof ensureCardAgent>, options?: {main?: boolean; previous?: Array<{session: string; machine?: string | null; ended_at?: number | null; end_reason?: string | null}>}): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.24",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.58",
|
|
28
|
-
"@volter/teams": "0.2.
|
|
28
|
+
"@volter/teams": "0.2.11",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|