@volter/supercode-orchestrator 0.5.205 → 0.5.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +138 -4
  2. package/activation.mjs +93 -2
  3. package/adapters/rh2.mjs +7 -2
  4. package/agents.mjs +24 -2
  5. package/bin/agent-channel.mjs +41 -22
  6. package/bin/connection-runner.mjs +141 -0
  7. package/bin/orchestrator.mjs +11 -2
  8. package/bin/runtime.mjs +16 -9
  9. package/board/args.mjs +3 -2
  10. package/board/attachment-read.mjs +77 -0
  11. package/board/call.mjs +4 -2
  12. package/board/capacity.mjs +46 -9
  13. package/board/ci.mjs +157 -53
  14. package/board/cli.mjs +225 -87
  15. package/board/client.mjs +18 -2
  16. package/board/custody.mjs +109 -0
  17. package/board/dispatch.mjs +297 -75
  18. package/board/doors.mjs +47 -17
  19. package/board/engine.mjs +78 -24
  20. package/board/fleet-listeners.mjs +268 -0
  21. package/board/fleet-observer.mjs +79 -0
  22. package/board/fleet-targets.mjs +82 -0
  23. package/board/incidents.mjs +7 -0
  24. package/board/listing.mjs +5 -0
  25. package/board/machine-fleet.mjs +7 -0
  26. package/board/notice-receipts.mjs +67 -0
  27. package/board/owner-door-worker.mjs +61 -0
  28. package/board/owner-door.mjs +46 -0
  29. package/board/owner-health.mjs +8 -2
  30. package/board/published.mjs +2 -2
  31. package/board/record-view.mjs +2 -0
  32. package/board/store.mjs +55 -3
  33. package/board/tools.mjs +11 -4
  34. package/board/workflow.mjs +91 -28
  35. package/board/ztrack.mjs +87 -21
  36. package/board-input.d.ts +2 -0
  37. package/board-input.mjs +16 -0
  38. package/connection.mjs +153 -0
  39. package/index.d.ts +7 -5
  40. package/index.mjs +1 -1
  41. package/installed-apps.mjs +31 -20
  42. package/loop.mjs +91 -36
  43. package/mcp/bridge.mjs +12 -0
  44. package/mcp/hermes-kanban-tools.json +22 -14
  45. package/mcp/mount.mjs +10 -9
  46. package/ontology.d.ts +37 -0
  47. package/operator.mjs +72 -4
  48. package/orchestration.mjs +7 -2
  49. package/package.json +9 -5
  50. package/reducer.mjs +74 -5
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,6 +8,7 @@
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';
@@ -15,8 +16,8 @@ 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
18
  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';
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
 
@@ -41,7 +42,7 @@ export function getTask(db, id) {
41
42
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
42
43
  if (!row) return null;
43
44
  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,
45
+ return { ...row, 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
46
  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
47
  }
47
48
 
@@ -122,10 +123,11 @@ export function setRunSession(db, runId, session) {
122
123
 
123
124
  /** The card's latest session the dispatcher recorded, newest run first; null when it never had one. */
124
125
  export function lastSession(db, taskId, slot = 'worker') {
126
+ if (detachedCard(db, taskId)) return null;
125
127
  // a session slot's own last session (runs recorded before slots existed are the worker's)
126
128
  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
129
  const s = json(row.metadata, {})?.supercode;
128
- if (s?.session_id && (s.slot ?? 'worker') === slot) return s;
130
+ if (s?.session_id && (s.slot ?? 'worker') === slot) return detachedSession(db, s) ? null : s;
129
131
  }
130
132
  return null;
131
133
  }
@@ -241,6 +243,9 @@ export function createCard(board, spec) {
241
243
  if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
242
244
  const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
243
245
  const entry = workflowFor(board.root);
246
+ if (spec.reviewer != null && (typeof spec.reviewer !== 'string' || !spec.reviewer.trim() || !entry.profiles?.[spec.reviewer]?.worker?.harness)) {
247
+ throw new Error('A card reviewer names a runnable profile in this home.');
248
+ }
244
249
  if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
245
250
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
246
251
  let id = newTaskId();
@@ -281,7 +286,8 @@ export function createCard(board, spec) {
281
286
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
282
287
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
283
288
  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 } : {}) });
