@volter/supercode-orchestrator 0.5.211 → 0.5.213

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.
@@ -25,9 +25,9 @@ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
25
25
  import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
26
26
  import { readMachineFleet } from './machine-fleet.mjs';
27
27
  import { homeFleetMachines } from './fleet-targets.mjs';
28
- import { delegatedWork, idleInterval } from './session-work.mjs';
28
+ import { delegatedWork, idleInterval, questionText } 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';
@@ -51,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
51
51
  import { basename, dirname, join } from 'node:path';
52
52
  import { createHash, randomUUID } from 'node:crypto';
53
53
  import { fileURLToPath } from 'node:url';
54
- import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, 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';
55
55
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
56
56
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
57
57
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
@@ -813,6 +813,40 @@ async function stopOwnedSession(board, session) {
813
813
  }
814
814
  }
815
815
 
816
+ /**
817
+ * Answer the question card `id`'s session shows on its pane (VO-3395): the option `choice` names (its number or its
818
+ * label), chosen through the pane machine's `panes.answer` door, as this board, for `by` (who asked the board to answer,
819
+ * recorded as said). The card records `question_answered` with who answered, what was chosen and whether the pane
820
+ * showed the answer taken; a door that refused or could not be reached records nothing and throws.
821
+ */
822
+ export async function answerQuestion(board, id, choice, { by }) {
823
+ const run = view(board, (db) => { const task = getTask(db, id); return task?.current_run_id ? currentRun(db, id) : null; });
824
+ if (!run) throw new Error(`${id} has no active run, so no session of it is asking anything`);
825
+ const session = run.metadata?.supercode ?? {};
826
+ if (!session.pane) throw new Error(`${id}'s session runs in no pane a machine keeps (${session.surface ?? 'unknown surface'}); its question has no pane door`);
827
+ const asked = session.question ?? null;
828
+ const machine = normalMachine(session.machine ?? localMachine()) === localMachine() ? null : session.machine;
829
+ const r = await runtimeDoor(machine, 'harness.v1.panes.answer', {
830
+ // the board has held the asker to the card's manager, creator or a person: on its own machine it says so as
831
+ // --foreign does, since a Windows daemon takes no local caller as established (#1245) and would refuse the board
832
+ pane: session.pane, choice: String(choice), answerer: by ?? null, foreign: true,
833
+ ...(asked?.question ? { question: asked.question } : {}), ...(session.launch_key ? { launch: session.launch_key } : {}),
834
+ }, { timeout: 30_000, as: boardAddress(board.label) });
835
+ if (r.code !== 0) {
836
+ const failure = json(r.stderr.trim(), null);
837
+ throw new Error(`${id}: pane ${session.pane} on ${session.machine ?? localMachine()} was not answered: ${failure?.message ?? (r.stderr || r.stdout).trim().slice(-500)}`);
838
+ }
839
+ const answer = json(r.stdout.trim(), {});
840
+ tx(board, (db) => {
841
+ const current = currentRun(db, id);
842
+ event(db, id, 'question_answered', { by: by ?? null, through: boardAddress(board.label), pane: session.pane, machine: session.machine ?? null,
843
+ question: answer.question ?? asked?.question ?? null, choice: answer.choice ?? null, keys: answer.keys ?? null, answered: answer.answered === true,
844
+ ...(answer.reason ? { reason: answer.reason } : {}) }, current?.id === run.id ? run.id : null);
845
+ if (answer.answered === true && current?.id === run.id) setRunSession(db, run.id, { question: null });
846
+ });
847
+ return answer;
848
+ }
849
+
816
850
  // ------------------------------------------------------------------ starting, as the workflow's dispatch says
817
851
 
818
852
  /** The command form a card's session uses to reach its board: `--root` on the board's machine, `--at` elsewhere. */
@@ -830,7 +864,14 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
830
864
  const out = [];
