@volter/supercode-orchestrator 0.5.226 → 0.5.227

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/store.mjs CHANGED
@@ -23,7 +23,7 @@ import { openZtrackBoard } from './ztrack.mjs';
23
23
  import { byOwner, LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
24
24
  import { isFilesBoard, openFilesBoard } from './files.mjs';
25
25
  import { signalBoardWrite } from './wake.mjs';
26
- import { PUBLICATION_COLUMNS, PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
26
+ import { PUBLICATION_COLUMNS, PUBLICATION_SCHEMA, incrementalStore, pruneDue, prunePublications, publicationTriggers, reclaimDue, reclaimSlice, recordCardPublications, trimDue, trimPublications } from './published.mjs';
27
27
 
28
28
  import { fillReadableIds } from './readable-id.mjs';
29
29
 
@@ -191,14 +191,19 @@ export function boardKey(root, slug = DEFAULT_BOARD, { creating = false, key: re
191
191
  }
192
192
  /** A card as a person names it: its board's key and its number (`VO-856`). */
193
193
  export const cardRef = (key, number) => (number ? `${key}-${number}` : null);
194
- // Sessions and worktree folders retain the card's immutable keyed number (`vo-856`).
195
- // Branches also carry its readable id; card agents use role-readable-id.
194
+ // A card's readable id names its branch, its worktree folder (workspace.mjs worktreeFolder) and its fresh sessions;
195
+ // card agents use role-readable-id. Its keyed number stays the immutable reference beside it.
196
196
  /** A card's handle (`vo-856`), or null for a card with no number. */
197
197
  export const cardHandle = (key, number) => (number ? `${String(key).toLowerCase()}-${number}` : null);
198
198
  /** The handle of card `id` on `board` (`vo-856`), or null before the card is filed. */
199
199
  export const cardHandleOf = (board, id) => id == null ? null : cardHandle(boardKey(board.root, board.slug), board.db.prepare(`SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`).get(id)?.number);
200
- /** A branch keeps the unique number and the card's readable id. */
201
- export const cardBranch = (handle, readableId) => readableId ? `${handle}-${readableId}` : handle;
200
+ /** Card `id`'s branch, named by its readable id (`voice-calls`, under a project's `<slug>/`), held by no other card. */
201
+ export function cardBranch(db, id, readableId, prefix = '') {
202
+ const branch = `${prefix}${readableId}`;
203
+ const holder = db.prepare('SELECT id FROM tasks WHERE branch_name=? AND id<>? LIMIT 1').get(branch, id);
204
+ if (holder) throw new Error(`branch ${branch} belongs to card ${holder.id}; name this card with another readable id`);
205
+ return branch;
206
+ }
202
207
  export const readableIdOf = (board, id) => board.db.prepare('SELECT readable_id FROM tasks WHERE id=?').get(id)?.readable_id ?? null;
203
208
 
204
209
  /** The home's boards that exist: `default` when it does, and every named one. */
@@ -370,6 +375,34 @@ function layOutNumbers(db) {
370
375
  } catch (error) { db.exec('ROLLBACK TO supercode_card_numbering; RELEASE supercode_card_numbering'); throw error; }
371
376
  }
372
377
 
378
+ /** Only cards are numbered (VO-3578 row 22): a store from before that rule keeps the numbers its tasks were given, and
379
+ * they go TASK_NUMBER_BATCH at a time, one batch in each board transaction (tx), so the republication each dropped
380
+ * number marks (published.mjs) is bounded with it: the live store's 3,320 at once held one transaction 16.5 s
381
+ * (VO-3578 take68). The sequence stays where it is, so no number a task held is handed out again. Once a board has
382
+ * none, this process stops looking. */
383
+ const TASK_NUMBER_BATCH = 100;
384
+ const unnumbered = new Set();
385
+ function dropTaskNumbers(db, board) {
386
+ const key = `${board.root}\0${board.key}`;
387
+ if (unnumbered.has(key)) return;
388
+ const rows = db.prepare(`SELECT n.task_id FROM ${CARD_NUMBERS} n JOIN supercode_cards c ON c.task_id = n.task_id WHERE c.subtask_of IS NOT NULL LIMIT ?`).all(TASK_NUMBER_BATCH);
389
+ if (!rows.length) { unnumbered.add(key); return; }
390
+ const drop = db.prepare(`DELETE FROM ${CARD_NUMBERS} WHERE task_id = ?`);
391
+ for (const row of rows) drop.run(row.task_id);
392
+ }
393
+
394
+ /** Only cards are numbered (VO-3578 row 22): a task's row (supercode_cards.subtask_of) is numbered by the insert trigger
395
+ * like any row, so a load gives each task's number back unless its source named it (`named`, task ids). The sequence
396
+ * returns to the highest number held, never below `floor` (what the source had already handed out). */
397
+ export function unnumberTasks(db, named = new Set(), floor = 0) {
398
+ const taken = db.prepare(`SELECT n.task_id FROM ${CARD_NUMBERS} n JOIN supercode_cards c ON c.task_id = n.task_id WHERE c.subtask_of IS NOT NULL`).all()
399
+ .filter((row) => !named.has(row.task_id));
400
+ if (!taken.length) return;
401
+ const drop = db.prepare(`DELETE FROM ${CARD_NUMBERS} WHERE task_id = ?`);
402
+ for (const row of taken) drop.run(row.task_id);
403
+ db.prepare(`UPDATE sqlite_sequence SET seq = MAX(?, (SELECT COALESCE(MAX(number), 0) FROM ${CARD_NUMBERS})) WHERE name = ?`).run(Number(floor) || 0, CARD_NUMBERS);
404
+ }
405
+
373
406
  /** Raise the numbers' high-water mark to the highest number held, and to `floor`: AUTOINCREMENT hands out only numbers
374
407
  * above it. For a store whose numbers were given or copied explicitly. */
375
408
  export function raiseCardNumbers(db, floor = 0) {
@@ -542,7 +575,9 @@ function heldHandle(entry, slug) {
542
575
  const statement = (st) => new Proxy(st, { get: (target, key) => key === 'run' ? (...args) => { guard(); return target.run(...args); } : bound(target, key) });
543
576
  const db = new Proxy(entry.board.db, { get: (target, key) => key === LIST_CONNECTION ? target : key === 'prepare' ? (sql) => statement(target.prepare(sql)) : key === 'exec' ? (sql) => { guard(); return target.exec(sql); } : bound(target, key) });
544
577
  let closed = false;
545
- return { ...entry.board, db, tx: (fn) => { guard(); return entry.board.tx(fn); },
578
+ // a rebuild runs on the backing's own connection, outside its write wrapper (ztrack.mjs vacuum); a plain store's is its db
579
+ const vacuum = (sql) => { guard(); return typeof entry.board.vacuum === 'function' ? entry.board.vacuum(sql) : entry.board.db.exec(sql); };
580
+ return { ...entry.board, db, vacuum, tx: (fn) => { guard(); return entry.board.tx(fn); },
546
581
  close() { if (closed) return; closed = true; entry.uses -= 1; if (entry.retired && !entry.uses) try { entry.board.close(); } catch { /* closed */ } } };
547
582
  }
548
583
 
@@ -620,7 +655,7 @@ export const tx = (board, fn) => {
620
655
  const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
621
656
  const started = performance.now();
622
657
  try {
623
- const result = board.tx(db => {const result=fn(db);fillReadableIds(db);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root,board.key);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
658
+ const result = board.tx(db => {const result=fn(db);fillReadableIds(db);dropTaskNumbers(db, board);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root,board.key);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
624
659
  signalBoardWrite(board.root, actorOf());
625
660
  return result;
626
661
  } finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
@@ -642,6 +677,13 @@ export function publishMarked(root) {
642
677
  const marked = step('marks', () => board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get()) || step('unimported', () => board.unimported?.());
643
678
  if (marked) published += step('publish', () => board.tx((db) => recordCardPublications(db, actorOf(), board.root, board.key)));
644
679
  if (step('prune due', () => pruneDue(board.db))) step('prune', () => board.tx((db) => prunePublications(db)));
680
+ // Maintenance that fails is said and left to the next wake: it never fails the owner's round. Older changes
681
+ // stored complete are trimmed a slice per wake (published.mjs storedSnapshot); a store `workflow compact`
682
+ // rebuilt gives free pages back a slice at a time.
683
+ try {
684
+ if (step('trim due', () => trimDue(board.db))) step('trim', () => board.tx((db) => trimPublications(db)));
685
+ else if (step('reclaim due', () => incrementalStore(board.db) && reclaimDue(board.db))) step('reclaim', () => board.tx((db) => reclaimSlice(db)));
686
+ } catch (error) { if (contention(error)) throw error; writeSync(2, `board ${slug}: publication maintenance failed and waits for the next wake: ${String(error?.message ?? error).slice(0, 300)}\n`); }
645
687
  } finally { board?.close(); }
646
688
  }
647
689
  return published;
package/board/tools.mjs CHANGED
@@ -132,7 +132,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
132
132
  }
133
133
  case 'kanban_request_review': {
134
134
  const stale = ownRun(); if (stale) return error(stale);
135
- await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
135
+ await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, plan: args.plan === true, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
136
136
  return ok({ task_id: tid, status: 'review' });
137
137
  }
138
138
  case 'kanban_request_changes': {
@@ -1,4 +1,4 @@
1
- import { allocateReadableId } from './readable-id.mjs';
1
+ import { allocateReadableId, cardReadableId } from './readable-id.mjs';
2
2
  // The board's workflow, interpreted (docs/architecture/orchestrator.md §2.9). The IR's `Orchestration.workflow`
3
3
  // (crates/interchange/src/orchestration/workflow.rs; Hermes's dispatcher, hermes.workflow.yaml, when a home declares
4
4
  // none) says every status a card can be in, what runs there, which events move a card and who may send them, what
@@ -6,7 +6,7 @@ import { allocateReadableId } from './readable-id.mjs';
6
6
  // the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
7
7
  // pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
8
8
  import { current } from './call.mjs';
9
- import { detachedCard, detachedSession } from './custody.mjs';
9
+ import { detachedCard, detachedSession, personOf } from './custody.mjs';
10
10
  import { notRuntime } from './owner-files.mjs';
11
11
  import { machineCall } from './doors.mjs';
12
12
  import { execFile } from 'node:child_process';
@@ -14,12 +14,12 @@ import { promisify } from 'node:util';
14
14
  import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node:fs';
15
15
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
16
16
  import { randomBytes } from 'node:crypto';
17
- import { actorOf, appendRunObservation, boardKey, cardHandleOf, event as record, json, now } from './store.mjs';
18
- import { releaseAllSideSessions } from './side-sessions.mjs';
17
+ import { actorOf, appendRunObservation, boardKey, cardBranch, cardHandleOf, event as record, json, now } from './store.mjs';
18
+ import { releaseAllSideSessions, sideSessions } from './side-sessions.mjs';
19
19
  import { evaluate, holds, render, needsSources } from './expr.mjs';
20
20
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
21
- import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
22
- import { cardNumber, identifyNotice, numbered } from './notices.mjs';
21
+ import { branchClaim, cardCommitsOn, isLocalWorkspace, isWorkspacePath, unrecordedMaking } from './workspace.mjs';
22
+ import { cardName, cardNumber, identifyNotice, numbered } from './notices.mjs';
23
23
  import { latestEvent } from './queries.mjs';
24
24
 
25
25
  const loaded = new Map();
@@ -309,7 +309,7 @@ function commitSource(db, task) {
309
309
  * (`commit_unread` says why): an unread checkout is not a card with no commits, and nothing may count it as one. */
310
310
  export function sessionProgress(db, task) {
311
311
  const event = one(db, `SELECT MAX(e.id) AS id FROM task_events e WHERE
312
- (e.task_id=? AND e.kind IN ('specified','review_requested','review_pass','changes_requested','audited')) OR
312
+ (e.task_id=? AND e.kind IN ('specified','review_requested','review_pass','pr_review_pass','changes_requested','audited')) OR
313
313
  (e.task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of=?) AND e.kind IN ('completed','cancelled'))`, task.id, task.id);
314
314
  const { path } = commitSource(db, task);
315
315
  const read = path ? commitTimes.get(`${path}:${task.id}`) ?? { known: false, error: 'its checkout has not been read yet' } : { known: true, value: null };
@@ -445,28 +445,51 @@ export function cardSectionHeading(line) {
445
445
  const label = /^\s*([A-Za-z][A-Za-z0-9 _-]*):\s*$/.exec(line);
446
446
  return label ? label[1].trim().toLowerCase() : null;
447
447
  }
448
- /** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
449
- * and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
450
- * needs, added after it); no other line added, removed or changed. */
451
- function onlyTicks(was, body) {
452
- if (typeof body !== 'string') return false;
453
- const before = String(was ?? '').split('\n');
454
- const after = body.split('\n');
455
- if (before.length !== after.length) return false;
456
- // an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
448
+ const CHECKBOX = /^\s*[-*] \[[ xX]\]/;
449
+ /** A card body read as Markdown reads it: a checkbox item is one block with its continuation lines (the indented lines
450
+ * after it, with any blank lines between them; engine.mjs bodyTasks reads a task's lines the same way), its `text` the
451
+ * item joined onto one line; every other line is a block of its own. Each block keeps the section it sits under. */
452
+ function bodyBlocks(body) {
453
+ const lines = String(body ?? '').split('\n');
454
+ const blocks = [];
457
455
  let section = null;
458
- return before.every((line, i) => {
456
+ for (let i = 0; i < lines.length; i += 1) {
457
+ const line = lines[i];
458
+ const item = blocks.at(-1)?.item ? blocks.at(-1) : null;
459
+ let next = i;
460
+ while (item && next < lines.length && !lines[next].trim()) next += 1;
461
+ if (item && next < lines.length && /^\s+\S/.test(lines[next]) && !CHECKBOX.test(lines[next])) {
462
+ item.lines.push(...lines.slice(i, next + 1));
463
+ item.text += ` ${lines[next].trim()}`;
464
+ i = next;
465
+ continue;
466
+ }
459
467
  const heading = cardSectionHeading(line);
460
468
  if (heading !== null) section = heading;
461
- if (line === after[i]) return true;
462
- if (section !== 'acceptance') return false;
463
- const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
464
- // Outcome prose may contain colons. Preserve the entire old line and permit only an appended pointer.
465
- if (TICKED_OUTCOME.test(line)) return pointed(line);
466
- const open = OPEN_OUTCOME.exec(line);
469
+ blocks.push({ section, item: CHECKBOX.test(line), text: line, lines: [line] });
470
+ }
471
+ return blocks;
472
+ }
473
+ /** Whether `body` is `was` with only outcomes ticked: each changed outcome (a checkbox item under the Acceptance heading,
474
+ * read whole with its continuation lines) was `- [ ] X` and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X`
475
+ * and is now `- [x] X: <pointer>` (the pointer a tick needs, added after it); no other line added, removed or changed. */
476
+ function onlyTicks(was, body) {
477
+ if (typeof body !== 'string') return false;
478
+ const before = bodyBlocks(was);
479
+ const after = bodyBlocks(body);
480
+ if (before.length !== after.length) return false;
481
+ // an outcome is an item of the card's Acceptance section (its heading at any level), not a task or any other checkbox
482
+ return before.every((block, i) => {
483
+ const now = after[i];
484
+ if (block.lines.join('\n') === now.lines.join('\n')) return true;
485
+ if (block.section !== 'acceptance' || !block.item || !now.item) return false;
486
+ const pointed = (ticked) => now.text.startsWith(`${ticked}: `) && now.text.trim().length > ticked.trim().length + 1;
487
+ // Outcome prose may contain colons. Preserve the entire old item and permit only an appended pointer.
488
+ if (TICKED_OUTCOME.test(block.text)) return pointed(block.text);
489
+ const open = OPEN_OUTCOME.exec(block.text);
467
490
  if (!open) return false;
468
491
  const ticked = `${open[1]}[x] ${open[2]}`;
469
- return after[i] === ticked || pointed(ticked);
492
+ return now.text === ticked || pointed(ticked);
470
493
  });
471
494
  }
472
495
 
@@ -538,6 +561,9 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
538
561
  blocked_by: blocks,
539
562
  paused: json(state.paused, null),
540
563
  detached: json(state.detached, null),
564
+ // the person the card is assigned to (custody.mjs personOf), or null: a person's card runs no session and closes on
565
+ // that person's completion
566
+ person: personOf(task.assignee),
541
567
  archived: Boolean(state.archived),
542
568
  every: state.every ?? null,
543
569
  next_run_at: state.next_run_at ?? null,
@@ -546,16 +572,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
546
572
  board: board.slug,
547
573
  get parents() { return parents(); },
548
574
  get children() { return children(); },
549
- acceptance_complete: () => {
550
- const lines = String(task.body ?? '').split('\n');
551
- let acceptance = false;
552
- return lines.every((line) => {
553
- const heading = cardSectionHeading(line);
554
- if (heading !== null) acceptance = heading === 'acceptance';
555
- if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
556
- return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
557
- });
558
- },
575
+ // every outcome ticked with a pointer, each outcome read whole: a pointer on a wrapped item's continuation line counts
576
+ acceptance_complete: () => bodyBlocks(task.body).every((b) => b.section !== 'acceptance' || !b.item || /^\s*[-*] \[[xX]\] .+: \S.+$/.test(b.text)),
559
577
  get counters() { return { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session().goal_turns || 0) }; },
560
578
  goal_mode: Boolean(task.goal_mode),
561
579
  get implementer() { return asked().implementer ?? task.assignee; },
@@ -571,7 +589,9 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
571
589
  get last_reviewer() { return changes().reviewer ?? null; },
572
590
  get arc() { return task.subtask_of ? one(db, `SELECT t.id, ${related}, t.title, t.body, t.assignee FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.id = ?`, task.subtask_of) ?? null : null; },
573
591
  // the cards filed as its subtasks (`create --subtask-of`), in the order they were filed
574
- get subtasks() { return all(db, `SELECT t.id, ${related}, t.title, t.assignee FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, task.id); },
592
+ // `paused`: a paused subtask's pause (who and why), or null; a paused subtask holds no review of its arc
593
+ get subtasks() { return all(db, `SELECT t.id, ${related}, t.title, t.assignee, c.paused FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, task.id)
594
+ .map((t) => ({ ...t, paused: json(t.paused, null) })); },
575
595
  get queue_reason() { return json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'respawn_guarded' AND created_at >= ? ORDER BY id DESC LIMIT 1",
576
596
  task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null; },
577
597
  // the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
@@ -662,18 +682,17 @@ function noticeRun(db, task) {
662
682
  : all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? ORDER BY id DESC', task.id).find((r) => json(r.metadata, {})?.supercode?.session_id) ?? null;
663
683
  }
664
684
 
665
- function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession = null) {
685
+ function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, adopting = false) {
686
+ // A verified adoption starts a new run before binding its session; that session is told its role's brief by the
687
+ // adoption itself (dispatch.mjs roleBrief), never a second notice of the transition.
688
+ if (adopting) return;
666
689
  const after = cardOf(db, task.id), to = statusOf(db, after);
667
- // A verified adoption starts a new run before binding its session. Its notice
668
- // belongs to that new run and verified identity, never the last ended session
669
- // that noticeRun found before the transition.
670
- const targetRun = noticeSession && after.current_run_id
671
- ? one(db, 'SELECT id, metadata FROM task_runs WHERE id = ?', after.current_run_id) : beforeRun;
690
+ const targetRun = beforeRun;
672
691
  const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
673
692
  const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
674
693
  const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
675
694
  if (!targetRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === targetRun.id)) return;
676
- const session = noticeSession ?? json(targetRun.metadata, {})?.supercode;
695
+ const session = json(targetRun.metadata, {})?.supercode;
677
696
  if (detachedSession(db, session)) return;
678
697
  const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
679
698
  if (!address && !session?.launch_key && !session?.pane) return;
@@ -683,10 +702,8 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
683
702
  if (address && address === actorOf()) return;
684
703
  if (session?.close_confirmed_at) return;
685
704
  const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: targetRun.id } } });
686
- const defaultNotice = noticeSession
687
- ? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
688
- : '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
689
- const body = numbered(boardKey(board.root, board.slug), cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
705
+ const defaultNotice = '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
706
+ const body = numbered(boardKey(board.root, board.slug), cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope), cardName(db, task.id));
690
707
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
691
708
  const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
692
709
  const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
@@ -752,7 +769,12 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
752
769
  const from = localOnly ? statusOf(db, task) : declaredStatus(db, board, entry, task);
753
770
  if (localOnly && !workflow.statuses[from]) return null; // only fire adopts an imported status
754
771
  const candidates = transitionsFor(workflow, from, name);
755
- if (!candidates.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there`);
772
+ // a refusal names the right form: each transition's description, its card placeholders filled with this card
773
+ const said = (t) => t.description ? String(t.description).replaceAll('<arc>', id).replaceAll('<card>', id) : null;
774
+ if (!candidates.length) {
775
+ const elsewhere = (workflow.verbs?.[name] ?? []).filter((t) => t.from?.length && t.description);
776
+ throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there.${elsewhere.map((t) => ` From ${t.from.join(' or ')}: ${said(t)}`).join('')}`);
777
+ }
756
778
  // `sender`: the roles the event was sent with (a validator can ask who is asking)
757
779
  const scope = scopeOf(db, board, task, { params, event: payload, lazy: localOnly, deferSources: localOnly, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
758
780
  const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
@@ -760,7 +782,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
760
782
  const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
761
783
  const request = state && candidates.some(transition => transition.by?.includes('creator'))
762
784
  ? ` Ask the manager with workflow request-state ${id} --state ${state} --reason <why> --evidence <verdict-and-pointers>.` : '';
763
- throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}${request}`);
785
+ throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map(said).filter(Boolean).join(' ')}${request}`);
764
786
  }
765
787
  // A source-dependent branch is undecidable before observation; preflight must not choose its fallback.
766
788
  const needsSource = expr => needsSources(expr, workflow.derived);
@@ -770,7 +792,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
770
792
  // none of the sender's rules holds, but another role's does (one verb with a rule per case, as `set` has one per
771
793
  // field): it is that role's to send
772
794
  const theirs = taken ? [] : candidates.filter((t) => !allowed.includes(t) && holds(t.guard ?? null, scope));
773
- if (theirs.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(theirs.flatMap((t) => t.by ?? []))].join(' or ')}. ${theirs.map((t) => t.description).filter(Boolean).join(' ')}`);
795
+ if (theirs.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(theirs.flatMap((t) => t.by ?? []))].join(' or ')}. ${theirs.map(said).filter(Boolean).join(' ')}${allowed.length ? ` Yours: ${allowed.map(said).filter(Boolean).join(' ')}` : ''}`);
774
796
  if (!taken) {
775
797
  const state = scope.card;
776
798
  const reasons = [state.paused && `paused: ${state.paused.reason ?? 'manager pause'}`,
@@ -787,12 +809,18 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
787
809
  return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
788
810
  }
789
811
 
790
- export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, noticeSession = null } = {}) {
812
+ export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, adopting = false } = {}) {
791
813
  const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
792
814
  if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
793
- const { task, from, taken, scope, beforeRun } = checked;
815
+ const { task, from, scope, beforeRun } = checked;
816
+ let { taken } = checked;
794
817
  const { params } = entry;
795
818
  const effects = [];
819
+ // Changing a card's text never moves it out of a review or an ended status, whatever a workflow's specify declares:
820
+ // a reviewed card sent back to work would launch a coder run on it. Its words still land and are recorded. The one
821
+ // move specify makes is a workflow's intake (Hermes's triage → todo), which leaves neither.
822
+ if (name === 'specify' && taken.target && (entry.workflow.statuses[from]?.run_role === 'reviewer' || ['done', 'archived'].includes(storedAs(entry.workflow, from))))
823
+ taken = { ...taken, target: undefined };
796
824
  const to = take(db, board, entry, task, from, taken, scope, effects);
797
825
  settle(db, board, entry, id, effects);
798
826
  projectStatus(db, board, entry, id);
@@ -800,11 +828,11 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
800
828
  // never turns it into an incident that waits for a second act
801
829
  const ended = task.current_run_id && one(db, 'SELECT ended_at FROM task_runs WHERE id = ?', task.current_run_id)?.ended_at != null ? task.current_run_id : null;
802
830
  if ((scope.card.dispatch_hold || ended) && roles.some(role => ['manager','creator'].includes(role)) &&
803
- (['resume','reclaim','replace_session','reopen','reopen_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
831
+ (['resume','reclaim','replace_session','reopen','reopen_review','return_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
804
832
  record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {}),
805
833
  ...(ended && name === 'set' && payload.field === 'assignee' && (payload.value ?? null) !== (task.assignee ?? null)
806
834
  ? { reassigned_from: task.assignee ?? null, reassigned_to: payload.value ?? null, abandon_waiting_input: true } : {})});
807
- queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession);
835
+ queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, adopting);
808
836
  return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
809
837
  }