289
+ 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,
290
+ ...(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
291
  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
292
  if (parents.length) inheritSubscriptions(db, id, parents);
287
293
  if (arc) {
@@ -290,7 +296,7 @@ export function createCard(board, spec) {
290
296
  event(db, arc.id, 'subtask_added', { subtask: id, title });
291
297
  }
292
298
  if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
293
- else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
299
+ else if (initial === 'running') claimIn(db, id, { ttl: claimTtl(), workflow: entry.workflow });
294
300
  // The creation declares its role even when dispatch is held. The existing durable effect queue
295
301
  // keeps public-door I/O outside this transaction and retains an offline declaration for retry.
296
302
  let declaration;
@@ -333,12 +339,13 @@ function wouldCycle(db, parent, child) {
333
339
  * from a native binary released before its `set` (the orchestrator and the native install separately) still has a verb
334
340
  * per property: it is answered so until that native is gone from the fleet, then this fallback is deleted (t_530ce43e).
335
341
  */
336
- export const SETTABLE = ['assignee', 'machine', 'model', 'every'];
342
+ export const SETTABLE = ['assignee', 'machine', 'model', 'every', 'reviewer'];
337
343
  export function set(board, id, field, value, { provider = null, roles } = {}) {
338
344
  if (!SETTABLE.includes(field)) throw new Error(`set takes one of ${SETTABLE.join(', ')}, not ${field}`);
339
345
  if (provider && (field !== 'model' || !value)) throw new Error('a provider goes with a model');
340
346
  value = value || null;
341
347
  if (workflowFor(board.root).workflow.verbs?.set) return act(board, id, 'set', { roles, payload: { field, value, provider } });
348
+ if (field === 'reviewer') throw new Error('This older workflow declares no reviewer setter; update its workflow before setting a card reviewer.');
342
349
  const verb = { assignee: ['assign', { assignee: value }], machine: ['move', { machine: value }], model: ['set_model', { model: value, provider }], every: ['set_every', { every: value }] }[field];
343
350
  return act(board, id, verb[0], { roles, payload: verb[1] });
344
351
  }
@@ -347,11 +354,12 @@ export function set(board, id, field, value, { provider = null, roles } = {}) {
347
354
 
348
355
  /** Claim a ready card: running, a claim held until `ttl` from now, a run opened. Answers the run id or null. */
349
356
  export function claim(board, id, { ttl, lock = claimer(), expectStatus = 'ready' } = {}) {
350
- return tx(board, (db) => claimIn(db, id, { ttl, lock, expectStatus }));
357
+ const { workflow } = workflowFor(board.root);
358
+ return tx(board, (db) => claimIn(db, id, { ttl, lock, expectStatus, workflow }));
351
359
  }
352
360
 
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 } = {}) {
361
+ /** Manual `claim` inside the caller's transaction; the declared stage and run commit together. */
362
+ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null, workflow } = {}) {
355
363
  if (getTask(db, id)?.subtask_of) throw new Error('a subtask is worked by its arc and has no run of its own');
356
364
  // a triage card is claimed to be specified, which does not wait on its parents
357
365
  if (expectStatus !== 'triage' && !parentsDone(db, id)) {
@@ -360,19 +368,22 @@ export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStat
360
368
  return null;
361
369
  }
362
370
  const task = must(db, id);
363
- if (expectStatus && task.status !== expectStatus) return null;
371
+ const sourceStatus = statusOf(db, task);
372
+ if (expectStatus && sourceStatus !== expectStatus) return null;
364
373
  if (task.claim_lock) return null;
374
+ if (!workflow?.statuses?.running) throw new Error('manual claim needs a running status declared by this home\'s workflow');
365
375
  const t = now();
366
376
  if (task.current_run_id) {
367
377
  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
378
  }
369
379
  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);
380
+ setStatus(db, workflow, id, 'running');
381
+ run(db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, id);
371
382
  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
383
  id, task.assignee, task.current_step_key, lock, expires, task.max_runtime_seconds, t);
373
384
  const runId = Number(r.lastInsertRowid);
374
385
  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);
386
+ event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: ['review', 'triage'].includes(sourceStatus) ? sourceStatus : 'ready' }, runId);
376
387
  return runId;
377
388
  }
378
389
 
@@ -428,6 +439,7 @@ export function setCaller(fn) { current().roles = fn; }
428
439
  const callerRoles = (db, cardId) => current().roles(db, cardId);
429
440
 
430
441
  export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
442
+ assertSourceReceipt(payload);
431
443
  const entry = await loadWorkflow(board.root);
432
444
  if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
433
445
  refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
@@ -455,6 +467,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
455
467
  }
456
468
 
