@volter/supercode-orchestrator 0.5.205 → 0.5.207

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 (52) 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 +278 -97
  15. package/board/client.mjs +18 -2
  16. package/board/custody.mjs +109 -0
  17. package/board/dispatch.mjs +305 -80
  18. package/board/doors.mjs +47 -17
  19. package/board/engine.mjs +92 -27
  20. package/board/files.mjs +17 -3
  21. package/board/fleet-listeners.mjs +268 -0
  22. package/board/fleet-observer.mjs +79 -0
  23. package/board/fleet-targets.mjs +82 -0
  24. package/board/incidents.mjs +7 -0
  25. package/board/listing.mjs +8 -2
  26. package/board/machine-fleet.mjs +7 -0
  27. package/board/notice-receipts.mjs +67 -0
  28. package/board/notices.mjs +5 -0
  29. package/board/owner-door-worker.mjs +61 -0
  30. package/board/owner-door.mjs +46 -0
  31. package/board/owner-health.mjs +8 -2
  32. package/board/published.mjs +2 -2
  33. package/board/record-view.mjs +2 -0
  34. package/board/store.mjs +84 -3
  35. package/board/tools.mjs +11 -4
  36. package/board/workflow.mjs +96 -31
  37. package/board/ztrack.mjs +207 -32
  38. package/board-input.d.ts +2 -0
  39. package/board-input.mjs +16 -0
  40. package/connection.mjs +153 -0
  41. package/index.d.ts +7 -5
  42. package/index.mjs +1 -1
  43. package/installed-apps.mjs +31 -20
  44. package/loop.mjs +91 -36
  45. package/mcp/bridge.mjs +12 -0
  46. package/mcp/hermes-kanban-tools.json +22 -14
  47. package/mcp/mount.mjs +10 -9
  48. package/ontology.d.ts +37 -0
  49. package/operator.mjs +72 -4
  50. package/orchestration.mjs +7 -2
  51. package/package.json +9 -5
  52. package/reducer.mjs +74 -5
@@ -5,6 +5,7 @@
5
5
  // the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
6
6
  // pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
7
7
  import { current } from './call.mjs';
8
+ import { detachedCard, detachedSession } from './custody.mjs';
8
9
  import { notRuntime } from './owner-files.mjs';
9
10
  import { machineCall } from './doors.mjs';
10
11
  import { execFile } from 'node:child_process';
@@ -17,9 +18,24 @@ import { releaseAllSideSessions } from './side-sessions.mjs';
17
18
  import { evaluate, holds, render, needsSources } from './expr.mjs';
18
19
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
19
20
  import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
20
- import { identifyNotice } from './notices.mjs';
21
+ import { cardNumber, identifyNotice, numbered } from './notices.mjs';
21
22
 
22
23
  const loaded = new Map();
24
+ // the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
25
+ const workflowWatchers = new Set();
26
+ const hold = (root, entry) => {
27
+ const again = loaded.get(root)?.orchestration !== entry.orchestration;
28
+ loaded.set(root, entry);
29
+ if (again) for (const watcher of workflowWatchers) watcher(root, entry);
30
+ };
31
+ /** `watcher(root, entry)` is told each workflow this process holds, now and whenever one is read again; answers its stop. */
32
+ export function watchWorkflows(watcher) {
33
+ workflowWatchers.add(watcher);
34
+ for (const [root, entry] of loaded) watcher(root, entry);
35
+ return () => workflowWatchers.delete(watcher);
36
+ }
37
+ /** Hold the workflow another thread of this process read for `root` (the owner's, on its read door). */
38
+ export function holdWorkflow(root, entry) { loaded.set(root, { ...entry, root }); }
23
39
  const homes = new Map(); // root → Promise<orchestration>: the home as the IR reads it, held until it changes
24
40
 