831
865
  for (const slug of slugs) {
832
866
  try {
833
- const b = openBoard(root, slug);
867
+ // Capacity spans awaits. It needs its own committed snapshot, not the shared writer's total_changes counter:
868
+ // unrelated owner calls and delivery writes used to invalidate all three attempts at this read.
869
+ let b;
870
+ try { b = openBoardSnapshot(root, slug); }
871
+ catch (error) {
872
+ if (error.code !== 'board_read_by_owner') throw error;
873
+ b = openBoard(root, slug); // files/non-WAL backings retain their guarded optimistic read
874
+ }
834
875
  try {
835
876
  b.slug ??= slug; b.label ??= boardLabel(root, slug);
836
877
  // each run's session read before it is counted live or not (unknown stays unknown)
@@ -840,7 +881,7 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
840
881
  runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
841
882
  }
842
883
  finally { b.close(); }
843
- } catch (error) { if (error.capacityUnread) throw error; /* its own tick reports other board errors */ }
884
+ } catch (error) { error.capacityUnread = true; throw error; }
844
885
  }
845
886
  out.defaults = harnessDefaults(out);
846
887
  return out;
@@ -927,6 +968,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
927
968
  if (task.subtask_of || task.current_run_id) continue;
928
969
  const state = scopeFor(task).card;
929
970
  if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
971
+ if (state.status === 'running' && completedWork(board.db, task.id)) {
972
+ waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
973
+ }
930
974
  if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
931
975
  if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
932
976
  if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
@@ -991,7 +1035,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
991
1035
  const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
992
1036
  if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
993
1037
  assertWorkspaceOwner(db, task, checked);
994
- if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, task.id);
1038
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
995
1039
  if (!task.assignee && plan.profile) {
996
1040
  db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
997
1041
  event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
@@ -1076,7 +1120,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1076
1120
  const checked = await checkWorkspace(board, task);
1077
1121
  if (checked) runTx(board, id, runId, (db) => {
1078
1122
  assertWorkspaceOwner(db, task, checked);
1079
- db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
1123
+ db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1080
1124
  });
1081
1125
  const status = view(board, (db) => statusOf(db, task));
1082
1126
  const spec = runSpec(entry, status, task);
@@ -1209,7 +1253,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
1209
1253
  const moved = tx(board, (db) => {
1210
1254
  if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
1211
1255
  assertWorkspaceOwner(db, before, checked);
1212
- if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
1256
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1213
1257
  if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
1214
1258
  event(db, id, 'dispatch_released', { reason: 'explicit start' });
1215
1259
  requestSessionClose(db,id);
@@ -1450,7 +1494,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1450
1494
  continue;
1451
1495
  }
1452
1496
  }
1453
- if (session?.monitor_held && !session.close_requested) continue; // only an explicit close action ends this process
1497
+ if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
1454
1498
  if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
1455
1499
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
1456
1500
  // The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
@@ -1617,7 +1661,18 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1617
1661
  return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
1618
1662
  };
1619
1663
  const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
1620
- const delegations = localSessionDelegations(board.root, localMachine());
1664
+ 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();
1665
+ const edges = localSessionDelegations(board.root, localMachine());
1666
+ const reached = new Set(running.map(({ id }) => addressOf(currentRun(board.db, id)?.metadata?.supercode)).filter(Boolean));
1667
+ const delegations = [];
1668
+ const pending = [...reached];
1669
+ const byStarter = new Map();
1670
+ for (const edge of edges) { const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children); }
1671
+ while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
1672
+ delegations.push(edge);
1673
+ if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
1674
+ }
1675
+ report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
1621
1676
  const delegatedReadings = [...fleet.sessions];
1622
1677
  // Forks and concurrent sessions need not have a pane. Read only the exact
1623
1678
  // native identities this home's starter receipts name, before any write.
@@ -1633,7 +1688,6 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1633
1688
  if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
1634
1689
  } catch { /* an unread child is never called working */ }
1635
1690
  }
1636
- 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();
1637
1691
  // every running card a session is on: a session waiting on its user is told once, naming them all
1638
1692
  const cardsOf = new Map();
1639
1693
  for (const { id } of running) {
@@ -1720,23 +1774,35 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1720
1774
  }
1721
1775
  // a reading that shows the session no longer waiting ends the prompt; a round that did not read it does not
