@volter/supercode-orchestrator 0.5.204 → 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.
- package/README.md +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/apply/doors.mjs +25 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +225 -87
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +297 -75
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +78 -24
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +5 -0
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +55 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +91 -28
- package/board/ztrack.mjs +87 -21
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- 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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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,
|
|
656
|
-
|
|
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,
|
|
662
|
-
|
|
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
|
+
}
|