25
41
  /**
@@ -49,15 +65,15 @@ export async function loadWorkflow(root) {
49
65
  entry = workflowEntry(orchestration);
50
66
  entry.root = root;
51
67
  entry.orchestration = orchestration;
52
- loaded.set(root, entry);
68
+ hold(root, entry);
53
69
  }
54
70
  current().workflows.set(root, entry);
55
71
  return entry;
56
72
  }
57
73
 
58
74
  // What the board's view of a home is decoded from: the workflow, each profile's config, credentials and the agent
59
- // layer, and which profiles there are. A session store or a run log changing is no change to it.
60
- const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
75
+ // layer, persona, and which profiles there are. A session store or a run log changing is no change to it.
76
+ const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json|AGENTS\.md)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
61
77
 
62
78
  /**
63
79
  * Follow the home's own files, so the held view is read again on its next use after one it is decoded from changes:
@@ -75,7 +91,7 @@ export function followHome(root, { onChange = () => {}, onLost = () => {} } = {}
75
91
  const linked = new Map(); // target folder → watcher
76
92
  const followLinks = () => {
77
93
  const targets = new Map();
78
- const decoded = ['workflow.yaml', 'config.yaml', '.env', 'agents.json'];
94
+ const decoded = ['workflow.yaml', 'config.yaml', '.env', 'agents.json', 'AGENTS.md'];
79
95
  const watchIn = (folder, name) => { if (!targets.has(folder)) targets.set(folder, new Set()); targets.get(folder).add(name); };
80
96
  let realRoot = root;
81
97
  try { realRoot = realpathSync(root); } catch { /* the home's own watch reports it */ }