1722
1776
  if (session.held_notice && live && !asked) setRunSession(db, runRow.id, { held_notice: null });
1777
+ if (session.question && live && !(asked && live.question)) setRunSession(db, runRow.id, { question: null });
1723
1778
  if (asked) {
1724
1779
  if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
1725
- // One prompt is one notice: the same wait (what it waits for, since when its harness says) on the same
1726
- // session, whichever of its cards a round reads first and however long it stands.
1727
- const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
1780
+ // The question its pane shows, with its options, is the card's to carry while it shows (VO-3395): kept on the
1781
+ // run, said in its event and its manager's notice with the door that answers it.
1782
+ const question = live.question && Array.isArray(live.question.options) && live.question.options.length ? {
1783
+ question: String(live.question.question ?? '').slice(0, 1000),
1784
+ options: live.question.options.slice(0, 20).map((option) => ({ number: Number(option.number), label: String(option.label ?? '').slice(0, 300), ...(option.detail ? { detail: String(option.detail).slice(0, 300) } : {}) })),
1785
+ } : null;
1786
+ const asking = question ? JSON.stringify([question.question, question.options.map((option) => option.label)]) : '';
1787
+ if (question && JSON.stringify([session.question?.question, session.question?.options?.map((option) => option.label)]) !== asking)
1788
+ setRunSession(db, runRow.id, { question: { ...question, address, pane: session.pane ?? null, machine: session.machine ?? null, since: observedAt } });
1789
+ // One prompt is one notice: the same wait (what it waits for, since when its harness says, the question its
1790
+ // pane shows) on the same session, whichever of its cards a round reads first and however long it stands.
1791
+ const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? '', asking].join('|');
1728
1792
  if (session.held_notice?.key === key) return;
1729
1793
  // every run a card works under: an ended run can still be its card's current one
1730
1794
  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);
1731
1795
  setRunSession(db, runRow.id, { held_notice: { key, at: observedAt } });
1732
1796
  if (told) return;
1733
- const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null });
1797
+ const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null, ...(question ? { question } : {}) });
1734
1798
  const others = (cardsOf.get(address) ?? []).filter((card) => card !== id);
1735
- const what = live.turn_source === 'pane_prompt' ? 'shows a prompt on its pane that waits for an answer'
1799
+ const what = question ? `asks on its pane: ${questionText(question)}`
1800
+ : live.turn_source === 'pane_prompt' ? 'shows a prompt on its pane that waits for an answer'
1736
1801
  : `reports that it is waiting on its user${live.waiting_for ? ` (${live.waiting_for})` : ''}${live.turn_since_ms ? ` since ${new Date(Number(live.turn_since_ms)).toISOString().slice(0, 19)}Z` : ''}`;
1737
1802
  event(db, id, 'session_held', observation, runRow.id);
1738
1803
  const to = managers[0] ?? null;
1739
- if (to) event(db, id, 'board_message', { to, text: `${id}: its session ${address} ${what}.${others.length ? ` It is also on ${others.join(', ')}.` : ''}` }, null);
1804
+ const door = question ? ` Answer it with: supercode workflow answer ${id} <option number> ${whereOf(board, task)}.` : '';
1805
+ if (to) event(db, id, 'board_message', { to, text: `${id}: its session ${address} ${what}.${door}${others.length ? ` It is also on ${others.join(', ')}.` : ''}` }, null);
1740
1806
  (report.held ??= []).push([id, 'waiting_on_user', address]);
1741
1807
  return;
1742
1808
  }
@@ -2284,6 +2350,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2284
2350
  const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
2285
2351
  .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
2286
2352
  .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
2353
+ // Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
2354
+ // checks below; an explicit close or another unresolved identity still holds the slot.
2355
+ && !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
2356
+ && r.session.session_id === last.session_id && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
2287
2357
  && !(closeRefused(r.session) && last?.session_id && r.session.session_id === last.session_id));
2288
2358
  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'}`);
2289
2359
  let prev = view(board, (db) => lastSession(db, task.id, slot));