457
469
  export async function requestStateMany(board, ids, payload = {}) {
470
+ assertSourceReceipt(payload);
458
471
  const targets = [...new Set(ids.map(String))];
459
472
  if (!targets.length) throw new Error('A state request names at least one card.');
460
473
  const entry = await loadWorkflow(board.root);
@@ -485,6 +498,7 @@ export async function requestStateMany(board, ids, payload = {}) {
485
498
 
486
499
  /** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
487
500
  export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
501
+ assertSourceReceipt(payload);
488
502
  const entry = await loadWorkflow(board.root);
489
503
  await refreshCardInputs(board, id, { commits: true });
490
504
  await observeCompletionEvidence(board, id, entry, payload);
@@ -552,7 +566,17 @@ export async function runEffects(board, effects = [], { pending: due = false, el
552
566
  // the claim: taken only while the row is due and not failed; another pass holding it answers no change
553
567
  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
568
  let done, error = null;
555
- try { done = await runEffect(board, e); } catch (thrown) { done = false; error = String(thrown?.message ?? thrown).slice(0, 500); }
569
+ try {
570
+ const effectSession = e.runId ? json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode : null;
571
+ done = e.id && e.kind !== 'board_message'
572
+ ? await sessionOperation(board, { taskId: e.id, address: e.address ?? sessionAddress(effectSession), kind: `board effect ${e.kind}` }, () => runEffect(board, e))
573
+ : await runEffect(board, e);
574
+ } catch (thrown) {
575
+ // Pending work no longer owns the person's session or its workspace.
576
+ done = Boolean(thrown?.sessionDetached);
577
+ error = done ? null : String(thrown?.message ?? thrown).slice(0, 500);
578
+ 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));
579
+ }
556
580
  if (!e.durableId) continue;
557
581
  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
582
  did.kept.push(e.durableId);
@@ -602,7 +626,7 @@ function stopWorker(board, runId) {
602
626
  if (!runId) return;
603
627
  const row = view(board, (db) => one(db, 'SELECT metadata, claim_lock FROM task_runs WHERE id = ?', runId));
604
628
  const worker = json(row?.metadata, {})?.supercode;
605
- if (worker?.surface === 'headless' && worker.pid) terminate(Number(worker.pid));
629
+ if (worker?.surface === 'headless' && worker.pid && !detachedSession(board.db, worker)) terminate(Number(worker.pid));
606
630
  }
607
631
 
608
632
  /**
@@ -652,14 +676,20 @@ function invalidateDescendants(db, board, id) {
652
676
 
653
677
  // each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
654
678
 
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 } });
679
+ export const complete = async (board, id, options = {}) => {
680
+ assertSourceReceipt(options);
681
+ const { result = null, summary = null, metadata = null, roles, runId = null, commit = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = options;
682
+ return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
683
+ };
657
684
  // 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
685
  export const block = async (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
659
686
  await act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
660
687
  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 } });
688
+ export const requestReview = async (board, id, options = {}) => {
689
+ assertSourceReceipt(options);
690
+ const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null } = options;
691
+ return await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head } });
692
+ };
663
693
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
664
694
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
665
695
  export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
@@ -752,11 +782,35 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
752
782
  * starts, completion and blocks) or `worker` (told of updates to the card it works).
753
783
  */
754
784
  export function subscribeIn(db, id, address, role = 'creator') {
785
+ if (personCustody(db, address)) return;
755
786
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
756
787
  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
788
  VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
758
789
  }
759
790
 
791
+ /** Reconcile only the current worker's audience, inside the binding transaction. */
792
+ export function replaceWorkerSubscriptions(db, id, address) {
793
+ const active = currentRun(db, id), session = active?.metadata?.supercode;
794
+ const bound = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
795
+ if (!address || bound !== address || active.ended_at) return false;
796
+ const replaces = (session.slot ?? 'worker') === 'worker';
797
+ const family = all(db, `WITH RECURSIVE family(task_id) AS (
798
+ SELECT ? UNION SELECT c.task_id FROM supercode_cards c JOIN family f ON c.subtask_of = f.task_id
799
+ ) SELECT task_id FROM family`, id);
800
+ const removed = [];
801
+ for (const { task_id: card } of family) {
802
+ 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) : []) {
803
+ 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);
804
+ removed.push({ card, address: old.chat_id });
805
+ }
806
+ // INSERT OR IGNORE preserves an independent creator/manager subscription
807
+ // and the current worker's existing cursor on repeated reconciliation.
808
+ subscribeIn(db, card, address, 'worker');
809
+ }
810
+ if (removed.length) event(db, id, 'notice_subscriptions_replaced', { address, role: 'worker', superseded: removed, reason: 'the current worker binding replaced its audience' }, active.id);
811
+ return true;
812
+ }
813
+
760
814
  export function subscribe(board, id, address, role = 'creator') {
761
815
  return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role); });