@@ -143,7 +159,7 @@ export function workflowFor(root) {
143
159
  /** Use `entry` for `root` (a caller that read the IR itself). */
144
160
  export function useWorkflow(root, entry) {
145
161
  entry.root = root;
146
- loaded.set(root, entry);
162
+ hold(root, entry);
147
163
  current().workflows.set(root, entry);
148
164
  return entry;
149
165
  }
@@ -170,6 +186,11 @@ export function completionOwnerOf(db, id) {
170
186
  return creatorOf(db, id);
171
187
  }
172
188
 
189
+ /** A card's explicitly named review profile, retained in its own history. */
190
+ export function reviewerOf(db, id) {
191
+ return json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind IN ('created','reviewer_set') ORDER BY id DESC LIMIT 1", id)?.payload, {})?.reviewer ?? null;
192
+ }
193
+
173
194
  export function latestReview(db, id) {
174
195
  const row = one(db, "SELECT id,started_at,metadata FROM task_runs WHERE task_id=? AND json_extract(metadata,'$.supercode.role')='reviewer' ORDER BY id DESC LIMIT 1", id);
175
196
  if (!row) return null;
@@ -363,7 +384,8 @@ export function runWorkingSeconds(row, at = now()) {
363
384
  return Math.max(0, end - Number(row.started_at ?? end) - Number(json(row.metadata,{})?.supercode?.stopped_seconds??0));
364
385
  }
365
386
 
366
- function timeOf(db, task) {
387
+ /** Elapsed role totals need no review candidate or workflow guard scope. */
388
+ export function timeOf(db, task) {
367
389
  const t = now();
368
390
  const out = { coding: 0, reviewing: 0, auditing: 0, triaging: 0 };
369
391
  const key = { implementer: 'coding', reviewer: 'reviewing', auditor: 'auditing', triager: 'triaging' };
@@ -394,6 +416,14 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
394
416
  const lastRun = once(() => one(db, 'SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY ended_at DESC, id DESC LIMIT 1', task.id));
395
417
  const lastCompleted = once(() => one(db, "SELECT * FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY ended_at DESC LIMIT 1", task.id));
396
418
  const asked = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
419
+ const entry = current().workflows.get(board.root) ?? loaded.get(board.root);
420
+ // Read the coding role, never the latest run of any role: after a review or a
421
+ // reassignment that run may belong to a different harness than the coder's.
422
+ const coding = once(() => one(db, "SELECT id,profile,metadata FROM task_runs WHERE task_id=? AND json_extract(metadata,'$.supercode.role')='implementer' ORDER BY id DESC LIMIT 1", task.id));
423
+ const codingSession = once(() => json(coding()?.metadata, {})?.supercode ?? {});
424
+ const codingProfile = once(() => coding()?.profile ?? asked().implementer ?? task.assignee);
425
+ const coderProfile = once(() => entry?.profiles?.[codingProfile()]);
426
+ const configuredCoder = once(() => coderProfile()?.worker);
397
427
  const changes = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
398
428
  const interruption = once(() => json(one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
399
429
  'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
@@ -415,6 +445,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
415
445
  status,
416
446
  blocked_by: blocks,
417
447
  paused: json(state.paused, null),
448
+ detached: json(state.detached, null),
418
449
  archived: Boolean(state.archived),
419
450
  every: state.every ?? null,
420
451
  next_run_at: state.next_run_at ?? null,
@@ -436,6 +467,15 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
436
467
  get counters() { return { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session().goal_turns || 0) }; },
437
468
  goal_mode: Boolean(task.goal_mode),
438
469
  get implementer() { return asked().implementer ?? task.assignee; },
470
+ get implementer_harness() { return codingSession().harness ?? configuredCoder()?.harness ?? null; },
471
+ get implementer_model() { return codingSession().model ?? (codingProfile() === task.assignee ? task.model_override : asked().implementer_model) ??
472
+ configuredCoder()?.model ?? coderProfile()?.residue?.config?.model?.default ?? null; },
473
+ get implementer_run() { return coding() ? { id: coding().id, profile: coding().profile, harness: codingSession().harness ?? null,
474
+ model: codingSession().model ?? null, machine: codingSession().machine ?? null } : null; },
475
+ get implementer_machine() { return Object.hasOwn(asked(), 'implementer_machine') ? asked().implementer_machine : task.machine ?? null; },
476
+ get implementer_model_override() { return Object.hasOwn(asked(), 'implementer_model_override') ? asked().implementer_model_override : task.model_override ?? null; },
477
+ get implementer_provider_override() { return Object.hasOwn(asked(), 'implementer_provider_override') ? asked().implementer_provider_override : task.provider_override ?? null; },
478
+ get reviewer() { return reviewerOf(db, task.id); },
439
479
  get last_reviewer() { return changes().reviewer ?? null; },
440
480
  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; },
441
481
  // the cards filed as its subtasks (`create --subtask-of`), in the order they were filed
@@ -485,13 +525,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
485
525
  Object.defineProperty(card, key, { get: read, enumerable: true, configurable: true });
486
526
  if (!lazy) read();
487
527
  }
488
- const derived = loaded.get(board.root)?.workflow?.derived ?? {};
489
- for (const [key, expr] of Object.entries(derived)) {
490
- // Preflight neither evaluates nor exposes a derived source value from a stale/missing observation.
491
- const value = deferSources && needsSources(expr, derived) ? undefined : evaluate(expr, { card, event, params, now: now() });
492
- Object.defineProperty(card, key, { value, writable: true, enumerable: true, configurable: true });
493
- }
494
- return {
528
+ const scope = {
495
529
  card,
496
530
  requester: actorOf(),
497
531
  event,
@@ -499,8 +533,15 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
499
533
  now: now(),
500
534
  board: board.label ?? board.slug,
501
535
  matches: (text, p) => (text === null || text === undefined ? false : pattern(p).test(String(text))),
536
+ lanes: entry ? lanesIn(entry) : {},
537
+ profile_harnesses: Object.fromEntries(Object.entries(entry?.profiles ?? {}).filter(([, profile]) => profile?.worker?.harness)
538
+ .map(([name, profile]) => [name, profile.worker.harness])),
502
539
  ...extra,
503
540
  };
541
+ // Derived facts see the same names as transitions, including the home's
542
+ // lanes. The pinned entry remains the basis if the home reloads mid-call.
543
+ for (const [key, expr] of Object.entries(entry?.workflow?.derived ?? {})) card[key] = deferSources && needsSources(expr, entry.workflow.derived) ? undefined : evaluate(expr, scope);
544
+ return scope;
504
545
  }
505
546
 
506
547
  // ------------------------------------------------------------------ events
@@ -528,13 +569,19 @@ function noticeRun(db, task) {
528
569
  : 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;
529
570
  }
530
571
 
531
- function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects) {
572
+ function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession = null) {
532
573
  const after = cardOf(db, task.id), to = statusOf(db, after);
574
+ // A verified adoption starts a new run before binding its session. Its notice
575
+ // belongs to that new run and verified identity, never the last ended session
576
+ // that noticeRun found before the transition.
577
+ const targetRun = noticeSession && after.current_run_id
578
+ ? one(db, 'SELECT id, metadata FROM task_runs WHERE id = ?', after.current_run_id) : beforeRun;
533
579
  const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
534
580
  const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
535
581
  const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
536
- if (!beforeRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === beforeRun.id)) return;
537
- const session = json(beforeRun.metadata, {})?.supercode;
582
+ if (!targetRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === targetRun.id)) return;
583
+ const session = noticeSession ?? json(targetRun.metadata, {})?.supercode;
584
+ if (detachedSession(db, session)) return;
538
585
  const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
539
586
  if (!address && !session?.launch_key && !session?.pane) return;
540
587
  // The session that made this change learns it from its own command's answer; a notice about
@@ -542,16 +589,19 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
542
589
  // confirmed is not told either: its next run resumes it with the card's state in its prompt.
543
590
  if (address && address === actorOf()) return;
544
591
  if (session?.close_confirmed_at) return;
545
- const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
546
- const body = render(entry.workflow.notify?.worker?.run_changed ?? '{{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 ?? ""}}', scope);
592
+ const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: targetRun.id } } });
593
+ const defaultNotice = noticeSession
594
+ ? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
595
+ : '{{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 ?? ""}}';
596
+ const body = numbered(cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
547
597
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
548
- const text = identifyNotice({ id: source, kind: name, run_id: beforeRun.id, created_at: now() }, body);
549
- const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
598
+ const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
599
+ 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')}` };
550
600
  const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
551
601
  effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
552
- const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', beforeRun.id)?.metadata, {}) ?? {};
602
+ const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', targetRun.id)?.metadata, {}) ?? {};
553
603
  current.supercode = { ...current.supercode, lifecycle_notice_pending: Number(inserted.lastInsertRowid) };
554
- run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(current), beforeRun.id);
604
+ run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(current), targetRun.id);
555
605
  }