@@ -2431,21 +2501,9 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2431
2501
  if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
2432
2502
  if (next === 'keep') {
2433
2503
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2434
- 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;
2504
+ 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;
2435
2505
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2436
- // a kept session is told what this run is for (its card came back to it, with what happened since), only while
2437
- // this run is still the card's current one (its pane and native reads were awaited)
2438
- tx(board, (db) => {
2439
- 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 });
2440
- setRunSession(db, runId, { ...session, ...meta });
2441
- // 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
2442
- if (prev.pane) for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
2443
- const s = json(r.metadata, {})?.supercode;
2444
- if (s?.pane === prev.pane && s.machine === prev.machine && !s.close_confirmed_at && !s.close_transferred_to) setRunSession(db, r.id, { ...s, close_transferred_to: runId, close_requested: null });
2445
- }
2446
- });
2447
- // filed in its mailbox, which delivers it
2448
- if (input) { assertRunCurrent(board, task.id, runId); await fileMail(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
2506
+ // The common binding below transfers ownership and subscriptions before filing the continuation prompt.
2449
2507
  } else if (prev.resumable !== false && await nativeAbsent(prev)) {
2450
2508
  // nothing to resume and no context to keep: the slot's next session starts fresh
2451
2509
  replaced = { session_id: prev.session_id, why: `${prev.machine} holds no ${prev.harness ?? 'claude-code'} session of that id (it never took a first turn)`, letGo: true };
@@ -2506,6 +2564,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2506
2564
  const saved = json(row?.metadata, {})?.supercode;
2507
2565
  if (saved?.address === session.address && !saved.launch_pending) return true;
2508
2566
  setRunSession(db, runId, session);
2567
+ transferRetainedSession(db, task.id, runId, session);
2509
2568
  event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
2510
2569
  // the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
2511
2570
  // leaves the implementer's, and a subtask's arc session's, subscription alone)
@@ -2517,9 +2576,26 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2517
2576
  return true;
2518
2577
  });
2519
2578
  if (!recorded) return { ...session, mode: 'finished_during_launch' };
2579
+ if (session.mode === 'adopt' && input) {
2580
+ assertRunCurrent(board, task.id, runId);
2581
+ await fileMail(board, session.address, input, `board-run-${task.id}-r${runId}`);
2582
+ }
2520
2583
  return session;
2521
2584
  }
2522
2585
 
2586
+ /** A continued role identity now belongs to its new attempt. Preserve the completed receipt, and keep an old
2587
+ * retained reference from closing the same native process/pane later. Transfer only after a successful bind. */
2588
+ function transferRetainedSession(db, id, runId, session) {
2589
+ if (!session?.session_id) return;
2590
+ 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)) {
2591
+ const old = json(row.metadata, {})?.supercode;
2592
+ if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
2593
+ || old.session_id !== session.session_id || old.machine !== session.machine || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
2594
+ setRunSession(db, row.id, { close_transferred_to: runId });
2595
+ event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
2596
+ }
2597
+ }
2598
+
2523
2599
  /** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
2524
2600
  * 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
2525
2601
  * this run's agent, or no agent is known for it (a session from before card agents). */
@@ -2776,7 +2852,19 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2776
2852
  const beforeRun = view(board, (db) => currentRun(db, id));
2777
2853
  const beforeCustody = view(board, db => JSON.stringify(detachedCard(db, id)));
2778
2854
  const beforeSession = beforeRun?.metadata?.supercode;
