@volter/supercode-orchestrator 0.5.210 → 0.5.212

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.
@@ -27,11 +27,12 @@ import { readMachineFleet } from './machine-fleet.mjs';
27
27
  import { homeFleetMachines } from './fleet-targets.mjs';
28
28
  import { delegatedWork, idleInterval } from './session-work.mjs';
29
29
  import { localSessionDelegations } from '../agents.mjs';
30
- import { creatorOf, sessionProgress } from './workflow.mjs';
30
+ import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
31
31
  import { boardLabel, boardName } from '@volter/teams/board-name';
32
32
  let fleetRoot = null;
33
33
  import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
34
34
  import { setImmediate as yieldToOwner } from 'node:timers/promises';
35
+ import { ACTIVE_RUN_IDS, latestEvent, readRunPages } from './queries.mjs';
35
36
  import { runtimeDoor } from './runtime-door.mjs';
36
37
  import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
37
38
  import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
@@ -50,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
50
51
  import { basename, dirname, join } from 'node:path';
51
52
  import { createHash, randomUUID } from 'node:crypto';
52
53
  import { fileURLToPath } from 'node:url';
53
- import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
54
+ import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
54
55
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
55
56
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
56
57
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
@@ -829,7 +830,14 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
829
830
  const out = [];
830
831
  for (const slug of slugs) {
831
832
  try {
832
- const b = openBoard(root, slug);
833
+ // Capacity spans awaits. It needs its own committed snapshot, not the shared writer's total_changes counter:
834
+ // unrelated owner calls and delivery writes used to invalidate all three attempts at this read.
835
+ let b;
836
+ try { b = openBoardSnapshot(root, slug); }
837
+ catch (error) {
838
+ if (error.code !== 'board_read_by_owner') throw error;
839
+ b = openBoard(root, slug); // files/non-WAL backings retain their guarded optimistic read
840
+ }
833
841
  try {
834
842
  b.slug ??= slug; b.label ??= boardLabel(root, slug);
835
843
  // each run's session read before it is counted live or not (unknown stays unknown)
@@ -839,7 +847,7 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
839
847
  runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
840
848
  }
841
849
  finally { b.close(); }
842
- } catch (error) { if (error.capacityUnread) throw error; /* its own tick reports other board errors */ }
850
+ } catch (error) { error.capacityUnread = true; throw error; }
843
851
  }
844
852
  out.defaults = harnessDefaults(out);
845
853
  return out;
@@ -926,6 +934,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
926
934
  if (task.subtask_of || task.current_run_id) continue;
927
935
  const state = scopeFor(task).card;
928
936
  if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
937
+ if (state.status === 'running' && completedWork(board.db, task.id)) {
938
+ waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
939
+ }
929
940
  if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
930
941
  if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
931
942
  if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
@@ -990,7 +1001,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
990
1001
  const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
991
1002
  if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
992
1003
  assertWorkspaceOwner(db, task, checked);
