@volter/supercode-orchestrator 0.5.205 → 0.5.206

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.
Files changed (50) hide show
  1. package/README.md +138 -4
  2. package/activation.mjs +93 -2
  3. package/adapters/rh2.mjs +7 -2
  4. package/agents.mjs +24 -2
  5. package/bin/agent-channel.mjs +41 -22
  6. package/bin/connection-runner.mjs +141 -0
  7. package/bin/orchestrator.mjs +11 -2
  8. package/bin/runtime.mjs +16 -9
  9. package/board/args.mjs +3 -2
  10. package/board/attachment-read.mjs +77 -0
  11. package/board/call.mjs +4 -2
  12. package/board/capacity.mjs +46 -9
  13. package/board/ci.mjs +157 -53
  14. package/board/cli.mjs +225 -87
  15. package/board/client.mjs +18 -2
  16. package/board/custody.mjs +109 -0
  17. package/board/dispatch.mjs +297 -75
  18. package/board/doors.mjs +47 -17
  19. package/board/engine.mjs +78 -24
  20. package/board/fleet-listeners.mjs +268 -0
  21. package/board/fleet-observer.mjs +79 -0
  22. package/board/fleet-targets.mjs +82 -0
  23. package/board/incidents.mjs +7 -0
  24. package/board/listing.mjs +5 -0
  25. package/board/machine-fleet.mjs +7 -0
  26. package/board/notice-receipts.mjs +67 -0
  27. package/board/owner-door-worker.mjs +61 -0
  28. package/board/owner-door.mjs +46 -0
  29. package/board/owner-health.mjs +8 -2
  30. package/board/published.mjs +2 -2
  31. package/board/record-view.mjs +2 -0
  32. package/board/store.mjs +55 -3
  33. package/board/tools.mjs +11 -4
  34. package/board/workflow.mjs +91 -28
  35. package/board/ztrack.mjs +87 -21
  36. package/board-input.d.ts +2 -0
  37. package/board-input.mjs +16 -0
  38. package/connection.mjs +153 -0
  39. package/index.d.ts +7 -5
  40. package/index.mjs +1 -1
  41. package/installed-apps.mjs +31 -20
  42. package/loop.mjs +91 -36
  43. package/mcp/bridge.mjs +12 -0
  44. package/mcp/hermes-kanban-tools.json +22 -14
  45. package/mcp/mount.mjs +10 -9
  46. package/ontology.d.ts +37 -0
  47. package/operator.mjs +72 -4
  48. package/orchestration.mjs +7 -2
  49. package/package.json +9 -5
  50. package/reducer.mjs +74 -5
@@ -18,11 +18,13 @@
18
18
  // acts on and does nothing when another round already acted (see `tick`). Every side effect follows a committed claim, so
19
19
  // a crash between the two steps is answered by the next round.
20
20
  import { machineCall } from './doors.mjs';
21
+ import { detachedCard, detachedSession, personCustody, sessionOperation, takeSessionCustody } from './custody.mjs';
21
22
  import { processSession } from './caller-session.mjs';
22
23
  import { callerOf, current, isCallersCall, ownCall } from './call.mjs';
23
24
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
24
25
  import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
25
26
  import { readMachineFleet } from './machine-fleet.mjs';
27
+ import { homeFleetMachines } from './fleet-targets.mjs';
26
28
  import { delegatedWork, idleInterval } from './session-work.mjs';
27
29
  import { localSessionDelegations } from '../agents.mjs';
28
30
  import { creatorOf, sessionProgress } from './workflow.mjs';
@@ -35,6 +37,8 @@ import { agentScope, localCardAgent, localReaderContext, localAgentSessions, run
35
37
  import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
36
38
  import { materializeAgentHome } from '../agent-home.mjs';
37
39
  import { memorySnapshot } from '../hermes-cli.mjs';
40
+ import { hermesModel } from '../worker-route.mjs';
41
+ import { MODEL_ARGUMENTS } from '../activation.mjs';
38
42
  import { createRequire } from 'node:module';
39
43
  import { spawn as spawnChild, execFile } from 'node:child_process';
40
44
  import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -48,10 +52,11 @@ import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace }
48
52
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
49
53
  import { evaluate, holds, render } from './expr.mjs';
50
54
  import { identifyNotice } from './notices.mjs';
55
+ import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
51
56
  import { pendingRequestMail } from './request-mail.mjs';
52
57
  import {
53
58
  cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
54
- recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, subscriptions, reconcileDependencySubscriptions,
59
+ recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions,
55
60
  } from './engine.mjs';
56
61
 
57
62
  const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