762
816
  }
@@ -767,9 +821,9 @@ export function unsubscribe(board, id, address) {
767
821
 
768
822
  function dependencyRecipient(db, id) {
769
823
  const owner = must(db, id);
770
- if (owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
824
+ if (owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
771
825
  const session = currentRun(db, id)?.metadata?.supercode ?? lastSession(db, id);
772
- return session?.forgotten || session?.close_confirmed_at ? null : session?.address ?? null;
826
+ return session?.forgotten || session?.close_confirmed_at || detachedSession(db, session) ? null : session?.address ?? null;
773
827
  }
774
828
 
775
829
  export function subscribeDependency(board, ownerId, sourceId, { remove = false } = {}) {
@@ -800,7 +854,7 @@ export function reconcileDependencySubscriptions(db) {
800
854
  const address = owner ? dependencyRecipient(db, owner.id) : null;
801
855
  const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
802
856
  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);
857
+ if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
804
858
  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
859
  else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
806
860
  }
@@ -833,7 +887,7 @@ export function listCards(db, { status = null, assignee = null, machine = null,
833
887
  }
834
888
 
835
889
  export function showCard(db, id) {
836
- const task = must(db, id);
890
+ const task = { ...must(db, id), reviewer: reviewerOf(db, id) };
837
891
  return {
838
892
  task,
839
893
  parents: parentsOf(db, id),
@@ -1011,7 +1065,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
1011
1065
  const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
1012
1066
  if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
1013
1067
  }
1014
- if (task.status === 'ready' && task.assignee) {
1068
+ if (!task.detached && task.status === 'ready' && task.assignee) {
1015
1069
  const since = [...events].reverse().find((e) => ['created', 'promoted', 'unblocked', 'reclaimed', 'session_lost', 'promoted_manual'].includes(e.kind))?.created_at ?? task.created_at;
1016
1070
  const age = t - since;
1017
1071
  if (age >= strandedSeconds) {
@@ -0,0 +1,268 @@
1
+ // Fleet facts are mail obligations, independent of card transitions and dispatch ticks. Intent comes only from the
2
+ // canonical workflow IR. The sender supplies observations from its admitted sources; this router grants nothing.
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { chmodSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+
8
+ const KINDS = new Set(['session_started', 'session_resumed', 'session_ended', 'duplicate_session_process', 'card_waiting_on_user', 'machine_health_crit']);
9
+ const ADDRESS = /^sc:[^:\s]+:[^:\s]+:[^:\s]+$/;
10
+ const MACHINE = /^[a-z0-9_-]+$/;
11
+ const MAX_PENDING = 4096, MAX_PENDING_BYTES = 16 * 1024 * 1024, MAX_EVENT_BYTES = 64 * 1024;
12
+ const digest = value => createHash('sha256').update(value).digest('hex');
13
+ const refused = message => Object.assign(new Error(message), { code: 'fleet_listener_refused' });
14
+
15
+ /** A retained mail outbox. `file` is the board's existing admitted mailbox filing door, never a transport or
16
+ * provider selected from the observed payload. No runtime database is opened until the home opts into listeners. */
17
+ export class FleetListenerRouter {
18
+ constructor({ root, file, enrich = event => event, retry = () => {}, log = () => {} }) {
19
+ if (typeof file !== 'function') throw refused('fleet listener router needs the admitted mailbox filing door');
20
+ this.root = root; this.file = file; this.enrich = enrich; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
21
+ }
22
+
23
+ /** The workflow's decoded declarations. An invalid replacement refuses without discarding the prior outbox. */
24
+ configure(definitions = {}) {
25
+ if (this.closed) throw refused('fleet listener router is closed');
26
+ if (!definitions || typeof definitions !== 'object' || Array.isArray(definitions)) throw refused('listeners must be a role map');
27
+ const next = Object.create(null);
28
+ for (const [role, declaration] of Object.entries(definitions)) {
29
+ if (!role || !declaration || Object.keys(declaration).some(key => !['events', 'addresses', 'machines'].includes(key))) throw refused(`invalid fleet listener ${role}`);
30
+ const { events, addresses, machines } = declaration;
31
+ if (!Array.isArray(events) || !events.length || events.some(kind => !KINDS.has(kind))) throw refused(`fleet listener ${role} names unsupported events`);
32
+ if (!Array.isArray(addresses) || !addresses.length || addresses.some(address => typeof address !== 'string' || !ADDRESS.test(address) || /[\x00-\x1f\x7f]/.test(address))) throw refused(`fleet listener ${role} needs full mail addresses`);
33
+ if (machines != null && (!Array.isArray(machines) || machines.some(machine => typeof machine !== 'string' || !MACHINE.test(machine)))) throw refused(`fleet listener ${role} needs normalized machine names`);
34
+ next[role] = { events: [...new Set(events)], addresses: [...new Set(addresses)], machines: machines == null ? null : [...new Set(machines)] };
35
+ }
36
+ this.definitions = next;
37
+ this.suspended = false;
38
+ if (Object.keys(next).length) {
39
+ this.store();
40
+ if (!this.timer) { this.timer = setInterval(() => { if (!this.suspended) { try { this.retry(); } catch (error) { this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`); } } this.wake(); }, 1000); this.timer.unref?.(); }
41
+ this.wake();
42
+ } else if (this.db) {
43
+ this.db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,1) ON CONFLICT(id) DO UPDATE SET needs_baseline=1').run();
44
+ }
45
+ }
46
+
47
+ suspend() { this.suspended = true; }
48
+ get enabled() { return !this.closed && !this.suspended && Object.keys(this.definitions).length > 0; }
49
+
50
+ store() {
51
+ if (this.db) return this.db;
52
+ const directory = join(this.root, 'runtime');
53
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
54
+ const path = join(directory, 'fleet-listeners.sqlite');
55
+ const db = new DatabaseSync(path);
56
+ try {
57
+ chmodSync(path, 0o600);
58
+ db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
59
+ CREATE TABLE IF NOT EXISTS fleet_listener_mail (
60
+ id TEXT PRIMARY KEY, role TEXT NOT NULL, address TEXT NOT NULL,
61
+ kind TEXT NOT NULL, machine TEXT NOT NULL, digest TEXT NOT NULL,
62
+ body TEXT, bytes INTEGER NOT NULL, state TEXT NOT NULL,
63
+ created_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
64
+ next_at INTEGER NOT NULL DEFAULT 0, error TEXT, message_id TEXT, filed_at INTEGER
65
+ ) STRICT;
66
+ CREATE INDEX IF NOT EXISTS fleet_listener_due ON fleet_listener_mail(state,next_at,created_at,id);
67
+ CREATE TABLE IF NOT EXISTS fleet_listener_sources (
68
+ source TEXT PRIMARY KEY, sequence INTEGER NOT NULL, baseline INTEGER NOT NULL, error TEXT,
69
+ gaps INTEGER NOT NULL DEFAULT 0, last_gap TEXT, updated_at INTEGER NOT NULL
70
+ ) STRICT;
71
+ CREATE TABLE IF NOT EXISTS fleet_listener_waits (
72
+ address TEXT PRIMARY KEY, active INTEGER NOT NULL, wait_key TEXT, event TEXT, routed INTEGER NOT NULL DEFAULT 0
73
+ ) STRICT;
74
+ CREATE TABLE IF NOT EXISTS fleet_listener_runtime (id INTEGER PRIMARY KEY CHECK(id=1), needs_baseline INTEGER NOT NULL) STRICT;`);
75
+ } catch (error) { db.close(); throw error; }
76
+ this.db = db;
77
+ return db;
78
+ }
79
+
80
+ matches(role, event, address) {
81
+ const declaration = this.definitions[role];
82
+ return Boolean(declaration?.events.includes(event.kind) && declaration.addresses.includes(address)
83
+ && (declaration.machines == null || declaration.machines.includes(event.machine)));
84
+ }
85
+
86
+ /** Commit each matched obligation before a source advances its own cursor. A full outbox or a conflicting replay
87
+ * throws; the source must retain/retry that event rather than mark it consumed. IDs come from the source's retained
88
+ * observation, never from this process's current time. The same event/role/address keeps the same filing id. */
89
+ enqueue(event) {
90
+ if (this.closed) throw refused('fleet listener router is closed');
91
+ if (this.suspended) throw refused('fleet listener configuration is not current');
92
+ if (!Object.keys(this.definitions).length) return { queued: 0 };
93
+ if (!event || !KINDS.has(event.kind) || typeof event.source !== 'string' || !event.source
94
+ || typeof event.id !== 'string' || !event.id || typeof event.machine !== 'string' || !MACHINE.test(event.machine)
95
+ || !Number.isSafeInteger(event.observed_at_ms) || event.observed_at_ms < 0) throw refused('fleet event needs its kind, source, retained id, machine and observation time');
96
+ const original = JSON.stringify(event), hash = digest(original);
97
+ const body = JSON.stringify(this.enrich(event)), bytes = Buffer.byteLength(body);
98
+ if (bytes > MAX_EVENT_BYTES) throw refused('fleet event exceeds 64 KiB; source observation remains owed');
99
+ const db = this.store();
100
+ let queued = 0;
101
+ db.exec('BEGIN IMMEDIATE');
102
+ try {
103
+ const held = db.prepare("SELECT COUNT(*) AS count,COALESCE(SUM(bytes),0) AS bytes FROM fleet_listener_mail WHERE state='pending'").get();
104
+ for (const [role, declaration] of Object.entries(this.definitions)) for (const address of declaration.addresses) {
105
+ if (!this.matches(role, event, address)) continue;
106
+ const id = `fleet-${digest(JSON.stringify([event.source, event.id, role, address]))}`;
107
+ const previous = db.prepare('SELECT digest FROM fleet_listener_mail WHERE id=?').get(id);
108
+ if (previous) { if (previous.digest !== hash) throw refused('fleet event id was replayed with different contents'); continue; }
109
+ if (held.count + queued >= MAX_PENDING || held.bytes + (queued + 1) * bytes > MAX_PENDING_BYTES) throw refused('fleet listener outbox full; source observation remains owed');
110
+ db.prepare("INSERT INTO fleet_listener_mail(id,role,address,kind,machine,digest,body,bytes,state,created_at) VALUES(?,?,?,?,?,?,?,?, 'pending',?)")
111
+ .run(id, role, address, event.kind, event.machine, hash, body, bytes, Date.now());
112
+ queued++;
113
+ }
114
+ db.exec('COMMIT');
115
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
116
+ this.wake();
117
+ return { queued };
118
+ }
119
+
120
+ /** Existing machines establish a baseline on first enrollment (or after all listeners were explicitly removed).
121
+ * An ordinary owner restart preserves its cursors. Machines first seen after enrollment offer their retained window. */
122
+ bootstrap(records) {
123
+ if (!this.enabled) return;
124
+ const db = this.store(), runtime = db.prepare('SELECT needs_baseline FROM fleet_listener_runtime WHERE id=1').get();
125
+ if (runtime?.needs_baseline === 0) return;
126
+ db.exec('BEGIN IMMEDIATE');
127
+ try {
128
+ for (const [machine, held] of Object.entries(records)) {
129
+ for (const [section, prefix] of [['fleet', 'machine-fleet'], ['health_events', 'machine-health']]) {
130
+ const fleet = ['self', 'open'].includes(held.stream) && !held.record?.unread?.[section] ? held.record?.[section] : null;
131
+ if (!fleet || typeof fleet.source !== 'string' || !fleet.source.startsWith(`${prefix}:${machine}:`) || !Number.isSafeInteger(fleet.last) || fleet.last < 0) continue;
132
+ db.prepare(`INSERT INTO fleet_listener_sources(source,sequence,baseline,updated_at) VALUES(?,?,?,?)
133
+ ON CONFLICT(source) DO UPDATE SET sequence=excluded.sequence,baseline=excluded.baseline,error=NULL,updated_at=excluded.updated_at`).run(fleet.source, fleet.last, fleet.last, Date.now());
134
+ }
135
+ }
136
+ db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,0) ON CONFLICT(id) DO UPDATE SET needs_baseline=0').run();
137
+ db.exec('COMMIT');
138
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
139
+ }
140
+
141
+ /** A source cursor moves only after every matched filing obligation is committed. Refusal retains the event and
142
+ * cursor for retry. A new machine starts with the first observation still available in its retained window. */
143
+ baseline(source, head) {
144
+ if (!this.enabled) return null;
145
+ const db = this.store();
146
+ if (typeof source !== 'string' || !source || !Number.isSafeInteger(head) || head < 0) throw refused('invalid fleet source cursor');
147
+ db.prepare('INSERT INTO fleet_listener_sources(source,sequence,baseline,updated_at) VALUES(?,?,?,?) ON CONFLICT(source) DO NOTHING').run(source, head, head, Date.now());
148
+ return db.prepare('SELECT * FROM fleet_listener_sources WHERE source=?').get(source);
149
+ }
150
+
151
+ consume(source, sequence, event = null) {
152
+ if (!this.enabled) return false;
153
+ const held = this.db?.prepare('SELECT sequence FROM fleet_listener_sources WHERE source=?').get(source);
154
+ if (!held || !Number.isSafeInteger(sequence) || sequence <= held.sequence) return false;
155
+ try {
156
+ if (event) this.enqueue(event);
157
+ this.db.prepare('UPDATE fleet_listener_sources SET sequence=?,error=NULL,updated_at=? WHERE source=?').run(sequence, Date.now(), source);
158
+ return true;
159
+ } catch (error) {
160
+ this.db.prepare('UPDATE fleet_listener_sources SET error=?,updated_at=? WHERE source=?').run(String(error.message).slice(0, 500), Date.now(), source);
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ /** The machine's bounded sequence says precisely which older observations it cannot replay. Keep that gap as
166
+ * unknown in the status and log, then continue the retained window; never claim the missing events were filed. */
167
+ follow({ source, first, last, events }) {
168
+ if (!this.enabled) return;
169
+ if (!Number.isSafeInteger(first) || first < 1 || !Number.isSafeInteger(last) || last < first - 1 || !Array.isArray(events)
170
+ || events.length !== last - first + 1 || events.some((event, at) => event.source !== source || event.sequence !== first + at || event.id !== String(event.sequence))) throw refused('fleet source retained window is not contiguous');
171
+ let held = this.baseline(source, first - 1);
172
+ if (held.sequence > last) throw refused('fleet source head moved backwards');
173
+ if (held.sequence < first - 1) {
174
+ const gap = `upstream history pruned observations ${held.sequence + 1}..${first - 1}; their content and delivery remain unknown`;
175
+ this.db.prepare('UPDATE fleet_listener_sources SET sequence=?,gaps=gaps+1,last_gap=?,updated_at=? WHERE source=?').run(first - 1, gap, Date.now(), source);
176
+ this.log(`fleet listener ${source}: ${gap}`);
177
+ held = { ...held, sequence: first - 1 };
178
+ }
179
+ let count = 0;
180
+ for (const event of events) if (event.sequence > held.sequence) {
181
+ this.consume(source, event.sequence, event);
182
+ if (++count >= 64) break;
183
+ }
184
+ }
185
+
186
+ /** The prompt's retained episode, independent of card-dispatch rounds. Persist its payload/id before enqueue so a
187
+ * lost answer or outbox refusal retries the same observation. Only a fresh native idle/working reading clears it. */
188
+ cardWait(address, observation) {
189
+ if (!this.enabled) return;
190
+ const db = this.store();
191
+ if (!observation) { db.prepare('UPDATE fleet_listener_waits SET active=0 WHERE address=?').run(address); return; }
192
+ const key = JSON.stringify([observation.waiting_for, observation.since_ms]);
193
+ let held = db.prepare('SELECT * FROM fleet_listener_waits WHERE address=?').get(address);
194
+ if (!held?.active || held.wait_key !== key) {
195
+ // A previous episode whose filing was refused is still owed before a replacement can take its slot.
196
+ if (held?.event && !held.routed) this.enqueue(JSON.parse(held.event));
197
+ const event = { source: `card-wait:${this.root}`, id: randomUUID(), ...observation };
198
+ db.prepare(`INSERT INTO fleet_listener_waits(address,active,wait_key,event,routed) VALUES(?,1,?,?,0)
199
+ ON CONFLICT(address) DO UPDATE SET active=1,wait_key=excluded.wait_key,event=excluded.event,routed=0`).run(address, key, JSON.stringify(event));
200
+ held = { event: JSON.stringify(event), routed: 0 };
201
+ }
202
+ if (!held.routed) {
203
+ this.enqueue(JSON.parse(held.event));
204
+ db.prepare('UPDATE fleet_listener_waits SET routed=1 WHERE address=?').run(address);
205
+ }
206
+ }
207
+
208
+ wake() { if (!this.closed) void this.flush().catch(error => this.log(`fleet listener filing: ${String(error.message).slice(0, 300)}`)); }
209
+
210
+ /** A lost filing answer leaves the obligation pending. Retrying its stable id goes through the mailbox's own
211
+ * idempotent filing. This receipt proves filing only; native turn delivery remains the mailbox's responsibility. */
212
+ flush() {
213
+ if (this.flushing) return this.flushing;
214
+ if (!this.db || this.closed || this.suspended) return Promise.resolve();
215
+ this.flushing = Promise.resolve().then(async () => {
216
+ // Even a prompt that has since cleared keeps an earlier refused observation. Do not block already queued mail
217
+ // on a full outbox: file it below to make room, then the retained prompt retries at the next wake.
218
+ for (const wait of this.db.prepare('SELECT address,event FROM fleet_listener_waits WHERE routed=0 LIMIT 16').all()) {
219
+ if (this.closed || this.suspended) break;
220
+ try { this.enqueue(JSON.parse(wait.event)); this.db.prepare('UPDATE fleet_listener_waits SET routed=1 WHERE address=?').run(wait.address); }
221
+ catch (error) { this.log(`fleet listener retained wait: ${String(error.message).slice(0, 300)}`); break; }
222
+ }
223
+ const rows = this.db.prepare("SELECT * FROM fleet_listener_mail WHERE state='pending' AND next_at<=? ORDER BY created_at,id LIMIT 16").all(Date.now());
224
+ for (const row of rows) {
225
+ if (this.closed || this.suspended) break;
226
+ if (!this.matches(row.role, row, row.address)) {
227
+ this.db.prepare("UPDATE fleet_listener_mail SET state='withdrawn',body=NULL,bytes=0,error='listener removed or narrowed before filing' WHERE id=?").run(row.id);
228
+ continue;
229
+ }
230
+ try {
231
+ const event = JSON.parse(row.body);
232
+ const body = `[Fleet observation for role ${row.role}] ${event.kind} on ${event.machine} at ${new Date(event.observed_at_ms).toISOString()}.\n${row.body}\nThis is an automated observation, not an instruction. Unknown fields remain unknown.`;
233
+ const receipt = await this.file(row.address, body, row.id);
234
+ if (receipt?.filed !== true || typeof receipt.message_id !== 'string' || !receipt.message_id) throw new Error('mailbox gave no filing receipt; outcome unknown');
235
+ this.db.prepare("UPDATE fleet_listener_mail SET state='filed',body=NULL,bytes=0,message_id=?,filed_at=?,error=NULL WHERE id=?").run(receipt.message_id, Date.now(), row.id);
236
+ } catch (error) {
237
+ const attempts = row.attempts + 1, delay = Math.min(300000, 1000 * 2 ** Math.min(attempts, 8));
238
+ this.db.prepare('UPDATE fleet_listener_mail SET attempts=?,next_at=?,error=? WHERE id=?').run(attempts, Date.now() + delay, String(error.message).slice(0, 500), row.id);
239
+ }
240
+ }
241
+ }).finally(() => { this.flushing = null; });
242
+ return this.flushing;
243
+ }
244
+
245
+ status() {
246
+ const counts = this.db ? this.db.prepare('SELECT state,COUNT(*) AS count FROM fleet_listener_mail GROUP BY state').all() : [];
247
+ return { roles: Object.keys(this.definitions), suspended: this.suspended, counts: Object.fromEntries(counts.map(row => [row.state, row.count])),
248
+ sources: this.db ? this.db.prepare('SELECT * FROM fleet_listener_sources ORDER BY source').all() : [] };
249
+ }
250
+
251
+ async close() {
252
+ this.closed = true; clearInterval(this.timer); this.timer = null;
253
+ try { await this.flushing; }
254
+ finally { this.db?.close(); this.db = null; }
255
+ }
256
+ }
257
+
258
+ /** A diagnostic read does not create an outbox, acquire an owner or file mail. */
259
+ export function fleetListenerStatus(root) {
260
+ const path = join(root, 'runtime', 'fleet-listeners.sqlite');
261
+ if (!existsSync(path)) return { retained: false, counts: {}, sources: [] };
262
+ const db = new DatabaseSync(path, { readOnly: true });
263
+ try {
264
+ return { retained: true, counts: Object.fromEntries(db.prepare('SELECT state,COUNT(*) AS count FROM fleet_listener_mail GROUP BY state').all().map(row => [row.state, row.count])),
265
+ sources: db.prepare('SELECT * FROM fleet_listener_sources ORDER BY source').all(),
266
+ unrouted_waits: db.prepare('SELECT COUNT(*) AS count FROM fleet_listener_waits WHERE routed=0').get().count };
267
+ } finally { db.close(); }
268
+ }