@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 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
- const runRetry=new Map();
375
- const retryKey=(org,row)=>`${org}/${row.agent_id}/${row.session_ref}`;
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
- try{const answer=joinAgentRun(root,org,row,{context,intended});runRetry.delete(retryKey(org,row));return answer;}
380
+ let answer;
381
+ try{answer=joinAgentRun(root,org,row,{context,intended});}
379
382
  catch(error){
380
- if(error?.claimRefused){const wait=Math.min((runRetry.get(retryKey(org,row))?.wait??60000)*2,3600000);runRetry.set(retryKey(org,row),{at:Date.now()+wait,wait});}
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=>!(runRetry.get(retryKey(org,row))?.at>Date.now()));}finally{db.close();}}
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
- Hermes Kanban's verbs, with their flags:
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 (Hermes's emergency stop, the ESTOP file: new starts stop, running sessions go on)
51
+ pause [reason] · resume (the board's emergency stop: new starts stop, running sessions go on)
52
52
 
53
- supercode's own:
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 Hermes's kanban.db (the default); files is one JSON file per
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 (Hermes's tenant is the first). A proven requester sponsors the card with the request as
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, compatPath: paths.sqlite });
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
- say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
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
- for (const r of await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true })) {
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; };
@@ -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(`closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
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
- if (r.code !== 0 || json(r.stdout.trim(), null)?.closed !== session.pane) throw new Error(`closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
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) { error = String(e.message).slice(-500); }
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
- setRunSession(db, row.id, { ...current, close_attempted_at: at, close_attempts: Number(current.close_attempts ?? 0) + 1, close_error: error });
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: true }, row.id);
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 lastAt = Math.floor(Number(live?.last_message_at_ms) / 1000);
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(live.last_message_at_ms) : null,
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
- recordAgentRun(board.root,session.agent_org,session.agent_id,{session:`${session.harness}:${session.session_id}`,machine:session.machine,run:row.id});
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.code==='rate_limited')break;}
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
- const latest = new Map(events.filter((ev) => !['commented', 'answered'].includes(ev.kind)).map((ev) => [ev.kind, ev.id]));
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
- 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; }
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) {
@@ -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'};
@@ -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, compatPath: backingPaths(root, slug).sqlite }), root, slug, path, backing: use };
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);
@@ -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
- if (db.boardErrors?.has(id)) throw new Error(`${id}: ${db.boardErrors.get(id)}`);
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);
@@ -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
- // (Hermes's, with supercode's own) and an index of the document's card spans. ADR 0001 and 0003.
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, retryContention, setActor } from './store.mjs';
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, statusOf, storedStatus, workflowFor } from './workflow.mjs';
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
- CREATE TABLE IF NOT EXISTS board_export (name TEXT NOT NULL, key TEXT NOT NULL, rev INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (name, key));`;
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), `hermes_cursor`. board_tasks: a subtask's key on its arc's Tasks list
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, compatPath }) {
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, compatPath });
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, compatPath, commit = false, manager = null, archive = {} }) {
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
- this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
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 Hermes export (every row) and the renderer (cards whose
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, hermes = null;
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 (hermes && hermes.cursor !== this.get('hermes_cursor', 0)) throw RETRY;
394
- if (this.seed) this.seedRows(); else this.lift(hermes);
395
- this.snapshotHeld();
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.23",
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.10",
28
+ "@volter/teams": "0.2.11",
29
29
  "@volter/ztrack": "^2.4.23",
30
30
  "yaml": "^2.9.0"
31
31
  },