556
606
 
557
607
  /**
@@ -644,7 +694,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
644
694
  return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
645
695
  }
646
696
 
647
- export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
697
+ export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, noticeSession = null } = {}) {
648
698
  const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
649
699
  if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
650
700
  const { task, from, taken, scope, beforeRun } = checked;
@@ -658,8 +708,10 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
658
708
  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;
659
709
  if ((scope.card.dispatch_hold || ended) && roles.some(role => ['manager','creator'].includes(role)) &&
660
710
  (['resume','reclaim','replace_session','reopen','reopen_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
661
- record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {})});
662
- queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects);
711
+ record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {}),
712
+ ...(ended && name === 'set' && payload.field === 'assignee' && (payload.value ?? null) !== (task.assignee ?? null)
713
+ ? { reassigned_from: task.assignee ?? null, reassigned_to: payload.value ?? null, abandon_waiting_input: true } : {})});
714
+ queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession);
663
715
  return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
664
716
  }
665
717
 
@@ -690,7 +742,7 @@ function cardOf(db, id) {
690
742
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
691
743
  if (!row) throw new Error(`no card ${id}`);
692
744
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
693
- return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
745
+ return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
694
746
  }
695
747
 
696
748
  // one transition: the old status's exit actions, the move, the transition's actions, the new status's entry actions
@@ -724,6 +776,7 @@ export function setStatus(db, workflow, id, status) {
724
776
  /** Take every `always` transition that holds, from wherever the card is, until none does. */