2779
- if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
2855
+ const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
2856
+ const adoptionSlot = beforeSession?.slot ?? view(board, db => {
2857
+ const scope = scopeOf(db, board, card0, { params: entry0.params });
2858
+ return String(templateValue(runSpec(entry0, statusOf(db, card0), card0)?.session ?? 'worker', scope));
2859
+ });
2860
+ const predecessor = view(board, db => lastSession(db, id, adoptionSlot));
2861
+ const sameIdentity = session => addressOf(session) === found.address;
2862
+ // A deferred run has issued no different identity. Bind its own slot's live predecessor, or the session
2863
+ // discovered in its keyed pane, without ending the run or opening another process.
2864
+ const unissued = !beforeSession?.session_id && !beforeSession?.pane && !beforeSession?.launch_key && !beforeSession?.pid && !beforeSession?.launch_pending;
2865
+ const keyedPane = !beforeSession?.session_id && found.pane && beforeSession?.pane === found.pane
2866
+ && beforeSession?.machine === host && beforeSession?.harness === harness;
2867
+ if (beforeRun && !sameIdentity(beforeSession) && !keyedPane && !(unissued && sameIdentity(predecessor))) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
2780
2868
  if (beforeSession?.pane && beforeSession.pane !== found.pane && await panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
2781
2869
 
2782
2870
  // the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
@@ -2790,7 +2878,6 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2790
2878
  }
2791
2879
  // Rule 2: a session working a card role is that role's agent, however it started; adopted, it joins the agent as a
2792
2880
  // launch does, and its recorded profile is compared with the role's intent (rule 4).
2793
- const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
2794
2881
  const agentRole = view(board, (db) => adoptedRole(db, board, entry0, card0, beforeRun)) ?? 'implementer';
2795
2882
  const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
2796
2883
  const lane = laneName ? lanes?.[laneName] : null, context = current().facts.SUPERCODE_CONTEXT, team = agentScope(context);
@@ -2800,6 +2887,8 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2800
2887
  const stale = (db) => {
2801
2888
  if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
2802
2889
  if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
2890
+ const binding = currentRun(db, id)?.metadata?.supercode;
2891
+ if (['session_id', 'pane', 'launch_key', 'launch_pending', 'launch_deferred'].some(key => (binding?.[key] ?? null) !== (beforeSession?.[key] ?? null))) return new Error(`${id}'s session binding changed during adoption; retry against its current session`);
2803
2892
  if (JSON.stringify(detachedCard(db, id)) !== beforeCustody) return new Error(`${id} changed session custody during adoption; request adoption again`);
2804
2893
  return null;
2805
2894
  };
@@ -2816,8 +2905,9 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2816
2905
  // agent store another, and every round after would trip on it.
2817
2906
  const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
2818
2907
  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}`);
2819
- // the card's previous session is read before the bind decides whether it is still live
2820
- await readBefore(fleet, view(board, (db) => lastSession(db, id)));
2908
+ // Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
2909
+ // adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
2910
+ await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
2821
2911
  const adopted = tx(board, (db) => {
2822
2912
  const task = getTask(db, id);
2823
2913
  if (!task) throw new Error(`no card ${id}`);
@@ -2826,10 +2916,10 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2826
2916
  if (changed) throw changed;
2827
2917
  if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
2828
2918
  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`);
2829
- const was = lastSession(db, id);
2919
+ const was = lastSession(db, id, adoptionSlot);
2830
2920
  // a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
2831
2921
  // left running, and is read)
