@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
package/board/doors.mjs CHANGED
@@ -45,33 +45,63 @@ async function opened(machine) {
45
45
  */
46
46
  export async function machineCall(machine, method, params = {}, { timeoutMs = 60_000 } = {}) {
47
47
  if (!method.startsWith('harness.v1.')) throw new Error('a machine door is a harness.v1 method');
48
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('a machine door timeout is a positive finite number of milliseconds');
48
49
  const started = performance.now();
50
+ const deadline = started + timeoutMs;
51
+ // The caller's bound covers admission too. Shared openings remain the owner's: a caller that stops waiting must
52
+ // not close another caller's channel/client. Once its admission wait expires, it never sends the method later.
53
+ const phase = async (name, work) => {
54
+ const at = performance.now();
55
+ const remaining = deadline - at;
56
+ const expired = () => Object.assign(new Error(`${method} exceeded ${timeoutMs} ms while waiting for ${name}${name === 'request' ? '; whether the method ran is unknown' : '; the method was not sent'}`), {
57
+ code: 'REQUEST_TIMEOUT', phase: name, requestNotSent: name !== 'request',
58
+ });
59
+ if (remaining <= 0) throw expired();
60
+ const controller = new AbortController();
61
+ let timer;
62
+ try {
63
+ const timeout = new Promise((_, reject) => {
64
+ timer = setTimeout(() => { const error = expired(); controller.abort(error); reject(error); }, remaining);
65
+ });
66
+ return await Promise.race([work({ timeoutMs: remaining, signal: controller.signal }), timeout]);
67
+ } finally {
68
+ clearTimeout(timer);
69
+ noteStep(`door ${method}: ${name}`, performance.now() - at);
70
+ }
71
+ };
49
72
  let door = null;
50
73
  try {
51
- door = await opened(machine);
74
+ door = await phase('machine channel', () => opened(machine));
52
75
  if (method.startsWith('harness.v1.sessions.')) {
53
76
  // The harness client starts its transport again itself once it ends (a daemon restart there); one that never
54
77
  // started is let go, so the next call starts another.
55
- if (!door.harness) {
56
- const starting = (async () => { const client = new SupercodeHarnessClient({ transport: channelHarness(door.channel, machine) }); await client.start?.(); return client; })();
57
- starting.catch(() => { if (door.harness === starting) door.harness = null; });
58
- door.harness = starting;
59
- }
60
- return await (await door.harness).request(method, params, { timeoutMs });
78
+ const client = await phase('harness admission', () => {
79
+ if (!door.harness) {
80
+ const starting = (async () => { const client = new SupercodeHarnessClient({ transport: channelHarness(door.channel, machine) }); await client.start?.(); return client; })();
81
+ starting.catch(() => { if (door.harness === starting) door.harness = null; });
82
+ door.harness = starting;
83
+ }
84
+ return door.harness;
85
+ });
86
+ return await phase('request', (options) => client.request(method, params, options));
61
87
  }
62
88
  // The node stream ends with the daemon behind it (another machine's daemon restarting, an upgrade) while the channel
63
89
  // to it stays open: it is used only while it is open, and opened again once it is not.
64
- if (!door.node) {
65
- const opening = door.channel.open('node').then((stream) => {
66
- stream.on('close', () => { if (door.node === opening) door.node = null; });
67
- return new StreamRpc(stream);
68
- });
69
- opening.catch(() => { if (door.node === opening) door.node = null; });
70
- door.node = opening;
71
- }
72
- return await (await door.node).call(method, params, { timeoutMs });
90
+ const rpc = await phase('node admission', () => {
91
+ if (!door.node) {
92
+ const opening = door.channel.open('node').then((stream) => {
93
+ stream.on('close', () => { if (door.node === opening) door.node = null; });
94
+ return new StreamRpc(stream);
95
+ });
96
+ opening.catch(() => { if (door.node === opening) door.node = null; });
97
+ door.node = opening;
98
+ }
99
+ return door.node;
100
+ });
101
+ return await phase('request', (options) => rpc.call(method, params, options));
73
102
  } catch (error) {
74
- if (machine && door && (error?.code === 'REQUEST_TIMEOUT' || error?.code === 504)) retireSilent(machine, door, started);
103
+ // An expired admission sent nothing and retains the shared opening. Retire only an owned remote request channel that stayed silent.
104
+ if (machine && door && error?.requestNotSent !== true && (error?.code === 'REQUEST_TIMEOUT' || error?.code === 504)) retireSilent(machine, door, started);
75
105
  throw error;
76
106
  } finally {
77
107
  noteStep(`door ${method}${machine ? ' (another machine)' : ''}`, performance.now() - started);
package/board/engine.mjs CHANGED
@@ -8,15 +8,16 @@
8
8
  // Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
9
9
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
10
10
  import { current } from './call.mjs';
11
+ import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
11
12
  import { executionOf } from './session-work.mjs';
12
13
  import { spawn, execFile } from 'node:child_process';
13
14
  import { existsSync, rmSync } from 'node:fs';
14
15
  import { join, resolve } from 'node:path';
15
16
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
16
17
  import { DatabaseSync } from 'node:sqlite';
17
- import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
18
- import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
19
- import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
18
+ import { view, actorOf, principalOf, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
19
+ import { assertSourceReceipt, completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
20
+ import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
20
21
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
21
22
  import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
22
23
 
@@ -37,11 +38,22 @@ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
37
38
  const all = (db, sql, ...args) => db.prepare(sql).all(...args);
38
39
  const run = (db, sql, ...args) => db.prepare(sql).run(...args);
39
40
 
41
+ // A card's number (#42): what the store gave it when it was made (store.mjs layOutNumbers). A person names a card by
42
+ // it wherever its id is taken (`42` or `#42`), and every view of a card shows it first, before the id.
43
+ /** Card `n`'s id on this board, or null when no card here has that number. */
44
+ export const cardByNumber = (db, n) => one(db, `SELECT t.id FROM ${CARD_NUMBERS} n JOIN tasks t ON t.id = n.task_id WHERE n.number = ?`, Number(n))?.id ?? null;
45
+ /** The number of card `id`, or null. */
46
+ export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
47
+ /** The numbers of cards `ids`: id → number. */
48
+ export const numbersOf = (db, ids) => new Map(ids.length ? all(db, `SELECT task_id, number FROM ${CARD_NUMBERS} WHERE task_id IN (SELECT value FROM json_each(?))`, JSON.stringify([...new Set(ids)])).map((r) => [r.task_id, r.number]) : []);
49
+ /** A card as a person reads it: its number first, then its id (`#42 t_4406c88a`). */
50
+ export const cardLabel = (number, id) => (number ? `#${number} ${id}` : String(id));
51
+
40
52
  export function getTask(db, id) {
41
53
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
42
54
  if (!row) return null;
43
55
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
44
- return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
56
+ return { ...row, number: numberOf(db, id), workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
45
57
  tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), sponsor: ours?.sponsor ?? null, sponsor_evidence: json(ours?.sponsor_evidence, null), requested_by_unverified: json(ours?.requested_by_unverified, null) };
46
58
  }
47
59
 
@@ -122,10 +134,11 @@ export function setRunSession(db, runId, session) {
122
134
 
123
135
  /** The card's latest session the dispatcher recorded, newest run first; null when it never had one. */
124
136
  export function lastSession(db, taskId, slot = 'worker') {
137
+ if (detachedCard(db, taskId)) return null;
125
138
  // a session slot's own last session (runs recorded before slots existed are the worker's)
126
139
  for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', taskId)) {
127
140
  const s = json(row.metadata, {})?.supercode;
128
- if (s?.session_id && (s.slot ?? 'worker') === slot) return s;
141
+ if (s?.session_id && (s.slot ?? 'worker') === slot) return detachedSession(db, s) ? null : s;
129
142
  }
130
143
  return null;
131
144
  }
@@ -241,6 +254,9 @@ export function createCard(board, spec) {
241
254
  if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
242
255
  const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
243
256
  const entry = workflowFor(board.root);
257
+ if (spec.reviewer != null && (typeof spec.reviewer !== 'string' || !spec.reviewer.trim() || !entry.profiles?.[spec.reviewer]?.worker?.harness)) {
258
+ throw new Error('A card reviewer names a runnable profile in this home.');
259
+ }
244
260
  if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
245
261
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
246
262
  let id = newTaskId();
@@ -281,7 +297,8 @@ export function createCard(board, spec) {
281
297
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
282
298
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
283
299
  every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
284
- event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null, status, parents, tags, ...(spec.sponsor ? { sponsor: spec.sponsor } : {}), ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
300
+ event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null,
301
+ ...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.sponsor ? { sponsor: spec.sponsor } : {}), ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
285
302
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
286
303
  if (parents.length) inheritSubscriptions(db, id, parents);
287
304
  if (arc) {
@@ -290,7 +307,7 @@ export function createCard(board, spec) {
290
307
  event(db, arc.id, 'subtask_added', { subtask: id, title });
291
308
  }
292
309
  if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
293
- else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
310
+ else if (initial === 'running') claimIn(db, id, { ttl: claimTtl(), workflow: entry.workflow });
294
311
  // The creation declares its role even when dispatch is held. The existing durable effect queue
295
312
  // keeps public-door I/O outside this transaction and retains an offline declaration for retry.
296
313
  let declaration;
@@ -333,12 +350,13 @@ function wouldCycle(db, parent, child) {
333
350
  * from a native binary released before its `set` (the orchestrator and the native install separately) still has a verb
334
351
  * per property: it is answered so until that native is gone from the fleet, then this fallback is deleted (t_530ce43e).
335
352
  */
336
- export const SETTABLE = ['assignee', 'machine', 'model', 'every'];
353
+ export const SETTABLE = ['assignee', 'machine', 'model', 'every', 'reviewer'];
337
354
  export function set(board, id, field, value, { provider = null, roles } = {}) {
338
355
  if (!SETTABLE.includes(field)) throw new Error(`set takes one of ${SETTABLE.join(', ')}, not ${field}`);
339
356
  if (provider && (field !== 'model' || !value)) throw new Error('a provider goes with a model');
340
357
  value = value || null;
341
358
  if (workflowFor(board.root).workflow.verbs?.set) return act(board, id, 'set', { roles, payload: { field, value, provider } });
359
+ if (field === 'reviewer') throw new Error('This older workflow declares no reviewer setter; update its workflow before setting a card reviewer.');
342
360
  const verb = { assignee: ['assign', { assignee: value }], machine: ['move', { machine: value }], model: ['set_model', { model: value, provider }], every: ['set_every', { every: value }] }[field];
343
361
  return act(board, id, verb[0], { roles, payload: verb[1] });
344
362
  }
@@ -347,11 +365,12 @@ export function set(board, id, field, value, { provider = null, roles } = {}) {
347
365
 
348
366
  /** Claim a ready card: running, a claim held until `ttl` from now, a run opened. Answers the run id or null. */
349
367
  export function claim(board, id, { ttl, lock = claimer(), expectStatus = 'ready' } = {}) {
350
- return tx(board, (db) => claimIn(db, id, { ttl, lock, expectStatus }));
368
+ const { workflow } = workflowFor(board.root);
369
+ return tx(board, (db) => claimIn(db, id, { ttl, lock, expectStatus, workflow }));
351
370
  }
352
371
 
353
- /** `claim` inside the caller's own transaction (a dispatcher's guard and claim see one state). */
354
- export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null } = {}) {
372
+ /** Manual `claim` inside the caller's transaction; the declared stage and run commit together. */
373
+ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null, workflow } = {}) {
355
374
  if (getTask(db, id)?.subtask_of) throw new Error('a subtask is worked by its arc and has no run of its own');
356
375
  // a triage card is claimed to be specified, which does not wait on its parents
357
376
  if (expectStatus !== 'triage' && !parentsDone(db, id)) {
@@ -360,19 +379,22 @@ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStat
360
379
  return null;
361
380
  }
362
381
  const task = must(db, id);
363
- if (expectStatus && task.status !== expectStatus) return null;
382
+ const sourceStatus = statusOf(db, task);
383
+ if (expectStatus && sourceStatus !== expectStatus) return null;
364
384
  if (task.claim_lock) return null;
385
+ if (!workflow?.statuses?.running) throw new Error('manual claim needs a running status declared by this home\'s workflow');
365
386
  const t = now();
366
387
  if (task.current_run_id) {
367
388
  run(db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
368
389
  }
369
390
  const expires = t + ttl;
370
- run(db, 'UPDATE tasks SET status = ?, claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', 'running', lock, expires, t, id);
391
+ setStatus(db, workflow, id, 'running');
392
+ run(db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, id);
371
393
  const r = run(db, "INSERT INTO task_runs (task_id, profile, step_key, status, claim_lock, claim_expires, max_runtime_seconds, started_at) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)",
372
394
  id, task.assignee, task.current_step_key, lock, expires, task.max_runtime_seconds, t);
373
395
  const runId = Number(r.lastInsertRowid);
374
396
  run(db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', runId, id);
375
- event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: ['review', 'triage'].includes(task.status) ? task.status : 'ready' }, runId);
397
+ event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: ['review', 'triage'].includes(sourceStatus) ? sourceStatus : 'ready' }, runId);
376
398
  return runId;
377
399
  }
378
400
 
@@ -428,6 +450,7 @@ export function setCaller(fn) { current().roles = fn; }
428
450
  const callerRoles = (db, cardId) => current().roles(db, cardId);
429
451
 
430
452
  export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
453
+ assertSourceReceipt(payload);
431
454
  const entry = await loadWorkflow(board.root);
432
455
  if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
433
456
  refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
@@ -455,6 +478,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
455
478
  }
456
479
 
457
480
  export async function requestStateMany(board, ids, payload = {}) {
481
+ assertSourceReceipt(payload);
458
482
  const targets = [...new Set(ids.map(String))];
459
483
  if (!targets.length) throw new Error('A state request names at least one card.');
460
484
  const entry = await loadWorkflow(board.root);
@@ -485,6 +509,7 @@ export async function requestStateMany(board, ids, payload = {}) {
485
509
 
486
510
  /** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
487
511
  export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
512
+ assertSourceReceipt(payload);
488
513
  const entry = await loadWorkflow(board.root);
489
514
  await refreshCardInputs(board, id, { commits: true });
490
515
  await observeCompletionEvidence(board, id, entry, payload);
@@ -552,7 +577,17 @@ export async function runEffects(board, effects = [], { pending: due = false, el
552
577
  // the claim: taken only while the row is due and not failed; another pass holding it answers no change
553
578
  if (e.durableId && !tx(board, (db) => db.prepare('UPDATE supercode_effects SET next_at = ? WHERE id = ? AND failed_at IS NULL AND next_at <= ?').run(now() + EFFECT_CLAIM, e.durableId, now()).changes)) { did.claimed_elsewhere.push(e.durableId); continue; }
554
579
  let done, error = null;
555
- try { done = await runEffect(board, e); } catch (thrown) { done = false; error = String(thrown?.message ?? thrown).slice(0, 500); }
580
+ try {
581
+ const effectSession = e.runId ? json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode : null;
582
+ done = e.id && e.kind !== 'board_message'
583
+ ? await sessionOperation(board, { taskId: e.id, address: e.address ?? sessionAddress(effectSession), kind: `board effect ${e.kind}` }, () => runEffect(board, e))
584
+ : await runEffect(board, e);
585
+ } catch (thrown) {
586
+ // Pending work no longer owns the person's session or its workspace.
587
+ done = Boolean(thrown?.sessionDetached);
588
+ error = done ? null : String(thrown?.message ?? thrown).slice(0, 500);
589
+ if (done && e.id) tx(board, db => event(db, e.id, 'effect_passed', { effect: e.durableId ?? null, kind: e.kind, reason: 'card detached to a person' }, e.runId ?? null));
590
+ }
556
591
  if (!e.durableId) continue;
557
592
  if (done) { tx(board, (db) => db.prepare('DELETE FROM supercode_effects WHERE id = ?').run(e.durableId)); did.done.push(`${e.durableId}:${e.kind}`); continue; }
558
593
  did.kept.push(e.durableId);
@@ -602,7 +637,7 @@ function stopWorker(board, runId) {
602
637
  if (!runId) return;
603
638
  const row = view(board, (db) => one(db, 'SELECT metadata, claim_lock FROM task_runs WHERE id = ?', runId));
604
639
  const worker = json(row?.metadata, {})?.supercode;
605
- if (worker?.surface === 'headless' && worker.pid) terminate(Number(worker.pid));
640
+ if (worker?.surface === 'headless' && worker.pid && !detachedSession(board.db, worker)) terminate(Number(worker.pid));
606
641
  }
607
642
 
608
643
  /**
@@ -652,14 +687,20 @@ function invalidateDescendants(db, board, id) {
652
687
 
653
688
  // each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
654
689
 
655
- export const complete = async (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = {}) =>
656
- await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
690
+ export const complete = async (board, id, options = {}) => {
691
+ assertSourceReceipt(options);
692
+ const { result = null, summary = null, metadata = null, roles, runId = null, commit = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = options;
693
+ return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
694
+ };
657
695
  // a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
658
696
  export const block = async (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
659
697
  await act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
660
698
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
661
- export const requestReview = async (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null, branch = null, pr = null, pr_head = null } = {}) =>
662
- await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
699
+ export const requestReview = async (board, id, options = {}) => {
700
+ assertSourceReceipt(options);
701
+ const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null } = options;
702
+ return await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head } });
703
+ };
663
704
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
664
705
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
665
706
  export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
@@ -752,11 +793,35 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
752
793
  * starts, completion and blocks) or `worker` (told of updates to the card it works).
753
794
  */
754
795
  export function subscribeIn(db, id, address, role = 'creator') {
796
+ if (personCustody(db, address)) return;
755
797
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
756
798
  run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
757
799
  VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
758
800
  }
759
801
 
802
+ /** Reconcile only the current worker's audience, inside the binding transaction. */
803
+ export function replaceWorkerSubscriptions(db, id, address) {
804
+ const active = currentRun(db, id), session = active?.metadata?.supercode;
805
+ const bound = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
806
+ if (!address || bound !== address || active.ended_at) return false;
807
+ const replaces = (session.slot ?? 'worker') === 'worker';
808
+ const family = all(db, `WITH RECURSIVE family(task_id) AS (
809
+ SELECT ? UNION SELECT c.task_id FROM supercode_cards c JOIN family f ON c.subtask_of = f.task_id
810
+ ) SELECT task_id FROM family`, id);
811
+ const removed = [];
812
+ for (const { task_id: card } of family) {
813
+ for (const old of replaces ? all(db, "SELECT chat_id,thread_id FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND json_extract(delivery_metadata,'$.role')='worker' AND chat_id!=?", card, address) : []) {
814
+ run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=? AND json_extract(delivery_metadata,'$.role')='worker'", card, old.chat_id, old.thread_id);
815
+ removed.push({ card, address: old.chat_id });
816
+ }
817
+ // INSERT OR IGNORE preserves an independent creator/manager subscription
818
+ // and the current worker's existing cursor on repeated reconciliation.
819
+ subscribeIn(db, card, address, 'worker');
820
+ }
821
+ if (removed.length) event(db, id, 'notice_subscriptions_replaced', { address, role: 'worker', superseded: removed, reason: 'the current worker binding replaced its audience' }, active.id);
822
+ return true;
823
+ }
824
+
760
825
  export function subscribe(board, id, address, role = 'creator') {
761
826
  return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role); });
762
827
  }
@@ -767,9 +832,9 @@ export function unsubscribe(board, id, address) {
767
832
 
768
833
  function dependencyRecipient(db, id) {
769
834
  const owner = must(db, id);
770
- if (owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
835
+ if (owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
771
836
  const session = currentRun(db, id)?.metadata?.supercode ?? lastSession(db, id);
772
- return session?.forgotten || session?.close_confirmed_at ? null : session?.address ?? null;
837
+ return session?.forgotten || session?.close_confirmed_at || detachedSession(db, session) ? null : session?.address ?? null;
773
838
  }
774
839
 
775
840
  export function subscribeDependency(board, ownerId, sourceId, { remove = false } = {}) {
@@ -800,7 +865,7 @@ export function reconcileDependencySubscriptions(db) {
800
865
  const address = owner ? dependencyRecipient(db, owner.id) : null;
801
866
  const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
802
867
  const identity = [row.task_id, row.chat_id, row.thread_id];
803
- if (!owner || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
868
+ if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
804
869
  else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=? ${where}`, address, JSON.stringify({ ...metadata, waiting_recipient: false, failures: 0 }), ...identity);
805
870
  else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
806
871
  }
@@ -826,19 +891,19 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
826
891
  // ------------------------------------------------------------------ reading
827
892
 
828
893
  export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
829
- const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
894
+ const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
830
895
  WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
831
896
  ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
832
897
  return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }));
833
898
  }
834
899
 
835
900
  export function showCard(db, id) {
836
- const task = must(db, id);
901
+ const task = { ...must(db, id), reviewer: reviewerOf(db, id) };
837
902
  return {
838
903
  task,
839
904
  parents: parentsOf(db, id),
840
905
  children: childrenOf(db, id),
841
- subtasks: all(db, 'SELECT t.id, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at', id),
906
+ subtasks: all(db, `SELECT t.id, n.number, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id),
842
907
  comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
843
908
  runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
844
909
  events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
@@ -1011,7 +1076,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
1011
1076
  const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
1012
1077
  if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
1013
1078
  }
1014
- if (task.status === 'ready' && task.assignee) {
1079
+ if (!task.detached && task.status === 'ready' && task.assignee) {
1015
1080
  const since = [...events].reverse().find((e) => ['created', 'promoted', 'unblocked', 'reclaimed', 'session_lost', 'promoted_manual'].includes(e.kind))?.created_at ?? task.created_at;
1016
1081
  const age = t - since;
1017
1082
  if (age >= strandedSeconds) {
package/board/files.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import { fenced } from './fence.mjs';
13
13
  import { markRemovedHeads } from './published.mjs';
14
+ import { CARD_NUMBERS } from './store.mjs';
14
15
  import { createHash } from 'node:crypto';
15
16
  import { join } from 'node:path';
16
17
  import { DatabaseSync } from 'node:sqlite';
@@ -98,6 +99,9 @@ class FilesBoard {
98
99
  const state = this.stateText === null ? null : JSON.parse(this.stateText);
99
100
  for (const sql of state?.schema ?? []) mem.exec(sql);
100
101
  this.init(mem, { fresh: !state?.schema?.length });
102
+ // the schema as the directory keeps it: a layout this load added (a table, a column, the card numbers) is written to
103
+ // every card's file by the next write (persist)
104
+ this.keptSchema = state?.schema ?? [];
101
105
  const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
102
106
  const insert = (table, rows) => {
103
107
  const have = columns(table);
@@ -108,17 +112,26 @@ class FilesBoard {
108
112
  };
109
113
  const previous = this.texts;
110
114
  this.texts = new Map();
115
+ const cards = [];
111
116
  for (const name of readdirSync(this.dir)) {
112
117
  if (!name.endsWith('.json') || name.startsWith('_')) continue;
113
118
  const text = readFileSync(join(this.dir, name), 'utf8');
114
119
  const card = JSON.parse(text);
115
120
  this.texts.set(card.id, text);
116
- for (const [table, rows] of Object.entries(card.tables ?? {})) insert(table, rows);
121
+ cards.push(card);
117
122
  }
118
- for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
123
+ // The id counters first: a number handed out before stays handed out.
119
124
  for (const { name, seq } of state?.sequence ?? []) {
120
125
  if (mem.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name).changes === 0) mem.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
121
126
  }
127
+ // Inserting a card's row numbers it when it has no number yet (store.mjs layOutNumbers): every number a card file
128
+ // holds loads first, so each card keeps its own and a new one is past them all, and then the cards load in the
129
+ // order they were made.
130
+ for (const card of cards) if (card.tables?.[CARD_NUMBERS]) insert(CARD_NUMBERS, card.tables[CARD_NUMBERS]);
131
+ const madeAt = (card) => Number(card.tables?.tasks?.[0]?.created_at ?? 0);
132
+ cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
133
+ for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
134
+ for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
122
135
  // Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
123
136
  // those marks: its next write compares each card with its published head once (a named limit: each projection opened
124
137
  // compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
@@ -172,7 +185,8 @@ class FilesBoard {
172
185
  /** Write the files of the cards the transaction touched, then `_board.json`; writes compare the content hash. */
173
186
  persist(schemaBefore) {
174
187
  const schema = this.schema();
175
- const reshaped = schema.join('\n') !== schemaBefore.join('\n');
188
+ // reshaped by this transaction, or by the load it runs on (a layout the directory does not keep yet)
189
+ const reshaped = schema.join('\n') !== schemaBefore.join('\n') || schema.join('\n') !== (this.keptSchema ?? []).join('\n');
176
190
  const touched = new Set(this.mem.prepare('SELECT DISTINCT id FROM _touched').all().map((r) => r.id).filter((id) => id !== null));
177
191
  if (reshaped) this.watch(); // a table made in the transaction gets its triggers; every card is written again
178
192
  const keys = this.keys;