725
777
  export function settle(db, board, entry, id, effects = []) {
726
778
  const { workflow, params } = entry;
779
+ if (detachedCard(db, id)) return effects;
727
780
  settleRelations(db, board, entry, id, effects);
728
781
  for (let hops = 0; hops < 16; hops += 1) {
729
782
  const task = cardOf(db, id);
@@ -772,12 +825,14 @@ function dueAfter(db, workflow, task, status, scope) {
772
825
 
773
826
  /**
774
827
  * Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
775
- * set and its actions run (a goal card by the workflow's default, for one). Answers the status.
828
+ * set and its actions run (a goal card by the workflow's default, for one). Answers the status. A validator that fails
829
+ * refuses the create, as it refuses any other event (a home that keeps its root unassigned says so here).
776
830
  */
777
831
  export function startNew(db, board, entry, task, event) {
778
832
  const scope = scopeOf(db, board, task, { params: entry.params, event, extra: { lanes: lanesIn(entry) } });
779
833
  const t = entry.workflow.initial.find((x) => holds(x.guard ?? null, scope));
780
834
  if (!t?.target) throw new Error('the workflow says nowhere for a new card to start');
835
+ for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(render(v.message, scope));
781
836
  const status = String(templateValue(t.target, scope));
782
837
  if (!entry.workflow.statuses[status]) throw new Error(`the workflow starts a new card in ${status}, which it does not declare`);
783
838
  setStatus(db, entry.workflow, task.id, status);
@@ -813,7 +868,9 @@ const FORMAT = {
813
868
  blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, waiting_on: e.waiting_on ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
814
869
  block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
815
870
  dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
816
- review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null, implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
871
+ review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null,
872
+ implementer: before.assignee, implementer_machine: before.machine ?? null, implementer_model: before.implementer_model ?? null,
873
+ implementer_model_override: before.model_override ?? null, implementer_provider_override: before.provider_override ?? null, reviewer: after.assignee }),
817
874
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
818
875
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
819
876
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
@@ -922,9 +979,15 @@ const ACTIONS = {
922
979
  } else record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
923
980
  },
924
981
  assign(ctx, { field, value }) {
982
+ if (field === 'reviewer') {
983
+ if (value != null && (typeof value !== 'string' || !ctx.entry.profiles?.[value]?.worker?.harness)) throw new Error('A card reviewer names a runnable profile in this home.');
984
+ record(ctx.db, ctx.id, 'reviewer_set', { reviewer: value ?? null }, ctx.runId);
985
+ return;
986
+ }
925
987
  if (field === 'machine') {
926
988
  const task = one(ctx.db, 'SELECT t.current_run_id,t.workspace_kind,c.workspace_anchor,c.machine FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?', ctx.id);
927
989
  if (task.current_run_id) throw new Error('End the current run before moving its card.');
990
+ if ((value ?? null) === task.machine) return;
928
991
  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');
929
992
  if ((value ?? null) !== task.machine) run(ctx.db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', ctx.id);
930
993
  cardField(ctx, 'machine', value ?? null);
@@ -984,6 +1047,7 @@ const ACTIONS = {
984
1047
  run(ctx.db, 'UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', ctx.id);
985
1048
  },
986
1049
  claim(ctx, role, scope) {
1050
+ if (detachedCard(ctx.db, ctx.id)) throw new Error('a detached card must receive a verified adoption before starting another run');
987
1051
  const task = cardOf(ctx.db, ctx.id);
988
1052
  const t = now();
989
1053
  const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
@@ -1124,7 +1188,7 @@ const ACTIONS = {
1124
1188
  send_to_actor(ctx, text) {
1125
1189
  const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
1126
1190
  const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
1127
- effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, text);
1191
+ effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(cardNumber(ctx.db, ctx.id), text));
1128
1192
  run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
1129
1193
  ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
1130
1194
  },
@@ -1139,7 +1203,7 @@ export function retireSupersededRuns(db, id, runId, session) {
1139
1203
  OR (? IS NOT NULL AND json_extract(metadata, '$.supercode.pane') = ?))`,
1140
1204
  runId, session.machine ?? null, session.session_id ?? null, session.session_id ?? null, session.pane ?? null, session.pane ?? null)) {
1141
1205
  const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
1142
- if (!s || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
1206
+ 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;
1143
1207
  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}`);
1144
1208
  if (s.pane && s.pane === session.pane) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1145
1209
  run(db, "UPDATE task_runs SET status='superseded', outcome='superseded', ended_at=?, claim_lock=NULL, claim_expires=NULL, worker_pid=NULL, metadata=? WHERE id=?", now(), JSON.stringify(whole), old.id);
@@ -1159,6 +1223,7 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
1159
1223
  if ((runId != null && row.id !== runId) || (through != null && row.id > through)) continue;
1160
1224
  const whole = json(row.metadata, {}) ?? {};
1161
1225
  const session = whole.supercode;
1226
+ if (detachedSession(db, session)) continue;
1162
1227
  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;
1163
1228
  if (session.close_requested && !session.closed_at) continue;
1164
1229
  whole.supercode = { ...session, close_requested: session.close_requested ?? now() };