993
- if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, task.id);
1004
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
994
1005
  if (!task.assignee && plan.profile) {
995
1006
  db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
996
1007
  event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
@@ -1075,7 +1086,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1075
1086
  const checked = await checkWorkspace(board, task);
1076
1087
  if (checked) runTx(board, id, runId, (db) => {
1077
1088
  assertWorkspaceOwner(db, task, checked);
1078
- db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
1089
+ db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1079
1090
  });
1080
1091
  const status = view(board, (db) => statusOf(db, task));
1081
1092
  const spec = runSpec(entry, status, task);
@@ -1208,7 +1219,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
1208
1219
  const moved = tx(board, (db) => {
1209
1220
  if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
1210
1221
  assertWorkspaceOwner(db, before, checked);
1211
- if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
1222
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1212
1223
  if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
1213
1224
  event(db, id, 'dispatch_released', { reason: 'explicit start' });
1214
1225
  requestSessionClose(db,id);
@@ -1398,8 +1409,7 @@ function recordInputClose(board, row, session, reading) {
1398
1409
  /** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
1399
1410
  const closesUnderway = new Map();
1400
1411
  export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
1401
- const rows = view(board, (db) => db.prepare(`SELECT id, task_id, profile, ended_at, metadata FROM task_runs
1402
- WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
1412
+ const rows = await readRunPages(board.db, 'id, task_id, profile, ended_at, metadata', { task: taskId, where: 'metadata IS NOT NULL' });
1403
1413
  // Repair pre-transfer history once: only identities with an older unended reference need a write.
1404
1414
  const latest = new Map(), transfers = new Map();
1405
1415
  for (const row of [...rows].sort((a,b) => b.id-a.id)) {
@@ -1413,10 +1423,15 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1413
1423
  }
1414
1424
  }
1415
1425
  if (transfers.size) tx(board, (db) => {
1416
- for (const row of transfers.values()) retireSupersededRuns(db, row.task_id, row.id, json(row.metadata, {})?.supercode);
1426
+ for (const row of transfers.values()) {
1427
+ // The paged scan yielded. Transfer only from the successor's current retained identity.
1428
+ const current = db.prepare('SELECT task_id,metadata FROM task_runs WHERE id=?').get(row.id);
1429
+ if (current) retireSupersededRuns(db, current.task_id, row.id, json(current.metadata, {})?.supercode);
1430
+ }
1417
1431
  });
1418
1432
  const panes = new Map();
1419
1433
  for (const row of rows) {
1434
+ await yieldToOwner();
1420
1435
  let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1421
1436
  if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
1422
1437
  // A keyed launch is reconciled by its machine's own daemon (D137), never by a door the close waits on. A session
@@ -1445,7 +1460,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1445
1460
  continue;
1446
1461
  }
1447
1462
  }
1448
- if (session?.monitor_held && !session.close_requested) continue; // only an explicit close action ends this process
1463
+ if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
1449
1464
  if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
1450
1465
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
1451
1466
  // The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
@@ -1474,12 +1489,12 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1474
1489
  const observed = r.code === 0 ? json(r.stdout.trim(), null) : null;
1475
1490
  if (observed?.pane !== session.pane) continue;
1476
1491
  if (observed.present === true && observed.yours === true) {
1477
- if (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())
1492
+ if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
1478
1493
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); })) continue;
1479
1494
  finalFailure = false;
1480
1495
  } else if (observed.present !== false) continue;
1481
1496
  }
1482
- 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());
1497
+ const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
1483
1498
  // Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
1484
1499
  // same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
1485
1500
  // only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
@@ -1531,7 +1546,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1531
1546
  const observed = panes.get(machine);
1532
1547
  if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
1533
1548
  if (observed.live.has(session.pane)) {
1534
- 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())
1549
+ const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
1535
1550
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
1536
1551
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
1537
1552
  // An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
@@ -1583,7 +1598,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1583
1598
  // Removing only the card's own left every subtask notifying a closed session, each attempt a failed delivery,
1584
1599
  // every round. A later run that resumes it by id subscribes it again at its launch (and each round while it is
1585
1600
  // the card's current run).
1586
- const subscribed = 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()
1601
+ const subscribed = db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all()
1587
1602
  .some((r) => addressOf(json(r.metadata, {})?.supercode) === address && r.id !== row.id);
1588
1603
  const unsubscribed = subscribed ? 0 : db.prepare("DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id = ? AND (task_id = ? OR json_extract(delivery_metadata, '$.role') = 'worker')").run(address, row.task_id).changes;
1589
1604
  event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine, unsubscribed,
@@ -1612,7 +1627,18 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1612
1627
  return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
1613
1628
  };
1614
1629
  const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
1615
- const delegations = localSessionDelegations(board.root, localMachine());
1630
+ const running = 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();
1631
+ const edges = localSessionDelegations(board.root, localMachine());
1632
+ const reached = new Set(running.map(({ id }) => addressOf(currentRun(board.db, id)?.metadata?.supercode)).filter(Boolean));
1633
+ const delegations = [];
1634
+ const pending = [...reached];
1635
+ const byStarter = new Map();
1636
+ for (const edge of edges) { const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children); }
1637
+ while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
1638
+ delegations.push(edge);
1639
+ if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
1640
+ }
1641
+ report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
1616
1642
  const delegatedReadings = [...fleet.sessions];
1617
1643
  // Forks and concurrent sessions need not have a pane. Read only the exact
1618
1644
  // native identities this home's starter receipts name, before any write.
@@ -1628,7 +1654,6 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1628
1654
  if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
1629
1655
  } catch { /* an unread child is never called working */ }
1630
1656
  }
1631
- const running = 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();
1632
1657
  // every running card a session is on: a session waiting on its user is told once, naming them all
1633
1658
  const cardsOf = new Map();
1634
1659
  for (const { id } of running) {
@@ -1722,7 +1747,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1722
1747
  const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
1723
1748
  if (session.held_notice?.key === key) return;
1724
1749
  // every run a card works under: an ended run can still be its card's current one
1725
- const told = db.prepare('SELECT r.metadata FROM task_runs r WHERE r.id != ? AND (r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id))').all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
1750
+ const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
1726
1751
  setRunSession(db, runRow.id, { held_notice: { key, at: observedAt } });
1727
1752
  if (told) return;
1728
1753
  const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null });
@@ -1752,7 +1777,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1752
1777
  if (reading.quiet_seconds < settings.idleQuiet) { retain('turn_unknown'); return; }
1753
1778
  if (session.unknown_notice) return;
1754
1779
  const key = [address, live?.turn_since_ms ?? 'unknown'].join('|');
1755
- const told = db.prepare('SELECT r.metadata FROM task_runs r WHERE r.id != ? AND (r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id))').all(runRow.id)
1780
+ const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
1756
1781
  .some(r => json(r.metadata, {})?.supercode?.unknown_notice?.key === key);
1757
1782
  setRunSession(db, runRow.id, { unknown_notice: { key, at: observedAt, since } });
1758
1783
  const observation = retain('turn_unknown_reported', { waiting_for: live?.waiting_for ?? null, turn_source: live?.turn_source ?? null, since_ms: live?.turn_since_ms ?? null });
@@ -2115,9 +2140,12 @@ async function readRunUsage(root,org,row,endedAt){
2115
2140
  async function synchronizeAgents(board, report) {
2116
2141
  // A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
2117
2142
  // A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
2118
- const agentRuns=[...new Map(view(board,db=>db.prepare("SELECT id,task_id,metadata,ended_at FROM task_runs WHERE json_extract(metadata,'$.supercode.agent_id') IS NOT NULL ORDER BY id").all())
2143
+ const agentRuns=[...new Map((await readRunPages(board.db, 'id,task_id,metadata,ended_at', { where: "json_extract(metadata,'$.supercode.agent_id') IS NOT NULL" }))
2119
2144
  .map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
2120
- for(const row of agentRuns){
2145
+ for(const scanned of agentRuns){
2146
+ await yieldToOwner();
2147
+ const row=view(board,db=>db.prepare('SELECT id,task_id,metadata,ended_at FROM task_runs WHERE id=?').get(scanned.id));
2148
+ if(!row)continue;
2121
2149
  const session=json(row.metadata,{})?.supercode;
2122
2150
  if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
2123
2151
  const ref=`${session.harness}:${session.session_id}`;
@@ -2276,6 +2304,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2276
2304
  const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
2277
2305
  .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
2278
2306
  .find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at
2307
+ // Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
2308
+ // checks below; an explicit close or another unresolved identity still holds the slot.
2309
+ && !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
2310
+ && r.session.session_id === last.session_id && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
2279
2311
  && !(closeRefused(r.session) && last?.session_id && r.session.session_id === last.session_id));
2280
2312
  if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
2281
2313
  let prev = view(board, (db) => lastSession(db, task.id, slot));
@@ -2423,13 +2455,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2423
2455
  if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
2424
2456
  if (next === 'keep') {
2425
2457
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2426
- const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, ...kept } = prev;
2458
+ const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, ...kept } = prev;
2427
2459
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2428
2460
  // a kept session is told what this run is for (its card came back to it, with what happened since), only while
2429
2461
  // this run is still the card's current one (its pane and native reads were awaited)
2430
2462
  tx(board, (db) => {
2431
2463
  if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its session was read; nothing was told to it`), { launchSuperseded: true });
2432
2464
  setRunSession(db, runId, { ...session, ...meta });
2465
+ transferRetainedSession(db, task.id, runId, session);
2433
2466
  // an earlier run's close of this pane is this run's now: the pane is the kept session's, not a close to retry
2434
2467
  if (prev.pane) for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
2435
2468
  const s = json(r.metadata, {})?.supercode;
@@ -2444,7 +2477,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2444
2477
  } else if (prev.resumable !== false) {
2445
2478
  const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
2446
2479
  const released = view(board,db=>{
2447
- const last = db.prepare("SELECT id,kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','restart_authorization_consumed') ORDER BY id DESC LIMIT 1").get(task.id);
2480
+ const last = latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'restart_authorization_consumed'], 'id,kind');
2448
2481
  return last?.kind === 'dispatch_released';
2449
2482
  });
2450
2483
  // Only a session whose run ended in a monitored incident waits for its manager or creator; one a workflow
@@ -2498,6 +2531,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2498
2531
  const saved = json(row?.metadata, {})?.supercode;
2499
2532
  if (saved?.address === session.address && !saved.launch_pending) return true;
2500
2533
  setRunSession(db, runId, session);
2534
+ transferRetainedSession(db, task.id, runId, session);
2501
2535
  event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
2502
2536
  // the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
2503
2537
  // leaves the implementer's, and a subtask's arc session's, subscription alone)
@@ -2512,6 +2546,19 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2512
2546
  return session;
2513
2547
  }
2514
2548
 
2549
+ /** A continued role identity now belongs to its new attempt. Preserve the completed receipt, and keep an old
2550
+ * retained reference from closing the same native process/pane later. Transfer only after a successful bind. */
2551
+ function transferRetainedSession(db, id, runId, session) {
2552
+ if (!session?.session_id) return;
2553
+ for (const row of db.prepare('SELECT id,metadata FROM task_runs WHERE task_id=? AND id<? AND ended_at IS NOT NULL').all(id, runId)) {
2554
+ const old = json(row.metadata, {})?.supercode;
2555
+ if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
2556
+ || old.session_id !== session.session_id || old.machine !== session.machine || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
2557
+ setRunSession(db, row.id, { close_transferred_to: runId });
2558
+ event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
2559
+ }
2560
+ }
2561
+
2515
2562
  /** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
2516
2563
  * owner of the session where it records one in this run's org, else the agent its own run ran as. Null when it is
2517
2564
  * this run's agent, or no agent is known for it (a session from before card agents). */
@@ -2808,8 +2855,10 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2808
2855
  // agent store another, and every round after would trip on it.
2809
2856
  const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
2810
2857
  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}`);
2811
- // the card's previous session is read before the bind decides whether it is still live
2812
- await readBefore(fleet, view(board, (db) => lastSession(db, id)));
2858
+ // Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
2859
+ // adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
2860
+ const adoptionSlot = beforeSession?.slot ?? runSpec(entry0, view(board, db => statusOf(db, card0)), card0)?.session ?? 'worker';
2861
+ await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
2813
2862
  const adopted = tx(board, (db) => {
2814
2863
  const task = getTask(db, id);
2815
2864
  if (!task) throw new Error(`no card ${id}`);
@@ -2818,7 +2867,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2818
2867
  if (changed) throw changed;
2819
2868
  if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
2820
2869
  if (task.machine && normalMachine(task.machine) !== host) throw new Error(`${id} runs on ${task.machine}, and ${found.address} is on ${host}; move the card first`);
2821
- const was = lastSession(db, id);
2870
+ const was = lastSession(db, id, adoptionSlot);
2822
2871
  // a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
2823
2872
  // left running, and is read)
2824
2873
  if (was && was.session_id !== sessionId && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
@@ -2956,8 +3005,11 @@ async function fileMail(board, to, body, key, { ask = false, wake = true, subjec
2956
3005
  }
2957
3006
 
2958
3007
  /** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
2959
- export function fileFleetNotice(root, to, body, key) {
2960
- return fileMail({ root, label: boardLabel(root, 'default') }, to, body, key);
3008
+ export async function fileFleetNotice(root, to, body, key) {
3009
+ const board = openBoard(root, 'default');
3010
+ board.label = boardLabel(root, 'default');
3011
+ try { return await fileMail(board, to, body, key); }
3012
+ finally { board.close(); }
2961
3013
  }
2962
3014
 
2963
3015
  /**
@@ -3102,9 +3154,13 @@ function noticeWait(board, sub, report, reason = null) {
3102
3154
  * the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
3103
3155
  * holds it again.
3104
3156
  */
3105
- function sessionRecord(db) {
3157
+ function sessionRecord(db, address) {
3106
3158
  const held = new Set(), latest = new Map();
3107
- for (const r of db.prepare('SELECT r.id, r.task_id, r.ended_at, r.metadata, t.id AS current FROM task_runs r LEFT JOIN tasks t ON t.current_run_id = r.id ORDER BY r.id').all()) {
3159
+ const named = sessionAt(address);
3160
+ if (!named) return { held, latest };
3161
+ // Both arms seek one native identity; the join's current_run_id has its own index. Never read unrelated sessions.
3162
+ for (const r of db.prepare(`SELECT r.id,r.task_id,r.ended_at,r.metadata,t.id AS current FROM task_runs r
3163
+ LEFT JOIN tasks t ON t.current_run_id=r.id WHERE r.id IN (${NOTICE_RUN_IDS}) ORDER BY r.id`).all(named.session_id, address)) {
3108
3164
  const session = json(r.metadata, {})?.supercode, address = addressOf(session);
3109
3165
  if (!address) continue;
3110
3166
  if (!r.ended_at || r.current) held.add(address);
@@ -3152,10 +3208,10 @@ async function endWorkerSubscriptions(board, fleet, report) {
3152
3208
  if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
3153
3209
  }
3154
3210
  tx(board, (db) => {
3155
- const { held, latest } = sessionRecord(db);
3156
3211
  const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
3157
3212
  const ended = new Map();
3158
3213
  for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
3214
+ const { held, latest } = sessionRecord(db, row.chat_id);
3159
3215
  const address = row.chat_id, record = latest.get(address);
3160
3216
  // a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
3161
3217
  if (!record || record.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
@@ -3185,7 +3241,6 @@ async function endWorkerSubscriptions(board, fleet, report) {
3185
3241
  */
3186
3242
  async function notify(board, fleet, report, entry) {
3187
3243
  await endWorkerSubscriptions(board, fleet, report);
3188
- const record = view(board, (db) => sessionRecord(db));
3189
3244
  for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
3190
3245
  await yieldToOwner();
3191
3246
  if (stopping) break;
@@ -3198,6 +3253,7 @@ async function notify(board, fleet, report, entry) {
3198
3253
  const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
3199
3254
  .all(sub.task_id, sub.last_event_id, ...kinds);
3200
3255
  if (!events.length) { noticeWait(board, sub, report); continue; }
3256
+ const record = view(board, db => sessionRecord(db, sub.chat_id));
3201
3257
  // Events name the acting native session; subscriptions may name its agent.
3202
3258
  // Use the daemon's retained binding, including former mains, for both
3203
3259
  // coalescing and delivery. An unread binding proves no additional alias.
@@ -3511,14 +3567,37 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
3511
3567
  return out;
3512
3568
  }
3513
3569
 
3570
+ /** An explicit completion of the latest attempt, after any previous review or reopening. A reclaimed or failed
3571
+ * attempt is not a completed disposition, and a new attempt or returned verdict invalidates the old receipt. */
3572
+ function completedWork(db, id) {
3573
+ const last = db.prepare(`SELECT id,kind,run_id FROM task_events WHERE task_id=?
3574
+ AND kind IN ('work_completed','review_requested','changes_requested','review_pass','review_voided','reopened')
3575
+ ORDER BY id DESC LIMIT 1`).get(id);
3576
+ if (last?.kind !== 'work_completed') return null;
3577
+ const attempt = db.prepare('SELECT id,outcome,ended_at FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
3578
+ return attempt?.id === last.run_id && attempt.outcome === 'completed' && attempt.ended_at != null ? last : null;
3579
+ }
3580
+
3581
+ /** Finish ends the attempt, not its caller's native turn. Keep observing that retained attempt until the board
3582
+ * requests review; the receipt's lifecycle/latest-attempt fence prevents an older session holding later work. */
3583
+ function reviewTurnRun(db, id) {
3584
+ const active = currentRun(db, id);
3585
+ if (active) return active;
3586
+ const completed = completedWork(db, id);
3587
+ if (!completed || detachedCard(db, id)) return null;
3588
+ const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(completed.run_id);
3589
+ const metadata = json(row?.metadata, {}) ?? {}, session = metadata.supercode;
3590
+ return row && session?.retain_after_run && !session.close_transferred_to && !detachedSession(db, session) ? { ...row, metadata } : null;
3591
+ }
3592
+
3514
3593
  /** Whether an arc with no open PR may be one that changed no code and is due its review: it has done work, by filing
3515
- * tasks or by a run that ended with an Acceptance outcome ticked (a fresh arc with neither has not started), and no
3594
+ * tasks, an explicit completed-work receipt, or a completed run with an Acceptance outcome ticked, and no
3516
3595
  * review of it is pending or passed (after changes are requested, closing the tasks that own the findings makes it
3517
3596
  * due again). */
3518
3597
  function noCodeReviewDue(board, id) {
3519
3598
  return view(board, (db) => {
3520
3599
  const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
3521
- if (!filed && !(db.prepare('SELECT 1 FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL LIMIT 1').get(id) &&
3600
+ if (!filed && !completedWork(db, id) && !(db.prepare("SELECT 1 FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at IS NOT NULL LIMIT 1").get(id) &&
3522
3601
  outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
3523
3602
  const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
3524
3603
  return !last || ['changes_requested','review_voided'].includes(last.kind);
@@ -3534,8 +3613,8 @@ function passedReview(board, id) {
3534
3613
  function outcomeTicked(body) {
3535
3614
  let acceptance = false;
3536
3615
  return String(body ?? '').split('\n').some((line) => {
3537
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
3538
- if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
3616
+ const heading = cardSectionHeading(line);
3617
+ if (heading !== null) acceptance = heading === 'acceptance';
3539
3618
  return acceptance && /^\s*[-*] \[[xX]\] \S/.test(line);
3540
3619
  });
3541
3620
  }
@@ -3556,7 +3635,7 @@ function outcomeTicked(body) {
3556
3635
  * running a proof, still holds the review. */
3557
3636
  async function turnInFlight(board, id, fleet, heldSince = null) {
3558
3637
  if (!fleet?.ok) return null;
3559
- const session = currentRun(board.db, id)?.metadata?.supercode;
3638
+ const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
3560
3639
  if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
3561
3640
  let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
3562
3641
  if (live?.turn == null && session.surface === 'native') try {
@@ -3628,14 +3707,17 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3628
3707
  }
3629
3708
  // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
3630
3709
  // (`review_held`) so a goal turn can be told to have started after that work was first observed.
3631
- const heldRun = currentRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3710
+ const heldRun = reviewTurnRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3632
3711
  const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
3633
3712
  const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
3634
3713
  if (inTurn) {
3635
- if (!held && !dry && heldRun) tx(board, (db) => { if (currentRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
3714
+ if (!held && !dry && heldRun) tx(board, (db) => { if (reviewTurnRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
3636
3715
  report.queued.push([id, `review waits: ${inTurn.why}`]);
3637
3716
  continue;
3638
3717
  }
3718
+ // A stable local refusal (wrong HEAD or uncommitted work) must not spend a full fresh REST scan every
3719
+ // round. Advisory discovery is sufficient to refuse, never to authorize; the scan below still owns that.
3720
+ if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
3639
3721
  if (!dry) await refreshCardInputs(board, id, { commits: true });
3640
3722
  // Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
3641
3723
  const observed = await withFreshReviewReads(async () => {
@@ -3884,7 +3966,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
3884
3966
  if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
3885
3967
  // what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
3886
3968
  queueEffects(board, effects);
3887
- phase('goals');
3969
+ phase('idle');
3888
3970
  ensureSubscriptions(board, entry);
3889
3971
  fireHooks(board, settings, report);
3890
3972
  if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
package/board/engine.mjs CHANGED
@@ -292,7 +292,7 @@ export function createCard(board, spec) {
292
292
  const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
293
293
  if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
294
294
  const machine = spec.machine ?? arc?.machine ?? null;
295
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
295
+ run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.workspaceRoot ?? null,
296
296
  tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
297
297
  for (const p of parents) addDependency(db, entry, id, p);
298
298
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
@@ -468,8 +468,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
468
468
  // Authorization and the comment row share one transaction: a refusal stores nothing.
469
469
  if (name === 'comment') r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
470
470
  // the cards that wait on it, and its subtasks, see it change
471
- const related = all(db, 'SELECT task_id, blocked_by FROM supercode_cards').filter((c) => json(c.blocked_by, []).some((b) => b.id === id)).map((c) => c.task_id);
472
- const around = [...related, ...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
471
+ // Card blockers are mirrored to task_links by relation writes and document imports. Its parent key is the reverse
472
+ // lookup; reading every card's blocked_by JSON on each public verb only rediscovers the same children.
473
+ const around = [...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
473
474
  for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
474
475
  return r;
475
476
  });
@@ -532,7 +533,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
532
533
  // it keeps trying at the capped wait, and at EFFECT_ATTEMPTS failed attempts its card records `effect_stalled` and the
533
534
  // managers are mailed once, so a passed card whose merge keeps failing is seen, and still heals when the cause clears.
534
535
  export const EFFECT_ATTEMPTS = 6;
535
- const OWNED = new Set(['merge_reviewed', 'cleanup_workspace']);
536
+ const OWNED = new Set(['merge_reviewed', 'cleanup_workspace', 'rename_card_agents']);
536
537
  const effectWait = (attempts) => Math.min(1800, 30 * 2 ** Math.max(0, attempts - 1));
537
538
  // A delivery pass claims a row by compare-and-set on its next_at (set ahead only while the row is due) before trying it,
538
539
  // so two passes (serve's, the hosted loop's, a person's `workflow deliver`, a verb's own) never try one effect at once.
@@ -620,6 +621,7 @@ async function runEffect(board, e) {
620
621
  if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
621
622
  if (e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id); return true; }
622
623
  if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
624
+ if (e.kind === 'rename_card_agents') { const { renameCardAgentsEffect } = await import('./agent-declarations.mjs'); return await renameCardAgentsEffect(board, e); }
623
625
  // a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
624
626
  if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); await fileBoardMail(board, { undelivered: [] }, e.id); return true; }
625
627
  if (e.kind === 'merge_reviewed') { const { mergeReviewed } = await import('./dispatch.mjs'); return await mergeReviewed(board, e) !== false; }
@@ -738,17 +740,26 @@ export const editResult = (board, id, { result, summary = null, metadata = undef
738
740
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
739
741
  export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
740
742
 
741
- /** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
743
+ /** File the named card's tasks only if its workflow accepts decompose; refusal leaves no cards behind. */
742
744
  export async function decompose(board, id, children, { author = 'user', roles } = {}) {
743
745
  if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
744
- const task = view(board, (db) => must(db, id));
745
- const ids = [];
746
- for (const c of children) ids.push(createCard(board, await prepareCard(board, {
747
- title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
748
- workspace: task.workspace_anchor ? `${task.workspace_kind}:${task.workspace_anchor}` : task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
749
- })).id);
750
- await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
751
- return ids;
746
+ const entry = await loadWorkflow(board.root);
747
+ await refreshCardInputs(board, id);
748
+ const result = tx(board, db => {
749
+ const task = must(db, id), sender = roles ?? callerRoles(db, id);
750
+ if (task.subtask_of) throw new Error(`${id} is itself a subtask of ${task.subtask_of}; decompose its arc instead`);
751
+ // Creation and authorization share this transaction. Guards see the actual child ids, and any refusal
752
+ // (including a later child's invalid spec) rolls back every child, event, subscription and queued effect.
753
+ const ids = children.map(c => createCard(board, {
754
+ title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
755
+ subtaskOf: id, workspace: 'scratch', tenant: task.tenant, createdBy: author,
756
+ }).id);
757
+ const move = fire(db, board, entry, id, 'decompose', { roles: sender, payload: { children: ids, author } });
758
+ for (const child of ids) settle(db, board, entry, child, move.effects);
759
+ return { ids, effects: move.effects };
760
+ });
761
+ await runEffects(board, result.effects);
762
+ return result.ids;
752
763
  }
753
764
 
754
765
  /** `child` waits for `parent`. */
@@ -15,9 +15,9 @@ const refused = message => Object.assign(new Error(message), { code: 'fleet_list
15
15
  /** A retained mail outbox. `file` is the board's existing admitted mailbox filing door, never a transport or
16
16
  * provider selected from the observed payload. No runtime database is opened until the home opts into listeners. */
17
17
  export class FleetListenerRouter {
18
- constructor({ root, file, enrich = event => event, retry = () => {}, log = () => {} }) {
18
+ constructor({ root, file, enrich = event => event, work = fn => fn(), retry = () => {}, log = () => {} }) {
19
19
  if (typeof file !== 'function') throw refused('fleet listener router needs the admitted mailbox filing door');
20
- this.root = root; this.file = file; this.enrich = enrich; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
20
+ this.root = root; this.file = file; this.enrich = enrich; this.work = work; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
21
21
  }
22
22
 
23
23
  /** The workflow's decoded declarations. An invalid replacement refuses without discarding the prior outbox. */
@@ -37,7 +37,15 @@ export class FleetListenerRouter {
37
37
  this.suspended = false;
38
38
  if (Object.keys(next).length) {
39
39
  this.store();
40
- if (!this.timer) { this.timer = setInterval(() => { if (!this.suspended) { try { this.retry(); } catch (error) { this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`); } } this.wake(); }, 1000); this.timer.unref?.(); }
40
+ if (!this.timer) {
41
+ this.timer = setInterval(() => {
42
+ void Promise.resolve().then(() => {
43
+ if (!this.closed && !this.suspended) return this.retry();
44
+ }).catch(error => this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`))
45
+ .finally(() => this.wake());
46
+ }, 1000);
47
+ this.timer.unref?.();
48
+ }
41
49
  this.wake();
42
50
  } else if (this.db) {
43
51
  this.db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,1) ON CONFLICT(id) DO UPDATE SET needs_baseline=1').run();
@@ -212,7 +220,10 @@ export class FleetListenerRouter {
212
220
  flush() {
213
221
  if (this.flushing) return this.flushing;
214
222
  if (!this.db || this.closed || this.suspended) return Promise.resolve();
215
- this.flushing = Promise.resolve().then(async () => {
223
+ // Prepare owner work before replaying retained waits too. Enrichment and enqueue remain synchronous, so the
224
+ // source cursor cannot advance until its complete event body is committed to the outbox.
225
+ this.flushing = Promise.resolve().then(() => this.work(async () => {
226
+ if (this.closed || this.suspended) return;
216
227
  // Even a prompt that has since cleared keeps an earlier refused observation. Do not block already queued mail
217
228
  // on a full outbox: file it below to make room, then the retained prompt retries at the next wake.
218
229
  for (const wait of this.db.prepare('SELECT address,event FROM fleet_listener_waits WHERE routed=0 LIMIT 16').all()) {
@@ -238,7 +249,7 @@ export class FleetListenerRouter {
238
249
  this.db.prepare('UPDATE fleet_listener_mail SET attempts=?,next_at=?,error=? WHERE id=?').run(attempts, Date.now() + delay, String(error.message).slice(0, 500), row.id);
239
250
  }
240
251
  }
241
- }).finally(() => { this.flushing = null; });
252
+ })).finally(() => { this.flushing = null; });
242
253
  return this.flushing;
243
254
  }
244
255