2832
- 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}`);
2922
+ if (was && !sameIdentity(was) && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
2833
2923
  if (!task.machine) db.prepare('INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine').run(id, host);
2834
2924
  // the lane that resumes it if it is lost, set in the same transaction (assigning first would let a tick start another)
2835
2925
  if (assignee && assignee !== task.assignee) { db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, id); event(db, id, 'assigned', { assignee }); }
@@ -2863,7 +2953,13 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2863
2953
  ...(agent ? { agent_id: agent.identity.id, agent_org: team.org, agent_context: context ?? null,
2864
2954
  agent_profile_intent: { ...structuredClone(agent.intended.resolved), worker: { ...agent.intended.resolved?.worker, harness: lane.harness, model: lane.model ?? null, cwd: dir ?? task.workspace_path ?? null } } } : {}) };
2865
2955
  // a deferred launch was never issued: the adopted session is the run's, and no later round launches another
2866
- setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, detached_at: null, detached_to: null, launch_deferred: false, slot: currentRun(db, id)?.metadata?.supercode?.slot ?? beforeSession?.slot ?? expected?.session ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
2956
+ const owned = [beforeSession, was].find(s => s && s.pane === session.pane && (sameIdentity(s) || s === beforeSession && keyedPane));
2957
+ setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session,
2958
+ detached_at: null, detached_to: null, launch_key: owned?.launch_key ?? null, launch_pending: false, launch_error: null,
2959
+ launch_deferred: false, launch_deferred_error: null, slot: adoptionSlot, retain_after_run: false,
2960
+ close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, close_failed_at: null, close_claim: null,
2961
+ released: false, forgotten: false, monitor_held: false, missing_since: null });
2962
+ transferRetainedSession(db, id, runId, session);
2867
2963
  event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
2868
2964
  replaceWorkerSubscriptions(db, id, found.address);
2869
2965
  for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
@@ -3526,14 +3622,37 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
3526
3622
  return out;
3527
3623
  }
3528
3624
 
3625
+ /** An explicit completion of the latest attempt, after any previous review or reopening. A reclaimed or failed
3626
+ * attempt is not a completed disposition, and a new attempt or returned verdict invalidates the old receipt. */
3627
+ function completedWork(db, id) {
3628
+ const last = db.prepare(`SELECT id,kind,run_id FROM task_events WHERE task_id=?
3629
+ AND kind IN ('work_completed','review_requested','changes_requested','review_pass','review_voided','reopened')
3630
+ ORDER BY id DESC LIMIT 1`).get(id);
3631
+ if (last?.kind !== 'work_completed') return null;
3632
+ const attempt = db.prepare('SELECT id,outcome,ended_at FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
3633
+ return attempt?.id === last.run_id && attempt.outcome === 'completed' && attempt.ended_at != null ? last : null;
3634
+ }
3635
+
3636
+ /** Finish ends the attempt, not its caller's native turn. Keep observing that retained attempt until the board
3637
+ * requests review; the receipt's lifecycle/latest-attempt fence prevents an older session holding later work. */
3638
+ function reviewTurnRun(db, id) {
3639
+ const active = currentRun(db, id);
3640
+ if (active) return active;
3641
+ const completed = completedWork(db, id);
3642
+ if (!completed || detachedCard(db, id)) return null;
3643
+ const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(completed.run_id);
3644
+ const metadata = json(row?.metadata, {}) ?? {}, session = metadata.supercode;
3645
+ return row && session?.retain_after_run && !session.close_transferred_to && !detachedSession(db, session) ? { ...row, metadata } : null;
3646
+ }
3647
+
3529
3648
  /** 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
3530
- * tasks or by a run that ended with an Acceptance outcome ticked (a fresh arc with neither has not started), and no
3649
+ * tasks, an explicit completed-work receipt, or a completed run with an Acceptance outcome ticked, and no
3531
3650
  * review of it is pending or passed (after changes are requested, closing the tasks that own the findings makes it
3532
3651
  * due again). */
3533
3652
  function noCodeReviewDue(board, id) {
3534
3653
  return view(board, (db) => {
3535
3654
  const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
3536
- if (!filed && !(db.prepare('SELECT 1 FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL LIMIT 1').get(id) &&
3655
+ 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) &&
3537
3656
  outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
3538
3657
  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);
3539
3658
  return !last || ['changes_requested','review_voided'].includes(last.kind);
@@ -3549,8 +3668,8 @@ function passedReview(board, id) {
3549
3668
  function outcomeTicked(body) {
3550
3669
  let acceptance = false;
3551
3670
  return String(body ?? '').split('\n').some((line) => {
3552
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
3553
- if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
3671
+ const heading = cardSectionHeading(line);
3672
+ if (heading !== null) acceptance = heading === 'acceptance';
3554
3673
  return acceptance && /^\s*[-*] \[[xX]\] \S/.test(line);
3555
3674
  });
3556
3675
  }
@@ -3571,7 +3690,7 @@ function outcomeTicked(body) {
3571
3690
  * running a proof, still holds the review. */
3572
3691
  async function turnInFlight(board, id, fleet, heldSince = null) {
3573
3692
  if (!fleet?.ok) return null;
3574
- const session = currentRun(board.db, id)?.metadata?.supercode;
3693
+ const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
3575
3694
  if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
3576
3695
  let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
3577
3696
  if (live?.turn == null && session.surface === 'native') try {
@@ -3596,7 +3715,8 @@ function reviewDueKey(board, id, payload) {
3596
3715
  return `${source}|${closed ?? ''}`;
3597
3716
  }
3598
3717
 
3599
- async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
3718
+ /** Review selection shared by rounds and read-only inspection (`dry: true`). */
3719
+ export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
3600
3720
  const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
3601
3721
  WHERE c.subtask_of IS NULL AND c.every IS NULL
3602
3722
  ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
@@ -3643,22 +3763,22 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3643
3763
  }
3644
3764
  // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
3645
3765
  // (`review_held`) so a goal turn can be told to have started after that work was first observed.
3646
- const heldRun = currentRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3766
+ const heldRun = reviewTurnRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3647
3767
  const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
3648
3768
  const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
3649
3769
  if (inTurn) {
3650
- if (!held && !dry && heldRun) tx(board, (db) => { if (currentRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
3770
+ if (!held && !dry && heldRun) tx(board, (db) => { if (reviewTurnRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
3651
3771
  report.queued.push([id, `review waits: ${inTurn.why}`]);
3652
3772
  continue;
3653
3773
  }
3654
- // A stable local refusal (wrong HEAD or uncommitted work) must not spend a full fresh REST scan every
3655
- // round. Advisory discovery is sufficient to refuse, never to authorize; the scan below still owns that.
3774
+ // A stable PR-property or local refusal must not spend a full fresh REST scan every round. Advisory
3775
+ // discovery is sufficient to refuse, never to authorize; the scan below still owns that.
3656
3776
  if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
3657
3777
  if (!dry) await refreshCardInputs(board, id, { commits: true });
3658
3778
  // Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
3659
3779
  const observed = await withFreshReviewReads(async () => {
3660
- const candidate = await openReviewPullRequests(board, id, { fresh: true })
3661
- ?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id) : null);
3780
+ const candidate = await openReviewPullRequests(board, id, { fresh: true, repositories: payload.review_repositories ?? [] })
3781
+ ?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id, { fresh: true }) : null);
3662
3782
  const evidence = !candidate || candidate.waits ? null : candidate.no_code
3663
3783
  ? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
3664
3784
  : await inspectCompletionEvidence(board, id, entry, candidate, 'request_review');
@@ -3902,7 +4022,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
3902
4022
  if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
3903
4023
  // what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
3904
4024
  queueEffects(board, effects);
3905
- phase('goals');
4025
+ phase('idle');
3906
4026
  ensureSubscriptions(board, entry);
3907
4027
  fireHooks(board, settings, report);
3908
4028
  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
@@ -131,7 +131,7 @@ export function setRunSession(db, runId, session) {
131
131
  const before = whole.supercode;
132
132
  whole.supercode = { ...before, ...session };
133
133
  const task = one(db, 'SELECT task_id FROM task_runs WHERE id = ?', runId);
134
- if (task && session.session_id && session.pane && (before?.session_id !== session.session_id || before?.pane !== session.pane)) retireSupersededRuns(db, task.task_id, runId, whole.supercode);
134
+ if (task && session.session_id && (before?.session_id !== session.session_id || before?.pane !== session.pane)) retireSupersededRuns(db, task.task_id, runId, whole.supercode);
135
135
  run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
136
136
  return whole;
137
137
  }
@@ -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)
@@ -740,17 +740,26 @@ export const editResult = (board, id, { result, summary = null, metadata = undef
740
740
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
741
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 } });
742
742
 
743
- /** 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. */
744
744
  export async function decompose(board, id, children, { author = 'user', roles } = {}) {
745
745
  if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
746
- const task = view(board, (db) => must(db, id));
747
- const ids = [];
748
- for (const c of children) ids.push(createCard(board, await prepareCard(board, {
749
- title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
750
- 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,
751
- })).id);
752
- await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
753
- 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;
754
763
  }
755
764
 
756
765
  /** `child` waits for `parent`. */