@@ -66,8 +71,10 @@ export function normalMachine(name) {
66
71
 
67
72
  /** This machine's name as supercode's addresses spell it: its Teams enrollment, else its host name. */
68
73
  let localMachineName;
69
- /** Learn it once per process, from the machine's own daemon (in-process: doors.mjs); a host with no daemon is its OS name. */
70
- export async function knowLocalMachine() {
74
+ /** Learn it once per process, from the machine's own daemon (in-process: doors.mjs); a host with no daemon is its OS name.
75
+ * `known`: the name this process's owner already learned (the owner's read door, owner-door.mjs). */
76
+ export async function knowLocalMachine(known = null) {
77
+ if (known && !localMachineName) localMachineName = normalMachine(known);
71
78
  if (localMachineName) return localMachineName;
72
79
  try { const machine = await machineCall(null, 'harness.v1.machine.describe', {}, { timeoutMs: 15_000 }); localMachineName = normalMachine(machine.name ?? machine.hostname); }
73
80
  catch { localMachineName = normalMachine(hostname()); }
@@ -144,17 +151,7 @@ export function callerSession() {
144
151
  /** The fleet as the home's machines answer for themselves (machine-fleet.mjs): this machine, and every machine an open
145
152
  * card of the home names or one of its notice subscribers is on. */
146
153
  export async function observeFleet(root = fleetRoot) {
147
- const machines = new Set();
148
- for (const slug of root ? listBoards(root) : []) {
149
- try {
150
- const b = openBoard(root, slug);
151
- try {
152
- const live = "t.status NOT IN ('done', 'cancelled', 'archived') AND NOT COALESCE(c.archived, 0)";
153
- for (const { machine } of b.db.prepare(`SELECT DISTINCT c.machine FROM supercode_cards c JOIN tasks t ON t.id = c.task_id WHERE c.machine IS NOT NULL AND c.machine != '' AND ${live}`).all()) machines.add(normalMachine(machine));
154
- for (const { chat_id: address } of b.db.prepare(`SELECT DISTINCT s.chat_id FROM kanban_notify_subs s JOIN tasks t ON t.id = s.task_id JOIN supercode_cards c ON c.task_id = t.id WHERE s.platform = 'supercode' AND s.chat_id LIKE 'sc:%' AND ${live}`).all()) machines.add(normalMachine(address.split(':')[1]));
155
- } finally { b.close(); }
156
- } catch { /* its own tick reports it */ }
157
- }
154
+ const machines = await homeFleetMachines(root, normalMachine);
158
155
  const fleet = await readMachineFleet(machines, localMachine());
159
156
  presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), unknown: fleet.unknown ?? new Set() };
160
157
  notePresence(presenceReading);
@@ -389,7 +386,8 @@ export function lanesOf(profiles = {}) {
389
386
  for (const [name, p] of Object.entries(profiles)) {
390
387
  const w = p?.worker;
391
388
  if (!w?.harness) continue;
392
- out[name] = { name, dir: p.dir, harness: w.harness, model: w.model ?? null, reasoning_effort: w.reasoning_effort ?? null, surface: w.surface ?? 'headless', capacity: w.capacity ?? null };
389
+ const model = w.model ?? (MODEL_ARGUMENTS[w.harness] ? hermesModel(p)?.model ?? null : null);
390
+ out[name] = { name, dir: p.dir, harness: w.harness, model, reasoning_effort: w.reasoning_effort ?? null, surface: w.surface ?? 'headless', capacity: w.capacity ?? null };
393
391
  }
394
392
  return out;
395
393
  }
@@ -444,16 +442,43 @@ async function recordBounces(board, fleet, entry, report) {
444
442
  try {
445
443
  const answer = await runtimeDoor(null, 'harness.v1.mail.expired', {}, { timeout: 15_000 });
446
444
  const expired = answer.code === 0 ? lastJson(answer.stdout)?.rows ?? [] : [];
447
- for (const expiry of expired.filter((row) => row?.bounced === true && row.id && at - Number(row.expired_at_ms) < 24 * 3600_000)) tx(board, (db) => {
448
- const filed = db.prepare("SELECT task_id, payload FROM task_events WHERE kind IN ('notice_filed', 'session_notice_filed', 'actor_notice_filed') AND json_extract(payload, '$.message_id') = ? ORDER BY id DESC LIMIT 1").get(expiry.id);
449
- if (!filed || db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind = 'notice_bounced' AND json_extract(payload, '$.message_id') = ?").get(filed.task_id, expiry.id)) return;
450
- const run = currentRun(db, filed.task_id), session = run?.metadata?.supercode;
451
- const uncounted = Boolean(run && !run.ended_at && json(filed.payload, {})?.verb === 'session_idle' && addressOf(session) === expiry.to && Number(session.goal_turns) > 0);
452
- if (uncounted) setRunSession(db, run.id, { goal_turns: Number(session.goal_turns) - 1 });
453
- event(db, filed.task_id, 'notice_bounced', { message_id: expiry.id, to: expiry.to, reason: expiry.reason, expired_at_ms: expiry.expired_at_ms, idle_turn_uncounted: uncounted }, run?.id ?? null);
454
- tell(db, filed.task_id, `${filed.task_id}: the board's notice ${expiry.id} to ${expiry.to} bounced: ${expiry.reason}. It stays unread in that mailbox and is not sent again.`);
455
- (report.bounced ??= []).push([filed.task_id, expiry.to, expiry.id]);
456
- });
445
+ const eligible = expired.filter((row) => row?.bounced === true && row.id && at - Number(row.expired_at_ms) < 24 * 3600_000);
446
+ if (eligible.length) {
447
+ const receipts = await noticeReceiptsInTurns(board, eligible.map(row => row.id));
448
+ const pending = eligible.filter(row => {
449
+ const filed = receipts.filed.get(row.id);
450
+ return filed && !receipts.bounced.has(noticeKey(filed.task_id, row.id));
451
+ });
452
+ let revision = receipts.revision;
453
+ // Eight expiries per writer turn; the historical search and duplicate lookup have already yielded.
454
+ for (let offset = 0; offset < pending.length; offset += 8) {
455
+ requireNoticeBasis(board, revision);
456
+ const recorded = tx(board, db => {
457
+ const basis = noticeWriterBasis(board, revision, receipts.ceiling);
458
+ const added = new Set(), staged = [];
459
+ for (const expiry of pending.slice(offset, offset + 8)) {
460
+ const found = receipts.filed.get(expiry.id), key = noticeKey(found.task_id, expiry.id);
461
+ if (receipts.bounced.has(key) || added.has(key)) continue;
462
+ // Revalidate the selected immutable receipt by primary key; no historical payload predicate in the writer.
463
+ const filed = db.prepare("SELECT task_id, payload FROM task_events WHERE id = ? AND kind IN ('notice_filed', 'session_notice_filed', 'actor_notice_filed') AND json_extract(payload, '$.message_id') = ?").get(found.id, expiry.id);
464
+ if (!filed) continue;
465
+ const run = currentRun(db, filed.task_id), session = run?.metadata?.supercode;
466
+ const uncounted = Boolean(run && !run.ended_at && json(filed.payload, {})?.verb === 'session_idle' && addressOf(session) === expiry.to && Number(session.goal_turns) > 0);
467
+ if (uncounted) setRunSession(db, run.id, { goal_turns: Number(session.goal_turns) - 1 });
468
+ event(db, filed.task_id, 'notice_bounced', { message_id: expiry.id, to: expiry.to, reason: expiry.reason, expired_at_ms: expiry.expired_at_ms, idle_turn_uncounted: uncounted }, run?.id ?? null);
469
+ tell(db, filed.task_id, `${filed.task_id}: the board's notice ${expiry.id} to ${expiry.to} bounced: ${expiry.reason}. It stays unread in that mailbox and is not sent again.`);
470
+ added.add(key);
471
+ staged.push([filed.task_id, expiry.to, expiry.id]);
472
+ }
473
+ return { rows: staged, revision: basis, ceiling: db.prepare('SELECT MAX(id) AS id FROM task_events').get().id };
474
+ });
475
+ for (const row of recorded.rows) receipts.bounced.add(noticeKey(row[0], row[2]));
476
+ (report.bounced ??= []).push(...recorded.rows);
477
+ receipts.ceiling = recorded.ceiling;
478
+ revision = requireNoticeBasis(board, recorded.revision, { ownWrites: true });
479
+ if (offset + 8 < pending.length) await yieldToOwner();
480
+ }
481
+ }
457
482
  } catch (error) { report.undelivered?.push(['mail', 'bounces', String(error.message).slice(-200)]); }
458
483
  // the first input of each session launched in the last ten minutes, until its machine's launch record settles it
459
484
  const running = view(board, (db) => db.prepare('SELECT t.id, r.id run_id, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id WHERE r.ended_at IS NULL').all());
@@ -481,8 +506,25 @@ async function supervise(board, fleet, settings, report, entry, effects) {
481
506
  // Published native observations are the only liveness input. Missing data remains unknown. A launch the
482
507
  // observations show stopped is asked, through its machine's door, how it ended.
483
508
  const exits = new Map();
509
+ const firstResponses = new Map();
484
510
  // every running session's liveness is read before anything is decided of it (unknown stays unknown)
485
511
  for (const { id } of rows) await readBefore(fleet, currentRun(board.db, id)?.metadata?.supercode);
512
+ // A living terminal can have refused its first turn. Read the native
513
+ // assistant outcome through the session door, before any board write.
514
+ for (const { id } of rows) {
515
+ const run = currentRun(board.db, id), session = run?.metadata?.supercode;
516
+ if (session?.harness !== 'claude-code' || session.mode !== 'fresh' || !session.launch_key || session.first_response || !session.session_id) continue;
517
+ let locator;
518
+ try { locator = nativeReading(nativeSessions(session.machine, session.harness, session.session_id), session).row?.locator; } catch { continue; }
519
+ if (!locator) continue;
520
+ const answer = await doorOn(session.machine, 'harness.v1.sessions.load', { locator, options: { include_subagents: false, message_limit: 0, inline_media: 'metadata' } }, { timeout: NATIVE_DISCOVERY_MS });
521
+ const loaded = answer.code === 0 ? lastJson(answer.stdout) : null;
522
+ const response = loaded?.session?.first_response;
523
+ if (loaded?.session?.session_id !== session.session_id || !['response', 'error'].includes(response?.outcome)
524
+ || !Number.isFinite(Date.parse(response.at)) || !Number.isFinite(run.started_at)
525
+ || Date.parse(response.at) < run.started_at * 1000) continue;
526
+ firstResponses.set(id, { run: run.id, session: session.session_id, response });
527
+ }
486
528
  for (const { id } of rows) {
487
529
  const session = currentRun(board.db, id)?.metadata?.supercode;
488
530
  if (!session?.launch_key || !fleet.ok || sessionAlive(fleet, session) !== false) continue;
@@ -548,6 +590,16 @@ async function supervise(board, fleet, settings, report, entry, effects) {
548
590
  }
549
591
  };
550
592
  const limits = limitsOf(db, board, entry, task, session);
593
+ const first = firstResponses.get(id);
594
+ if (first?.run === runRow?.id && first.session === session?.session_id && !session.first_response) {
595
+ session = { ...session, first_response: first.response };
596
+ setRunSession(db, runRow.id, session);
597
+ if (first.response.outcome === 'error') {
598
+ observe('exited', { error: `native session ${session.session_id} refused its first response (${first.response.error_code ?? 'native API error'})`,
599
+ session_id: session.session_id, pane: session.pane, first_response: first.response }, 'crashed');
600
+ return;
601
+ }
602
+ }
551
603
  // A verified adoption binds the run to its session; a launch key still pending beside it is the reconciler's to
552
604
  // retire at its machine, and no longer what the run waits on.
553
605
  const adopted = session?.mode === 'adopt' && Boolean(session.address);
@@ -714,6 +766,11 @@ function limitsOf(db, board, entry, task, session) {
714
766
 
715
767
  async function stopSession(board, session) {
716
768
  if (!session) return;
769
+ if (view(board, db => detachedSession(db, session))) throw Object.assign(new Error('close deferred: session was detached to a person'), { sessionDetached: true });
770
+ return sessionOperation(board, { address: addressOf(session), kind: 'session close' }, () => stopOwnedSession(board, session));
771
+ }
772
+
773
+ async function stopOwnedSession(board, session) {
717
774
  if (boardCopyOf(board)) throw copyRefusal(board, 'closes no session');
718
775
  if (session.surface === 'headless' && pidAlive(session.pid)) {
719
776
  // SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
@@ -764,11 +821,11 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
764
821
  // each run's session read before it is counted live or not (unknown stays unknown)
765
822
  for (const r of b.db.prepare('SELECT r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id').all()) await readBefore(fleet, json(r.metadata, {})?.supercode);
766
823
  const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
767
- out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
824
+ out.push(...await holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
768
825
  runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
769
826
  }
770
827
  finally { b.close(); }
771
- } catch { /* its own tick reports it */ }
828
+ } catch (error) { if (error.capacityUnread) throw error; /* its own tick reports other board errors */ }
772
829
  }
773
830
  out.defaults = harnessDefaults(out);
774
831
  return out;
@@ -832,7 +889,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
832
889
  const tickState = { local_started: 0 };
833
890
  const startable = new Set(Object.keys(workflow.statuses).filter((status) => transitionsFor(workflow, status, d.start).length));
834
891
  const everyCard = board.db.prepare("SELECT id FROM tasks WHERE status != 'archived'").all().map((r) => getTask(board.db, r.id));
835
- const scopeFor = (task, extra = {}) => scopeOf(board.db, board, task, { params, extra: { host: hostOf(pressure), tick: tickState, ...extra } });
892
+ const scopeFor = (task, extra = {}) => scopeOf(board.db, board, task, { params, lazy: true, extra: { host: hostOf(pressure), tick: tickState, ...extra } });
836
893
  // who would run it: the role of the status its start moves it to, and that role's lane
837
894
  const planOf = (task, scope) => {
838
895
  const status = statusOf(board.db, task);
@@ -855,6 +912,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
855
912
  if (task.subtask_of || task.current_run_id) continue;
856
913
  const state = scopeFor(task).card;
857
914
  if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
915
+ if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
858
916
  if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
859
917
  if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
860
918
  if (workflow.run && state.blocked_by.length) { waiting(task, `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`); continue; }
@@ -989,7 +1047,7 @@ function launchFailed(board, entry, id, error, payload) {
989
1047
  * longer the card's current one is its supersession, whatever failed, so no caller reports it against the successor. */
990
1048
  export async function launchRun(board, entry, id, lane, fleet, settings) {
991
1049
  const runId = getTaskNow(board, id).current_run_id;
992
- try { return await launchAttempt(board, entry, id, lane, fleet, settings, runId); }
1050
+ try { return await sessionOperation(board, { taskId: id, kind: 'session launch' }, () => launchAttempt(board, entry, id, lane, fleet, settings, runId)); }
993
1051
  catch (error) {
994
1052
  error.launchRun = runId;
995
1053
  if (!error.launchSuperseded && !view(board, (db) => runCurrent(db, id, runId))) error.launchSuperseded = true;
@@ -1021,7 +1079,19 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1021
1079
  // caps here too, excluding this attempt's own reservation. Other cap kinds keep their existing admission rules.
1022
1080
  const sessionCaps = entry.workflow.dispatch.capacity?.filter(cap => cap.counts === 'sessions' && cap.where?.dispatch === true) ?? [];
1023
1081
  if (sessionCaps.length) {
1024
- const held = await homeHoldings(board.root, listBoards(board.root), lanesOf(entry.profiles), fleet, entry);
1082
+ let held;
1083
+ try { held = await homeHoldings(board.root, listBoards(board.root), lanesOf(entry.profiles), fleet, entry); }
1084
+ catch (error) {
1085
+ if (!error.capacityUnread) throw error;
1086
+ // No launch door has been issued. Keep this reservation retryable, including a deferred run whose
1087
+ // reconciliation claim just cleared launch_deferred. Top-level capacity reads still propagate unread.
1088
+ runTx(board, id, runId, db => {
1089
+ const previous = currentRun(db, id)?.metadata?.supercode;
1090
+ setRunSession(db, runId, { status, role: spec.role, slot, launched_at: now(), launch_deferred: true, launch_deferred_error: error.message });
1091
+ if (previous?.launch_deferred_error !== error.message) event(db, id, 'launch_deferred', { reason: error.message }, runId);
1092
+ });
1093
+ throw Object.assign(error, { launchDeferred: true, launchPending: true });
1094
+ }
1025
1095
  const others = held.filter(x => !(x.board === board.slug && x.run === runId));
1026
1096
  const refused = capacity(sessionCaps, others, evaluate).admit({ card: id, board: board.slug, machine: executionHost(lane, task.machine, localMachine(), normalMachine),
1027
1097
  assignee: lane.name ?? task.assignee, status, stored: scope.card.stored ?? status, model: task.model_override ?? lane.model ?? null, harness: lane.harness,
@@ -1121,6 +1191,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
1121
1191
  const fleet = await observeFleet();
1122
1192
  const before = getTaskNow(board, id), checked = await checkWorkspace(board, before);
1123
1193
  const moved = tx(board, (db) => {
1194
+ if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
1124
1195
  assertWorkspaceOwner(db, before, checked);
1125
1196
  if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
1126
1197
  if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
@@ -1163,6 +1234,27 @@ export const unboundLaunch = (session, launchWindow, at = now()) => Boolean(sess
1163
1234
  && Number(session.launched_at) > 0 && at - Number(session.launched_at) >= launchWindow);
1164
1235
  /** The fleet turns of a session read in a turn (`needs_input`: a turn stopped on a permission or a question). */
1165
1236
  const TURN_IN_PROGRESS = new Set(['working', 'needs_input']);
1237
+ /**
1238
+ * A harness's own blocking prompt on the pane's screen, which no input from the board answers: the session is stopped,
1239
+ * not between turns. Claude Code's usage limit ("What do you want to do? 1. Stop and wait for limit to reset …") is
1240
+ * one; its reset time is said when the prompt names it. Null when the screen shows none.
1241
+ */
1242
+ const BLOCKING_PROMPTS = [
1243
+ { reason: 'provider-limit', harness: 'claude-code', test: /Stop and wait for limit to reset/i, resets: /continue automatically at ([^\n│]+?)\s*(?:│|$)/im },
1244
+ ];
1245
+ async function blockingPrompt(session) {
1246
+ if (!session?.pane) return null;
1247
+ const machine = session.machine ?? localMachine();
1248
+ const r = await doorOn(machine, 'harness.v1.panes.capture', { pane: session.pane, lines: 60 }, { timeout: 10_000 });
1249
+ const text = r.code === 0 ? lastJson(r.stdout)?.text : null;
1250
+ if (typeof text !== 'string') return null; // unread (or a release without this door): the turn reading stands
1251
+ for (const prompt of BLOCKING_PROMPTS) {
1252
+ if (prompt.harness !== (session.harness ?? 'claude-code') || !prompt.test.test(text)) continue;
1253
+ const resets = prompt.resets.exec(text)?.[1]?.trim() ?? null;
1254
+ return { reason: prompt.reason, resets, observed_at: now() };
1255
+ }
1256
+ return null;
1257
+ }
1166
1258
  /** The watch on a session whose turn is not published: it began (`since`) at the first such reading, at the reading
1167
1259
  * that found its record written again (`mark`: its last write and when its harness says its state began, in ms), or
1168
1260
  * at the first reading after more than `gap` seconds unread (`seen`: the last reading). */
@@ -1170,7 +1262,7 @@ function unreadWatch(held, mark, gap, at = now()) {
1170
1262
  const kept = held && Number(held.mark) === mark && Number(held.since) <= at && at - Number(held.seen) <= gap;
1171
1263
  return { since: kept ? Number(held.since) : at, mark, seen: at };
1172
1264
  }
1173
- async function turnAtClose(session, pane = null, { launchWindow = null, quiet = null, gap = 0 } = {}) {
1265
+ async function turnAtClose(session, pane = null, { launchWindow = null, quiet = null, gap = 0, inputAbandonment = null } = {}) {
1174
1266
  const machine = session.machine ?? localMachine();
1175
1267
  const recordOf = (rows, id, harness) => rows.find((row) => row.locator?.session_id === id && row.locator?.harness === harness);
1176
1268
  let activity, rows = null;
@@ -1208,12 +1300,21 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1208
1300
  }
1209
1301
  if (activity.presence && activity.presence !== 'running') return null;
1210
1302
  if (activity.turn === 'idle') return null;
1303
+ if (inputAbandonment && activity.presence === 'running' && activity.turn === 'needs_input'
1304
+ && activity.session_id === session.session_id && activity.harness === session.harness) {
1305
+ return { abandonInput: { ...inputAbandonment, observed_at: now(), session_id: activity.session_id,
1306
+ harness: activity.harness, pane: session.pane, turn: activity.turn } };
1307
+ }
1211
1308
  const who = activity.session_id ? `sc:${normalMachine(machine)}:${activity.harness ?? session.harness}:${activity.session_id}` : addressOf(session);
1212
1309
  const inTurn = (turn) => ({ why: `its session ${who} is in a turn (${turn}); it closes once the turn ends`, unread: null });
1213
1310
  if (TURN_IN_PROGRESS.has(activity.turn)) return inTurn(activity.turn);
1214
1311
  // No turn published: the session's own record says whether anything goes on in it. One whose record does not answer,
1215
1312
  // or shows neither a write nor when its state began, has nothing to count from and waits.
1216
1313
  const unknown = `its session ${who} publishes no turn (${activity.turn ?? 'unknown'})`;
1314
+ // A harness held at its own blocking prompt (a provider's usage limit) is stopped, whatever its record shows: closed,
1315
+ // with why, rather than waited on until the limit resets.
1316
+ const blocked = await blockingPrompt(session).catch(() => null);
1317
+ if (blocked) return { stopped: blocked };
1217
1318
  const sessionId = activity.session_id ?? session.session_id, harness = activity.harness ?? session.harness ?? 'claude-code';
1218
1319
  if (!sessionId || !quiet) return { why: unknown };
1219
1320
  if (!rows) try { rows = (await discoverNative(machine, harness, sessionId)).sessions; }
@@ -1229,13 +1330,60 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
1229
1330
  /** Hold the close of an ended run's session for what `turnAtClose` read, with the watch that reading leaves on the run. */
1230
1331
  async function holdForTurn(session, pane, options) {
1231
1332
  const held = await turnAtClose(session, pane, options);
1333
+ if (held?.abandonInput) return held.abandonInput;
1334
+ if (held?.stopped) return { stopped: held.stopped };
1232
1335
  if (held) throw Object.assign(new Error(`close deferred: ${held.why}`), 'unread' in held ? { closeUnread: held.unread } : {});
1233
1336
  }
1234
1337
 
1338
+ /** Only a recorded manager/creator release of this ended worker permits abandoning input. */
1339
+ function reassignedInputClose(board, row, session) {
1340
+ if (!row.ended_at || !row.profile || !session.pane || !session.session_id || !session.harness
1341
+ || (session.slot ?? 'worker') !== 'worker' || !session.launch_key || !['fresh', 'resume'].includes(session.mode)) return null;
1342
+ return view(board, (db) => {
1343
+ const release = db.prepare(`SELECT id, payload, created_at FROM task_events WHERE task_id = ? AND kind = 'dispatch_released'
1344
+ AND json_extract(payload, '$.ended_run') = ? ORDER BY id DESC LIMIT 1`).get(row.task_id, row.id);
1345
+ const intent = json(release?.payload, null);
1346
+ if (intent?.reason !== 'explicit set') return null;
1347
+ if (intent.abandon_waiting_input === true && Object.hasOwn(intent, 'reassigned_to') && intent.reassigned_from === row.profile
1348
+ && intent.reassigned_to !== intent.reassigned_from) return { release_event: release.id, assignment_event: null };
1349
+ // Older writers recorded the role-authorized release and assignment separately.
1350
+ // Require the same actor, exact ended run and end timestamp; a model change is insufficient.
1351
+ const assignment = db.prepare(`SELECT id, payload, created_at FROM task_events WHERE task_id = ? AND run_id = ?
1352
+ AND kind = 'assigned' AND id < ? ORDER BY id DESC LIMIT 1`).get(row.task_id, row.id, release.id);
1353
+ const changed = json(assignment?.payload, null);
1354
+ if (!changed?.by || !Object.hasOwn(changed, 'assignee') || changed.by !== intent.by || changed.assignee === row.profile
1355
+ || assignment.created_at !== row.ended_at || release.created_at < assignment.created_at
1356
+ || release.created_at > assignment.created_at + 1) return null;
1357
+ return { release_event: release.id, assignment_event: assignment.id };
1358
+ });
1359
+ }
1360
+
1361
+ function recordInputClose(board, row, session, reading) {
1362
+ return tx(board, (db) => {
1363
+ const latest = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ? AND task_id = ?').get(row.id, row.task_id);
1364
+ const current = json(latest?.metadata, {})?.supercode;
1365
+ if (!latest?.ended_at || !current || current.pane !== session.pane || current.session_id !== session.session_id
1366
+ || normalMachine(current.machine) !== normalMachine(session.machine) || !current.close_requested
1367
+ || current.close_confirmed_at || current.close_transferred_to || current.close_claim?.lock !== claimer()
1368
+ || !(Number(current.close_claim?.expires) > now())) return false;
1369
+ const protectedNow = db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL
1370
+ OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all().some((r) => {
1371
+ const s = json(r.metadata, {})?.supercode;
1372
+ return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine);
1373
+ });
1374
+ if (protectedNow) return false;
1375
+ const prior = current.close_input_reading;
1376
+ setRunSession(db, row.id, { close_input_reading: reading });
1377
+ if (prior?.release_event !== reading.release_event || prior?.session_id !== reading.session_id)
1378
+ event(db, row.task_id, 'session_input_close_authorized', reading, row.id);
1379
+ return true;
1380
+ });
1381
+ }
1382
+
1235
1383
  /** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
1236
1384
  const closesUnderway = new Map();
1237
1385
  export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
1238
- const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
1386
+ const rows = view(board, (db) => db.prepare(`SELECT id, task_id, profile, ended_at, metadata FROM task_runs
1239
1387
  WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
1240
1388
  // Repair pre-transfer history once: only identities with an older unended reference need a write.
1241
1389
  const latest = new Map(), transfers = new Map();
@@ -1255,7 +1403,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1255
1403
  const panes = new Map();
1256
1404
  for (const row of rows) {
1257
1405
  let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1258
- if (!session || session.close_transferred_to || session.close_confirmed_at) continue;
1406
+ if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
1259
1407
  // A keyed launch is reconciled by its machine's own daemon (D137), never by a door the close waits on. A session
1260
1408
  // adopted onto a run whose launch was still pending did not come from that launch: the key is let go and the
1261
1409
  // session is the run's, released or closed as its own below. A launch that never bound a pane left nothing the
@@ -1340,7 +1488,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1340
1488
  try {
1341
1489
  const claimed = tx(board, (db) => {
1342
1490
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1343
- if (!current || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || claimHolds(current.close_claim)) return false;
1491
+ if (!current || detachedSession(db, current) || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || claimHolds(current.close_claim)) return false;
1344
1492
  requestSessionClose(db, row.task_id, { runId: row.id });
1345
1493
  // a close this process already claims, with a minute of its claim left, is tried again without a new claim
1346
1494
  const own = current.close_claim?.lock === claimer() && Number(current.close_claim.expires) > now() + 60;
@@ -1373,7 +1521,20 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1373
1521
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
1374
1522
  // An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
1375
1523
  // already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
1376
- if (row.ended_at) { const { launchWindow, idleQuiet: quiet, interval } = boardSettings(workflowFor(board.root).params); await holdForTurn(session, observed.live.get(session.pane), { launchWindow, quiet, gap: Math.max(3 * interval, quiet / 2) }); }
1524
+ if (row.ended_at) {
1525
+ const { launchWindow, idleQuiet: quiet, interval } = boardSettings(workflowFor(board.root).params);
1526
+ const reading = await holdForTurn(session, observed.live.get(session.pane), {
1527
+ launchWindow, quiet, gap: Math.max(3 * interval, quiet / 2),
1528
+ inputAbandonment: reassignedInputClose(board, row, session),
1529
+ });
1530
+ if (reading?.stopped) {
1531
+ // the run says why its session stopped (a provider's limit, and when it resets), then the close proceeds
1532
+ const { reason, resets, observed_at } = reading.stopped;
1533
+ tx(board, (db) => event(db, row.task_id, 'run_incident', { observation: 'session_stopped', run: row.id, session: addressOf(session), pane: session.pane,
1534
+ reason: `${addressOf(session)} stopped at its harness's ${reason} prompt${resets ? `; it resets ${resets}` : ''}`, stopped: reason, resets, observed_at }, row.id));
1535
+ } else if (reading && !recordInputClose(board, row, session, reading))
1536
+ throw new Error('close deferred: ownership changed before ending its input dialog');
1537
+ }
1377
1538
  await stopSession(board, session); observed.live.delete(session.pane);
1378
1539
  }
1379
1540
  }
@@ -1387,7 +1548,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1387
1548
  }
1388
1549
  tx(board, (db) => {
1389
1550
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
1390
- if (!current || current.pane !== session.pane || current.session_id !== session.session_id) return;
1551
+ if (!current || detachedSession(db, current) || current.pane !== session.pane || current.session_id !== session.session_id) return;
1391
1552
  const at = now();
1392
1553
  // a close confirmed meanwhile (a claim that expired under a slow close) is not undone by this attempt's failure
1393
1554
  if (error && current.close_confirmed_at) return;
@@ -1629,10 +1790,10 @@ function effectMessageKey(effect) {
1629
1790
  */
1630
1791
  export async function sessionNotice(board, e) {
1631
1792
  const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
1632
- if (prior?.close_confirmed_at) {
1793
+ if (prior?.close_confirmed_at || view(board, db => detachedSession(db, prior))) {
1633
1794
  tx(board, (db) => {
1634
1795
  // A closed session has nothing to be told; its next run starts with its own prompt.
1635
- event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId, reason: 'session closed' }, e.runId);
1796
+ event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId, reason: prior?.detached_at ? 'session detached to a person' : 'session closed' }, e.runId);
1636
1797
  if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
1637
1798
  });
1638
1799
  return true;
@@ -1643,6 +1804,7 @@ export async function sessionNotice(board, e) {
1643
1804
  tx(board, (db) => {
1644
1805
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
1645
1806
  if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
1807
+ if (filed.detached) event(db, e.id, 'session_notice_not_required', { address, notice: e.durableId, reason: 'session detached to a person' }, e.runId);
1646
1808
  if (filed.new) event(db, e.id, 'session_notice_filed', { address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, message_id: filed.message_id }, e.runId);
1647
1809
  });
1648
1810
  return true;
@@ -1653,6 +1815,7 @@ export async function sessionEffect(board, e) {
1653
1815
  if (e.kind === 'send_to_actor') {
1654
1816
  const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
1655
1817
  const session = json(row?.metadata, {})?.supercode;
1818
+ if (view(board, db => detachedSession(db, session))) return true;
1656
1819
  const address = addressOf(session);
1657
1820
  if (!address) throw new Error('actor session identity unavailable');
1658
1821
  // filed in the actor's mailbox, which delivers it
@@ -1842,9 +2005,8 @@ function bindLaunch(board, row, session, pane, sid) {
1842
2005
  ...(address ? { address, launch_pending: false, launch_error: null, seen_at: now() } : {}) });
1843
2006
  if (current.ended_at) requestSessionClose(db, row.task_id, { runId: row.id });
1844
2007
  else if (address && getTask(db, row.task_id)?.current_run_id === row.id) {
1845
- subscribeIn(db, row.task_id, address, 'worker');
2008
+ replaceWorkerSubscriptions(db, row.task_id, address);
1846
2009
  for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id=?').all(row.task_id)) subscribeIn(db, p.parent_id, address, 'dependency');
1847
- for (const t of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of=?').all(row.task_id)) subscribeIn(db, t.task_id, address, 'worker');
1848
2010
  }
1849
2011
  if (sid && saved.launch_pending) event(db, row.task_id, 'session_launch_reconciled', { key: session.launch_key, pane, session_id: sid, role: saved.role }, row.id);
1850
2012
  return true;
@@ -1909,7 +2071,7 @@ async function synchronizeAgents(board, report) {
1909
2071
  .map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
1910
2072
  for(const row of agentRuns){
1911
2073
  const session=json(row.metadata,{})?.supercode;
1912
- if(!session?.session_id||!session.agent_org)continue;
2074
+ if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
1913
2075
  const ref=`${session.harness}:${session.session_id}`;
1914
2076
  // An ended run whose session the agent store already holds for another agent lost that claim when it ran: no round
1915
2077
  // can change an ended run, so the disagreement is history. It is said once on its card (agent_ownership_conflict)
@@ -1932,7 +2094,7 @@ async function synchronizeAgents(board, report) {
1932
2094
  let read=0;
1933
2095
  for(const row of runsAwaitingUsage(board.root,org)){
1934
2096
  const run=agentRuns.find(item=>String(item.id)===String(row.run_id));
1935
- if(!run?.ended_at||read>=5)continue;
2097
+ if(!run?.ended_at||read>=5||view(board,db=>detachedSession(db,json(run.metadata,{})?.supercode)))continue;
1936
2098
  await readRunUsage(board.root,org,row,run.ended_at);read++;
1937
2099
  }
1938
2100
  }
@@ -2209,11 +2371,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2209
2371
  // the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
2210
2372
  // leaves the implementer's, and a subtask's arc session's, subscription alone)
2211
2373
  const before = prev ? addressOf(prev) : null;
2212
- if (before && before !== session.address) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(task.id, before);
2213
- subscribeIn(db, task.id, session.address, 'worker');
2374
+ if (before && before !== session.address) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND json_extract(delivery_metadata,'$.role')='worker'").run(task.id, before);
2375
+ replaceWorkerSubscriptions(db, task.id, session.address);
2214
2376
  // it sees the cards its own depends on change, and works its subtasks
2215
2377
  for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(task.id)) subscribeIn(db, p.parent_id, session.address, 'dependency');
2216
- for (const t of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of = ?').all(task.id)) subscribeIn(db, t.task_id, session.address, 'worker');
2217
2378
  return true;
2218
2379
  });
2219
2380
  if (!recorded) return { ...session, mode: 'finished_during_launch' };
@@ -2464,6 +2625,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2464
2625
  const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId), { session_id: sessionId, harness });
2465
2626
  if (found.pane ? !await panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
2466
2627
  const beforeRun = view(board, (db) => currentRun(db, id));
2628
+ const beforeCustody = view(board, db => JSON.stringify(detachedCard(db, id)));
2467
2629
  const beforeSession = beforeRun?.metadata?.supercode;
2468
2630
  if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
2469
2631
  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}`);
@@ -2489,6 +2651,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2489
2651
  const stale = (db) => {
2490
2652
  if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
2491
2653
  if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
2654
+ if (JSON.stringify(detachedCard(db, id)) !== beforeCustody) return new Error(`${id} changed session custody during adoption; request adoption again`);
2492
2655
  return null;
2493
2656
  };
2494
2657
  if (request !== null) tx(board, (db) => {
@@ -2529,14 +2692,19 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2529
2692
  const expected = runSpec(entry, statusOf(db, task), task);
2530
2693
  if (role && role !== expected?.role) throw new Error(`${id} needs its ${expected?.role ?? 'declared'} role, not ${role}`);
2531
2694
  if (expected?.role === 'reviewer') {
2532
- const implementer = lastSession(db, id, 'worker');
2695
+ // Independence reads history even when its implementer's custody was handed back.
2696
+ const implementer = json(db.prepare(`SELECT metadata FROM task_runs WHERE task_id=?
2697
+ AND COALESCE(json_extract(metadata,'$.supercode.slot'),'worker')='worker'
2698
+ AND json_extract(metadata,'$.supercode.session_id') IS NOT NULL ORDER BY id DESC LIMIT 1`).get(id)?.metadata, {})?.supercode;
2533
2699
  if (implementer?.session_id === sessionId) throw new Error('a reviewer must be independent of the implementer session');
2534
2700
  }
2701
+ takeSessionCustody(db, id, found.address);
2535
2702
  event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
2536
2703
  // the manager's adoption is the workflow's own `adopt` verb where it declares one, sent as the manager; else its
2537
2704
  // dispatch start, which the platform sends for a verified adoption
2538
2705
  if (!beforeRun) { const verb = adoptionVerb(entry);
2539
- fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() } }); }
2706
+ fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() },
2707
+ noticeSession: { address: found.address, session_id: sessionId, machine: host, harness } }); }
2540
2708
  const runId = getTask(db, id).current_run_id;
2541
2709
  if (!runId) throw new Error(`${id} waits on parents that are not done`);
2542
2710
  // the agent joined above is the one of the run's own role; a run of another role would name the wrong agent
@@ -2546,13 +2714,23 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2546
2714
  ...(agent ? { agent_id: agent.identity.id, agent_org: team.org, agent_context: context ?? null,
2547
2715
  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 } } } : {}) };
2548
2716
  // a deferred launch was never issued: the adopted session is the run's, and no later round launches another
2549
- setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, 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 });
2717
+ 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 });
2550
2718
  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);
2551
- subscribeIn(db, id, found.address, 'worker');
2719
+ replaceWorkerSubscriptions(db, id, found.address);
2552
2720
  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');
2553
2721
  return { ...session, run: runId, workspace: dir };
2554
2722
  });
2555
- if (agent) recordAgentRun(board.root, team.org, agent.identity.id, { session: `${harness}:${sessionId}`, machine: host, run: adopted.run });
2723
+ if (agent) {
2724
+ recordAgentRun(board.root, team.org, agent.identity.id, { session: `${harness}:${sessionId}`, machine: host, run: adopted.run });
2725
+ // Teams learns whose the session is the way a launch tells it (ADR 0013, "launched or adopted"): the session's machine
2726
+ // queues it with the same agent environment a launch carries, and binds it when Teams answers. The adoption stands
2727
+ // either way; the card says whether the machine took it.
2728
+ const environment = agentEnvironment(agent, { root: board.root, context: normalMachine(host) === localMachine() ? localReaderContext(team.org, agent.identity.id) ?? undefined : undefined });
2729
+ const r = await doorOn(host, 'harness.v1.sessions.own', { session: `${harness}:${sessionId}`, environment }, { timeout: 30_000 });
2730
+ const answer = r.code === 0 ? lastJson(r.stdout) : null;
2731
+ tx(board, (db) => event(db, id, answer?.queued === true ? 'agent_session_queued' : 'agent_session_unqueued',
2732
+ { session: found.address, agent: agent.identity.id, ...(answer?.queued === true ? {} : { reason: String(answer?.reason ?? (r.stderr || r.stdout || 'the machine did not answer')).slice(0, 300) }) }, adopted.run));
2733
+ }
2556
2734
  return adopted;
2557
2735
  }
2558
2736
 
@@ -2565,7 +2743,7 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
2565
2743
  const panes = new Map();
2566
2744
  for (const row of rows) {
2567
2745
  const saved = json(row.metadata,{})?.supercode;
2568
- if (!saved?.session_id || saved.forgotten || saved.close_transferred_to) continue;
2746
+ if (!saved?.session_id || saved.forgotten || saved.close_transferred_to || view(board, db => detachedCard(db, row.task_id) || detachedSession(db, saved))) continue;
2569
2747
  if (!saved.monitor_held && !['crashed','closed','reclaimed','failed'].includes(row.outcome)) continue;
2570
2748
  const live = fleet.sessions?.find(s => s.address === addressOf(saved) && s.state === 'running' && s.pane);
2571
2749
  if (!live) continue;
@@ -2581,7 +2759,7 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
2581
2759
  tx(board, db => {
2582
2760
  const task = getTask(db,row.task_id), scope = scopeOf(db,board,task,{params:entry.params});
2583
2761
  const automaticPause = scope.card.paused?.reason?.startsWith('its session ended cleanly (');
2584
- if (task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
2762
+ if (task.detached || detachedSession(db, saved) || task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
2585
2763
  if (automaticPause) db.prepare('UPDATE supercode_cards SET paused=NULL WHERE task_id=?').run(row.task_id);
2586
2764
  const session = { ...saved,pane,mode:'manual-continuation',state:entry.workflow.run?.initial ?? 'active',monitor_held:false,
2587
2765
  prior_intervals:[...(saved.prior_intervals??[]),{ended_at:row.ended_at,outcome:row.outcome,error:row.error}],
@@ -2594,8 +2772,7 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
2594
2772
  db.prepare('UPDATE tasks SET current_run_id=?,claim_lock=?,claim_expires=? WHERE id=?').run(row.id,claimer(),now()+900,row.task_id);
2595
2773
  projectStatus(db,board,entry,row.task_id);
2596
2774
  event(db,row.task_id,'dispatch_released',{reason:'verified manual continuation',session:addressOf(session),incident:saved.incident??null});
2597
- subscribeIn(db,row.task_id,addressOf(session),'worker');
2598
- for (const sub of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of=?').all(row.task_id)) subscribeIn(db,sub.task_id,addressOf(session),'worker');
2775
+ replaceWorkerSubscriptions(db,row.task_id,addressOf(session));
2599
2776
  (report.continued ??= []).push([row.task_id,addressOf(session),pane]);
2600
2777
  });
2601
2778
  }
@@ -2613,18 +2790,33 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
2613
2790
  * A question the board waits on (`ask`) is answered by a reply to the board's address; a notice is not. Throws only
2614
2791
  * when the filing failed.
2615
2792
  */
2616
- async function fileMail(board, to, body, key, { ask = false, wake = true, subject } = {}) {
2793
+ async function fileMail(board, to, body, key, { ask = false, wake = true, subject, eligible = null } = {}) {
2617
2794
  if (boardCopyOf(board)) throw copyRefusal(board, 'files no mail for sessions or managers');
2618
- const request = {
2619
- from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, notice: !ask, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
2620
- body: ask ? `[A question from the ${board.label} board] ${body}` : `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
2621
- // the board's own Teams context (the call it files in): the sender is observed in that team, never the daemon's
2622
- context: current().facts.SUPERCODE_CONTEXT ?? null,
2795
+ const perform = async () => {
2796
+ if (eligible && !eligible()) return { filed: false, new: false, skipped: true };
2797
+ const request = {
2798
+ from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, notice: !ask, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
2799
+ body: ask ? `[A question from the ${board.label} board] ${body}` : `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
2800
+ // the board's own Teams context (the call it files in): the sender is observed in that team, never the daemon's
2801
+ context: current().facts.SUPERCODE_CONTEXT ?? null,
2802
+ };
2803
+ const r = await runtimeDoor(null, 'harness.v1.mail.file', request, { timeout: 30_000 });
2804
+ const answer = r.code === 0 ? lastJson(r.stdout) : null;
2805
+ if (answer?.code !== 0) throw new Error((answer?.text || (lastJson(r.stderr)?.message ?? r.stderr) || 'no answer from the mailbox\'s filing door').trim().slice(-300));
2806
+ return { filed: true, message_id: answer.message_id, new: answer.new === true };
2623
2807
  };
2624
- const r = await runtimeDoor(null, 'harness.v1.mail.file', request, { timeout: 30_000 });
2625
- const answer = r.code === 0 ? lastJson(r.stdout) : null;
2626
- if (answer?.code !== 0) throw new Error((answer?.text || (lastJson(r.stderr)?.message ?? r.stderr) || 'no answer from the mailbox\'s filing door').trim().slice(-300));
2627
- return { filed: true, message_id: answer.message_id, new: answer.new === true };
2808
+ // Dispatcher mail reserves this exact identity before entering the mailbox door.
2809
+ if (!board.db) throw new Error('mail filing requires the board custody store');
2810
+ try { return await sessionOperation(board, { address: to, kind: 'mail filing' }, perform); }
2811
+ catch (error) {
2812
+ if (!error.sessionDetached) throw error;
2813
+ return { filed: false, new: false, detached: true };
2814
+ }
2815
+ }
2816
+
2817
+ /** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
2818
+ export function fileFleetNotice(root, to, body, key) {
2819
+ return fileMail({ root, label: boardLabel(root, 'default') }, to, body, key);
2628
2820
  }
2629
2821
 
2630
2822
  /**
@@ -2636,9 +2828,15 @@ export async function alertManagers(root, slug, text, key) {
2636
2828
  try { const entry = await loadWorkflow(root); managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : []; } catch { /* a workflow that cannot load names nobody */ }
2637
2829
  const label = boardLabel(root, slug);
2638
2830
  let told = 0;
2639
- for (const to of managers) {
2640
- try { await fileMail({ root, label }, to, text, key, { subject: text.split('\n')[0] }); told += 1; } catch { /* a filing that failed here is said by the next alert */ }
2641
- }
2831
+ if (!managers.length) return told;
2832
+ let board;
2833
+ try { board = openBoard(root, slug); board.label = label; }
2834
+ catch { return told; } // an unavailable store grants no permission to wake a session
2835
+ try {
2836
+ for (const to of managers) {
2837
+ try { const filed = await fileMail(board, to, text, key, { subject: text.split('\n')[0] }); if (filed.filed) told += 1; } catch { /* a filing that failed here is said by the next alert */ }
2838
+ }
2839
+ } finally { board.close(); }
2642
2840
  return told;
2643
2841
  }
2644
2842
 
@@ -2667,9 +2865,13 @@ export async function fileBoardMail(board, report, taskId = null) {
2667
2865
  const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
2668
2866
  const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
2669
2867
  tx(board, db => {
2868
+ if (filed.detached) {
2869
+ for (const member of mail.members) event(db, member.task_id, 'board_message_passed', { event: member.id, to: mail.to, reason: 'session detached to a person' });
2870
+ return;
2871
+ }
2670
2872
  for (const member of mail.members) if (!db.prepare("SELECT 1 FROM task_events WHERE kind='board_message_sent' AND task_id=? AND json_extract(payload,'$.event')=?").get(member.task_id, member.id)) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, message_id: filed.message_id, ...(mail.message_id ? { request_id: member.message_id ?? mail.message_id } : {}) });
2671
2873
  });
2672
- (report.mailed ??= []).push([first.task_id, mail.to]);
2874
+ if (filed.filed) (report.mailed ??= []).push([first.task_id, mail.to]);
2673
2875
  } catch (error) {
2674
2876
  for (const member of mail.members) report.undelivered.push([member.task_id, mail.to, String(error.message).slice(-200)]);
2675
2877
  }
@@ -2710,8 +2912,7 @@ function ensureSubscriptions(board, entry) {
2710
2912
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(t.current_run_id)?.metadata, {})?.supercode;
2711
2913
  const address = addressOf(session);
2712
2914
  if (!address || (session.slot ?? 'worker') !== 'worker') continue;
2713
- subscribeIn(db, t.id, address, 'worker');
2714
- for (const st of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of = ?').all(t.id)) subscribeIn(db, st.task_id, address, 'worker');
2915
+ replaceWorkerSubscriptions(db, t.id, address);
2715
2916
  }
2716
2917
  });
2717
2918
  }
@@ -2794,18 +2995,29 @@ async function notify(board, fleet, report, entry) {
2794
2995
  for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
2795
2996
  await yieldToOwner();
2796
2997
  if (stopping) break;
2998
+ if (view(board, db => personCustody(db, sub.chat_id))) continue;
2797
2999
  if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
2798
3000
  const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
3001
+ // Native notices wait with their cursor intact until their own door proves
3002
+ // liveness. A failed fleet reading supplies no permission to file them.
3003
+ // Agent, board and operator mailboxes route independently of a native PID.
3004
+ if (sessionAt(sub.chat_id)) {
3005
+ const alive = fleet.ok ? await subscriberAlive(fleet, sub.chat_id) : null;
3006
+ if (alive !== true) {
3007
+ report.undelivered.push([sub.task_id, sub.chat_id, alive === false ? 'its session is not running; its notices wait' : 'its liveness could not be read; its notices wait until it can be']);
3008
+ continue;
3009
+ }
3010
+ }
2799
3011
  // a session the fleet does not list this pass is told next pass: one reading is not a loss (a loaded box drops
2800
3012
  // sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
2801
3013
  // has its subscription moved when the new one starts
2802
3014
  const ended = recordedEnded(record, sub.chat_id);
2803
- if ((role !== 'creator' || ended) && fleet.ok) {
3015
+ if (!sessionAt(sub.chat_id) && (role !== 'creator' || ended) && fleet.ok) {
2804
3016
  const alive = await subscriberAlive(fleet, sub.chat_id);
2805
3017
  if (alive === false) continue;
2806
3018
  // unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
2807
3019
  if (alive === null) { report.undelivered.push([sub.task_id, sub.chat_id, 'its liveness could not be read; its notices wait until it can be']); continue; }
2808
- } else if (ended) continue; // no fleet reading: nothing is filed unread to a session recorded as ended
3020
+ } else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
2809
3021
  const kinds = heardBy(entry, role);
2810
3022
  if (!kinds.length) continue;
2811
3023
  // The retained cursor resumes the next page; one native read never materializes the whole unread history.
@@ -2847,11 +3059,21 @@ async function notify(board, fleet, report, entry) {
2847
3059
  text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
2848
3060
  } else text = noticeOf(entry, role, ev.kind, task, p);
2849
3061
  if (text) {
3062
+ // A verified binding may have replaced this audience while liveness
3063
+ // or an earlier filing awaited a door. Re-read before every filing.
3064
+ const currentSub = board.db.prepare(`SELECT last_event_id,delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
3065
+ const currentRole = json(currentSub?.delivery_metadata, {})?.role ?? 'creator';
3066
+ if (!currentSub || currentRole !== role) break;
3067
+ if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
2850
3068
  const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
2851
3069
  // filed under its fixed key: a second pass filing the same notice files nothing new
2852
- const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
3070
+ const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`,
3071
+ eligible: () => view(board, db => {
3072
+ const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
3073
+ return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
3074
+ }) });
2853
3075
  if (filed.new) tx(board, (db) => event(db, sub.task_id, 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, message_id: filed.message_id, wake }));
2854
- report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
3076
+ if (filed.filed) report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
2855
3077
  }
2856
3078
  cursor = ev.id;
2857
3079
  forward(cursor);
@@ -3163,7 +3385,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3163
3385
  if (!task) return false;
3164
3386
  // a status the platform cannot request a review from needs no scope built to say so
3165
3387
  if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform'))) return false;
3166
- const scope = scopeOf(db, board, task, { params: entry.params });
3388
+ const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
3167
3389
  if (scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
3168
3390
  // The card's Tasks are its subtasks; an open one holds the review, and says so.
3169
3391
  const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status));