810
838
 
@@ -967,6 +995,10 @@ const FORMAT = {
967
995
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
968
996
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
969
997
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
998
+ // the manager's return of a review with no verdict: the request it returned and its run, so the next review's selection
999
+ // reads which heads it named (ci.mjs returnedReview)
1000
+ review_returned: ({ e, before, after, to }) => ({ reason: e.reason ?? null, status: to, review_request: before.last_events.review_requested?.id ?? null,
1001
+ review_run: before.current_run_id ?? null, implementer: after.assignee }),
970
1002
  review_voided: ({ e, before }) => ({ reason: e.reason, review_request: before.last_events.review_requested?.id ?? null,
971
1003
  review_run: before.latest_review?.id ?? null, verdict: Math.max(before.last_events.review_pass?.id ?? 0, before.last_events.changes_requested?.id ?? 0) || null }),
972
1004
  // what arrived (an unblock says it), and where the card resumes
@@ -1083,6 +1115,10 @@ const ACTIONS = {
1083
1115
  if ((value ?? null) === task.machine) return;
1084
1116
  if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
1085
1117
  ACTIONS.forget_session(ctx, '*');
1118
+ // a worktree the card made with only its path recorded (workspace.mjs unrecordedMaking) is recorded as made before
1119
+ // the move clears the path, so the branch it made stays its own on the new machine
1120
+ const made = unrecordedMaking(ctx.db, ctx.id);
1121
+ if (made) record(ctx.db, ctx.id, 'worktree_made', made, null);
1086
1122
  run(ctx.db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', ctx.id);
1087
1123
  cardField(ctx, 'workspace_root', null);
1088
1124
  cardField(ctx, 'machine', value ?? null);
@@ -1130,10 +1166,13 @@ const ACTIONS = {
1130
1166
  WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
1131
1167
  ctx.runId = runId;
1132
1168
  ctx.lastOutcome = outcome;
1133
- runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
1169
+ // the card's own coder bound as its reviewer leaves the reviewer slot unclosed (handBackCoder), here or earlier in
1170
+ // this move (its close_session); the run being ended is the one it was handed back from
1171
+ const handed = handBackCoder(ctx.db, ctx.id, runId) || handedBackFrom(json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode, runId);
1172
+ if (!handed) runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
1134
1173
  // A declared completion may end the attempt while retaining its role session. In particular, its own caller
1135
1174
  // must be able to finish and return before the board starts the independent review.
1136
- if (close_session) {
1175
+ if (close_session && !handed) {
1137
1176
  requestSessionClose(ctx.db, ctx.id, { runId });
1138
1177
  ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
1139
1178
  }
@@ -1198,19 +1237,31 @@ const ACTIONS = {
1198
1237
  if (said?.workspace) {
1199
1238
  const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
1200
1239
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
1201
- ACTIONS.forget_session(ctx, '*');
1202
- const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
1203
1240
  const moves = Object.hasOwn(ws, 'machine');
1241
+ // A retarget lets go of the card's sessions, but never of the arc's own coder while it is open (openCoder): its
1242
+ // next run, a review's return included, is delivered into that session, never a fresh one started beside it
1243
+ // (VO-3755 t_c2d5d21b: a retarget forgot adopted coder c46c2896 at 06:51:01Z on 2026-10-27, and the run after its
1244
+ // review's changes_requested, 2736, launched ee91b4d8 beside it). A move to another machine lets it go.
1245
+ forgetSessions(ctx, '*', null, moves ? null : openCoder(ctx.db, ctx.id));
1246
+ const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
1204
1247
  record(ctx.db, ctx.id, 'workspace_changed', { from: { kind: task.workspace_kind, repository: prior?.workspace_anchor, branch: task.branch_name, ...(moves ? { machine: task.machine } : {}) }, to: { kind: ws.kind, repository: ws.repository, branch: ws.branch, ...(moves ? { machine: ws.machine } : {}) } }, null);
1205
1248
  run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
1206
1249
  cardField(ctx, 'workspace_anchor', ws.repository ?? null);
1207
1250
  cardField(ctx, 'workspace_root', null);
1208
1251
  if (moves) cardField(ctx, 'machine', ws.machine ?? null);
1252
+ // a branch the board named for the card is claimed until its worktree is made (workspace.mjs branchClaim)
1253
+ if (ws.named && ws.branch) record(ctx.db, ctx.id, 'branch_named', { branch: ws.branch, repository: ws.repository ?? null }, null);
1209
1254
  }
1210
1255
  if (said?.readable_id != null) {
1211
1256
  const task = cardOf(ctx.db, ctx.id);
1212
- const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
1257
+ const readable = task.subtask_of ? allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id) : cardReadableId(ctx.db, said.readable_id, ctx.id);
1213
1258
  run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
1259
+ // a branch the board named for the card follows its name until its worktree is made (workspace.mjs branchClaim)
1260
+ if (readable !== task.readable_id && !task.subtask_of && task.branch_name?.split('/').at(-1) === task.readable_id && branchClaim(ctx.db, ctx.id, task.branch_name)) {
1261
+ const branch = cardBranch(ctx.db, ctx.id, readable, task.branch_name.slice(0, task.branch_name.lastIndexOf('/') + 1));
1262
+ run(ctx.db, 'UPDATE tasks SET branch_name=? WHERE id=?', branch, ctx.id);
1263
+ record(ctx.db, ctx.id, 'branch_named', { branch, repository: one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id)?.workspace_anchor ?? null }, null);
1264
+ }
1214
1265
  if (readable !== task.readable_id) {
1215
1266
  // Keep the obligation with the edit: a stopped dispatcher or interrupted caller must not lose the rename.
1216
1267
  // Delivery reads the current name, so an older edit can never restore an obsolete name.
@@ -1264,15 +1315,7 @@ const ACTIONS = {
1264
1315
  const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
1265
1316
  const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
1266
1317
  if (typeof slot === 'object' && slot && !harness) return;
1267
- for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
1268
- const whole = json(row.metadata, {}) ?? {};
1269
- const session = whole.supercode;
1270
- if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
1271
- requestSessionClose(ctx.db, ctx.id, { runId: row.id });
1272
- const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
1273
- latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
1274
- run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
1275
- }
1318
+ forgetSessions(ctx, name, harness);
1276
1319
  },
1277
1320
  // an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
1278
1321
  // a card it does not apply to now is passed over
@@ -1302,15 +1345,19 @@ const ACTIONS = {
1302
1345
  ctx.effects.push({ kind: 'board_message', id: ctx.id });
1303
1346
  },
1304
1347
  send_to_actor(ctx, text) {
1348
+ // A board message that wakes the run's session (an idle reminder) carries the run's pending brief when its caller
1349
+ // read one (dispatch.mjs pendingBrief): the text saved here is the text delivered, whatever the workflow's words.
1350
+ const brief = typeof ctx.scope.pending_brief === 'string' && ctx.scope.pending_brief ? ctx.scope.pending_brief : null;
1351
+ if (brief && !String(text ?? '').includes(brief)) text = `${String(text ?? '')} ${brief}`;
1305
1352
  const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
1306
1353
  const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
1307
- effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(boardKey(ctx.board.root, ctx.board.slug), cardNumber(ctx.db, ctx.id), text));
1354
+ effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(boardKey(ctx.board.root, ctx.board.slug), cardNumber(ctx.db, ctx.id), text, cardName(ctx.db, ctx.id)));
1308
1355
  run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
1309
1356
  ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
1310
1357
  },
1311
1358
  };
1312
1359
 
1313
- /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
1360
+ /** A newer attempt on a card retires its stale references. Adoption can share a session across cards. */
1314
1361
  export function retireSupersededRuns(db, id, runId, session) {
1315
1362
  if (!session?.session_id && !session?.pane) return;
1316
1363
  for (const old of all(db, `SELECT id,task_id,ended_at,metadata FROM task_runs WHERE id IN (
@@ -1321,6 +1368,10 @@ export function retireSupersededRuns(db, id, runId, session) {
1321
1368
  runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
1322
1369
  const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
1323
1370
  if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
1371
+ // Attaching records work on another card, not a transfer of its native session or agent identity.
1372
+ // Keep both cards' runs and close receipts independent; an attachment cannot retire its starter's run,
1373
+ // and the starter's later lifecycle must not retire an attached card's run either.
1374
+ if (old.task_id !== id && (session.mode === 'adopt' || s.mode === 'adopt')) continue;
1324
1375
  if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
1325
1376
  const transferred = (s.pane && s.pane === session.pane) || (!s.pane && !session.pane && s.session_id === session.session_id);
1326
1377
  if (old.ended_at) {
@@ -1351,6 +1402,8 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
1351
1402
  const session = whole.supercode;
1352
1403
  if (detachedSession(db, session)) continue;
1353
1404
  if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
1405
+ // A reviewer binding of the card's own coder is never the review's to close (handBackCoder)
1406
+ if ((session.slot ?? 'worker') === 'reviewer' && handBackCoder(db, id, row.id)) continue;
1354
1407
  if (session.close_requested && !session.closed_at) continue;
1355
1408
  whole.supercode = { ...session, close_requested: session.close_requested ?? now() };
1356
1409
  delete whole.supercode.closed_at; // older versions recorded success without checking the receipt
@@ -1358,6 +1411,78 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
1358
1411
  }
1359
1412
  }
1360
1413
 
1414
+ /**
1415
+ * Let go of card `ctx.id`'s sessions in slot `name` ('*': every slot), those of another harness than `harness` when it is
1416
+ * given: each is asked to close and is never continued; the slot's next run starts a new session. `keep` names a worker
1417
+ * session that stays the arc's (openCoder): it is neither closed nor forgotten, so its next run is delivered into it.
1418
+ */
1419
+ function forgetSessions(ctx, name, harness, keep = null) {
1420
+ for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
1421
+ const whole = json(row.metadata, {}) ?? {};
1422
+ const session = whole.supercode;
1423
+ if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
1424
+ if (keep && session.session_id === keep && (session.slot ?? 'worker') === 'worker') continue;
1425
+ requestSessionClose(ctx.db, ctx.id, { runId: row.id });
1426
+ const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
1427
+ // a reviewer binding the close handed back to its coder's slot is the coder's, and is not forgotten with the review
1428
+ if (handedBackFrom(latest.supercode, row.id) && name !== '*' && name !== 'worker') continue;
1429
+ latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
1430
+ run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * The session id of card `id`'s worker slot's latest session when it is still open: an adopted session the board released
1436
+ * (left running at its run's end) or one retained for its next run, never closed or let go. It is the arc's coder, and
1437
+ * every next implementer run continues it (dispatch.mjs continuationOf); null when the slot's session is closed or gone.
1438
+ */
1439
+ function openCoder(db, id) {
1440
+ for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', id)) {
1441
+ const s = json(row.metadata, {})?.supercode;
1442
+ if (!s?.session_id || (s.slot ?? 'worker') !== 'worker') continue;
1443
+ return !s.forgotten && !detachedSession(db, s) && (s.released || (s.retain_after_run && !s.close_requested)) ? s.session_id : null;
1444
+ }
1445
+ return null;
1446
+ }
1447
+
1448
+ /**
1449
+ * Run `runId`'s reviewer binding, when it is card `id`'s own coder (a worker or side session adopted onto its review by
1450
+ * mistake, t_2a2ef475 run 2732), leaves the reviewer slot as its review ends, and is never closed by that end:
1451
+ * - the worker slot's latest session (the coder's own, kept open through review by ADR 0019) is handed back to the
1452
+ * worker slot, retained as a finish retains it, so the next implementer run continues it (transferRetainedSession);
1453
+ * - any other session (a side session beside that coder) is released, as the board releases any session it did not
1454
+ * launch: left running, its close settled, in no slot, so it neither outranks the coder's session in the worker slot
1455
+ * nor holds a later review.
1456
+ * Answers whether it did. After it, the binding is an ordinary one of its new slot, closed like any other.
1457
+ */
1458
+ function handBackCoder(db, id, runId) {
1459
+ const whole = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {}) ?? {};
1460
+ const session = whole.supercode;
1461
+ if (!session?.session_id || session.role !== 'reviewer' || (session.slot ?? 'worker') !== 'reviewer' || !ownCoder(db, id, session.session_id)) return false;
1462
+ const coder = one(db, `SELECT json_extract(metadata, '$.supercode.session_id') AS session_id FROM task_runs WHERE task_id = ? AND id != ?
1463
+ AND COALESCE(json_extract(metadata, '$.supercode.slot'), 'worker') = 'worker' AND json_extract(metadata, '$.supercode.session_id') IS NOT NULL
1464
+ ORDER BY id DESC LIMIT 1`, id, runId)?.session_id;
1465
+ const at = now();
1466
+ if (coder === session.session_id) whole.supercode = { ...session, slot: 'worker', retain_after_run: true, close_requested: null, handed_back_from: 'reviewer', handed_back_run: runId };
1467
+ else {
1468
+ whole.supercode = { ...session, slot: 'side', retain_after_run: false, close_requested: session.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null, released: true, handed_back_from: 'reviewer', handed_back_run: runId };
1469
+ record(db, id, 'session_released', { session_id: session.session_id, pane: session.pane ?? null, machine: session.machine ?? null, reason: 'the card\'s own coder, bound as its reviewer: left running, out of the reviewer slot' }, runId);
1470
+ }
1471
+ run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
1472
+ return true;
1473
+ }
1474
+
1475
+ /** Whether binding `session` is the one handBackCoder handed back from run `runId`'s review. The marker names its run, so a
1476
+ * copy of the binding on another run (a continuation) is never taken for it, whatever the copy carried. */
1477
+ const handedBackFrom = (session, runId) => session?.handed_back_from === 'reviewer' && Number(session.handed_back_run) === Number(runId);
1478
+
1479
+ /** Whether native session `sessionId` is card `id`'s own coder: a worker-slot session of any of its runs, or a side session. */
1480
+ export function ownCoder(db, id, sessionId) {
1481
+ if (sideSessions(db, id).some((s) => s.session === sessionId)) return true;
1482
+ return Boolean(one(db, `SELECT 1 FROM task_runs WHERE task_id = ? AND COALESCE(json_extract(metadata, '$.supercode.slot'), 'worker') = 'worker'
1483
+ AND json_extract(metadata, '$.supercode.session_id') = ? LIMIT 1`, id, sessionId));
1484
+ }
1485
+
1361
1486
  function runSession(ctx, change) {
1362
1487
  const runId = ctx.runId ?? one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id)?.current_run_id;
1363
1488
  if (!runId) return;
@@ -1475,7 +1600,9 @@ function settleRelations(db, board, entry, id, effects) {
1475
1600
  const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
1476
1601
  run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, answered: answer } : b)), id);
1477
1602
  }
1478
- if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
1603
+ // an answer's reaction names who answered as its actor (`by`): nobody is told of what their own reply caused
1604
+ const replier = answer ? mailboxAnswers.get(block.id)?.by ?? null : null;
1605
+ if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}`, ...(replier ? { by: replier } : {}) } }).effects);
1479
1606
  // a block that has aged is told once, not every age interval for as long as it lasts
1480
1607
  else if (policy.aged && policy.age_seconds != null && !block.aged_at) {
1481
1608
  const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));