@agentpulselabs/cli-plugin-pulse 1.1.2 → 1.1.5
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 +9 -0
- package/package.json +3 -2
- package/src/commands/pulse/cutover.js +34 -10
- package/src/daemon/main.js +1 -0
- package/src/hub/core/db.mjs +21 -5
- package/src/hub/workers/manager.mjs +44 -6
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +51 -16
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +6 -3
- package/src/lib/daemonProcess.js +43 -6
- package/src/lib/daemonRuntime.js +25 -7
- package/src/lib/hubDbReader.js +6 -0
- package/src/lib/hubScan.js +1 -0
- package/src/lib/ledger.js +21 -12
- package/src/lib/lifecycle.js +3 -1
- package/src/lib/runnerStore.js +61 -49
- package/src/lib/serviceManager.js +3 -1
- package/src/runner/bridge.js +8 -3
- package/src/runner/capture.js +83 -20
- package/src/runner/channelDiscipline.js +48 -14
- package/src/runner/channelWalk.js +42 -0
- package/src/runner/deliveryStates.js +22 -9
- package/src/runner/directoryCache.js +34 -0
- package/src/runner/facade.js +71 -14
- package/src/runner/hubCapture.js +105 -56
- package/src/runner/loopMonitor.js +89 -0
- package/src/runner/runnerHub.js +3 -1
- package/src/runner/runnerProcess.js +58 -16
- package/src/runner/runnerService.js +29 -4
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
package/src/runner/capture.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
1
2
|
import { withTx } from '../hub/core/db.mjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -18,6 +19,17 @@ const SCHEMA = [
|
|
|
18
19
|
'CREATE TABLE IF NOT EXISTS pulse_capture (id INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, op TEXT NOT NULL, k1 TEXT NOT NULL, k2 TEXT)',
|
|
19
20
|
];
|
|
20
21
|
|
|
22
|
+
/** One chunk of swept or pumped work holds the write lock and the event loop about this long, then yields. */
|
|
23
|
+
export const CHUNK_MS = 50;
|
|
24
|
+
/**
|
|
25
|
+
* Between chunks the lock is left free this long (writeInChunks' pause, without blocking the loop); longer while
|
|
26
|
+
* another connection is writing (it committed since the last pause), because its busy handler sleeps up to 100 ms
|
|
27
|
+
* between tries and would otherwise rarely land in a short gap.
|
|
28
|
+
*/
|
|
29
|
+
export const CHUNK_PAUSE_MS = 15;
|
|
30
|
+
export const WRITER_PAUSE_MS = 120;
|
|
31
|
+
const JOURNAL_PAGE = 200;
|
|
32
|
+
|
|
21
33
|
function triggerSql({ table, keys, ops }) {
|
|
22
34
|
return ops.map((op) => {
|
|
23
35
|
const row = op === 'DELETE' ? 'OLD' : 'NEW';
|
|
@@ -37,30 +49,40 @@ export function installCapture(db) {
|
|
|
37
49
|
* Turns every captured hub write into outbox commands inside the transaction that made it.
|
|
38
50
|
*
|
|
39
51
|
* `tx(fn, { guard })` runs fn in one write transaction (the ported core's own withTx calls join it), drains the trigger
|
|
40
|
-
*
|
|
52
|
+
* rows fn added through `mapping.commands(change, db)` into the outbox, then runs `guard`; a guard that throws rolls the
|
|
41
53
|
* hub write and its commands back together. `applying(fn)` writes with the triggers off, for rows that came from the
|
|
42
54
|
* cloud: other connections never see the flag set, because SQLite serializes writers and the flag is set and reset
|
|
43
|
-
* inside one write transaction. `pump()` drains what writes
|
|
55
|
+
* inside one write transaction. `pump({ budgetMs })` drains what writes on other connections (the collect worker) left
|
|
56
|
+
* in the journal, oldest first, for at most budgetMs per transaction. `chunks(steps)` runs steps in capture
|
|
57
|
+
* transactions of about CHUNK_MS each, leaving the event loop and the lock free between them (`yieldLock`).
|
|
44
58
|
*/
|
|
45
59
|
export function createCapture({ db, store, mapping, log = () => {} }) {
|
|
46
|
-
const journal = db.prepare('SELECT id, tbl, op, k1, k2 FROM pulse_capture ORDER BY id');
|
|
47
|
-
const
|
|
60
|
+
const journal = db.prepare('SELECT id, tbl, op, k1, k2 FROM pulse_capture WHERE id > ? ORDER BY id LIMIT ?');
|
|
61
|
+
const newest = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM pulse_capture');
|
|
62
|
+
const waiting = db.prepare('SELECT EXISTS (SELECT 1 FROM pulse_capture) AS found');
|
|
63
|
+
const clear = db.prepare('DELETE FROM pulse_capture WHERE id > ? AND id <= ?');
|
|
48
64
|
const setApplying = db.prepare('UPDATE pulse_ctx SET applying = ? WHERE one = 1');
|
|
65
|
+
const dataVersion = db.prepare('PRAGMA data_version');
|
|
66
|
+
let lastVersion = null;
|
|
67
|
+
const batchOf = () => (mapping.batch ? mapping.batch(db) : (change) => mapping.commands(change, db));
|
|
49
68
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
const entityOf = (row) => `${row.tbl}\0${row.k1}\0${row.k2 ?? ''}`;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Commands for one page of journal rows, each entity once at its latest row in the page, in row order. Stops after
|
|
73
|
+
* the row at which `over()` turns true; returns the id of the last row it is done with and what it queued. Rows
|
|
74
|
+
* before that whose entity comes again later in the page are left to that later row.
|
|
75
|
+
*/
|
|
76
|
+
function drainPage(rows, commands, over) {
|
|
77
|
+
const lastAt = new Map(rows.map((row, index) => [entityOf(row), index]));
|
|
59
78
|
const entries = [];
|
|
60
|
-
|
|
79
|
+
let done = rows.length - 1;
|
|
80
|
+
for (const [index, row] of rows.entries()) {
|
|
81
|
+
if (lastAt.get(entityOf(row)) !== index) continue;
|
|
82
|
+
const { tbl, op, k1, k2 } = row;
|
|
61
83
|
const change = { tbl, op, k1, k2 };
|
|
62
84
|
try {
|
|
63
|
-
entries.push(...
|
|
85
|
+
entries.push(...commands(change));
|
|
64
86
|
} catch (error) {
|
|
65
87
|
// A full outbox refuses the write itself (507 pulse_backlog), so the caller is told.
|
|
66
88
|
if (error.code === 'pulse_backlog') throw error;
|
|
@@ -71,16 +93,36 @@ export function createCapture({ db, store, mapping, log = () => {} }) {
|
|
|
71
93
|
);
|
|
72
94
|
log('capture_failed', { tbl, op, code: error?.code || 'capture_failed' });
|
|
73
95
|
}
|
|
96
|
+
if (index < rows.length - 1 && over()) { done = index; break; }
|
|
74
97
|
}
|
|
75
98
|
if (entries.length) store.enqueue(entries);
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
return { last: rows[done].id, queued: entries.length, all: done === rows.length - 1 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drains journal rows after `after`, a page at a time, until none are left or `budgetMs` has passed. */
|
|
103
|
+
function drain({ after = 0, budgetMs = Infinity } = {}) {
|
|
104
|
+
const started = performance.now();
|
|
105
|
+
const over = () => performance.now() - started >= budgetMs;
|
|
106
|
+
const commands = batchOf();
|
|
107
|
+
let last = after;
|
|
108
|
+
let queued = 0;
|
|
109
|
+
for (;;) {
|
|
110
|
+
const rows = journal.all(last, JOURNAL_PAGE);
|
|
111
|
+
if (!rows.length) break;
|
|
112
|
+
const page = drainPage(rows, commands, over);
|
|
113
|
+
queued += page.queued;
|
|
114
|
+
last = page.last;
|
|
115
|
+
if (!page.all || rows.length < JOURNAL_PAGE || over()) break;
|
|
116
|
+
}
|
|
117
|
+
if (last > after) clear.run(after, last);
|
|
118
|
+
return queued;
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
function tx(fn, { guard = null, label = 'capture' } = {}) {
|
|
81
122
|
return withTx(db, () => {
|
|
123
|
+
const mark = newest.get().id;
|
|
82
124
|
const out = fn();
|
|
83
|
-
drain();
|
|
125
|
+
drain({ after: mark });
|
|
84
126
|
if (guard) guard();
|
|
85
127
|
return out;
|
|
86
128
|
}, label);
|
|
@@ -97,7 +139,28 @@ export function createCapture({ db, store, mapping, log = () => {} }) {
|
|
|
97
139
|
}, 'apply');
|
|
98
140
|
}
|
|
99
141
|
|
|
100
|
-
const pump = () => withTx(db, drain, 'capture pump');
|
|
142
|
+
const pump = ({ budgetMs = Infinity } = {}) => withTx(db, () => drain({ budgetMs }), 'capture pump');
|
|
143
|
+
const pending = () => Boolean(waiting.get().found);
|
|
144
|
+
const yieldLock = () => {
|
|
145
|
+
const version = dataVersion.get().data_version;
|
|
146
|
+
const writing = lastVersion !== null && version !== lastVersion;
|
|
147
|
+
lastVersion = version;
|
|
148
|
+
return sleep(writing ? WRITER_PAUSE_MS : CHUNK_PAUSE_MS);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
async function chunks(steps, { budgetMs = CHUNK_MS, label = 'capture chunk' } = {}) {
|
|
152
|
+
let i = 0;
|
|
153
|
+
let n = 0;
|
|
154
|
+
while (i < steps.length) {
|
|
155
|
+
const started = performance.now();
|
|
156
|
+
n += 1;
|
|
157
|
+
tx(() => {
|
|
158
|
+
do steps[i++](); while (i < steps.length && performance.now() - started < budgetMs);
|
|
159
|
+
}, { label: `${label} (chunk ${n})` });
|
|
160
|
+
if (i < steps.length) await yieldLock();
|
|
161
|
+
}
|
|
162
|
+
return n;
|
|
163
|
+
}
|
|
101
164
|
|
|
102
|
-
return { tx, applying, pump, drain };
|
|
165
|
+
return { tx, applying, pump, pending, chunks, yieldLock, drain };
|
|
103
166
|
}
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { channelFiles, normRole, parsePosts } from '../hub/lib/channels.mjs';
|
|
4
4
|
import { isTerminal } from '../hub/lib/closure.mjs';
|
|
5
|
+
import { channelFilesAsync, readPostsAsync } from './channelWalk.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Pulse 1.1.2: the delivery-discipline rules as checks in the runner, from the Cursor hook and a sweep.
|
|
@@ -129,6 +130,9 @@ function readPosts(file) {
|
|
|
129
130
|
} catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/** What E3 needs of a channel: its newest post, how many posts it has and its mtime; null when it can't be read. */
|
|
134
|
+
const staleState = (read) => (read ? { last: read.posts[read.posts.length - 1] || null, count: read.posts.length, mtime: read.mtime } : null);
|
|
135
|
+
|
|
132
136
|
const keyOf = (file) => {
|
|
133
137
|
const m = file.match(new RegExp(`(?:^|/)(${NAME})/\\.agents/(${NAME})/channel\\.md$`));
|
|
134
138
|
return m ? `${m[1]}/${m[2]}` : null;
|
|
@@ -352,49 +356,79 @@ export function createChannelDiscipline(hub, {
|
|
|
352
356
|
}
|
|
353
357
|
|
|
354
358
|
/** E3's owner of a channel's newest post: the session that wrote it, else the agent of that stream and role, else its architect. */
|
|
355
|
-
function ownerOf(key, last) {
|
|
359
|
+
function ownerOf(key, last, directory) {
|
|
356
360
|
const wrote = q.author.get(key, last.role, last.head);
|
|
357
361
|
if (wrote) return wrote.session;
|
|
358
|
-
const dir =
|
|
362
|
+
const dir = directory();
|
|
359
363
|
const crew = dir.filter((a) => a.streamKey === key && normRole(a.role) === last.role).sort((a, b) => (b.seenAt || 0) - (a.seenAt || 0));
|
|
360
364
|
if (crew.length) return crew[0].id;
|
|
361
365
|
const arch = dir.find((a) => a.isArchitect && (a.streams || []).includes(key));
|
|
362
366
|
return last.role === 'ARCHITECT' && arch ? arch.id : null;
|
|
363
367
|
}
|
|
364
368
|
|
|
365
|
-
function architectOf(key, owner) {
|
|
366
|
-
const dir =
|
|
369
|
+
function architectOf(key, owner, directory) {
|
|
370
|
+
const dir = directory();
|
|
367
371
|
const a = owner ? dir.find((d) => d.id === owner) : null;
|
|
368
372
|
if (a && a.deliveryId) return a.deliveryId;
|
|
369
373
|
const arch = dir.find((d) => d.isArchitect && (d.streams || []).includes(key));
|
|
370
374
|
return arch ? arch.deliveryId || arch.id : null;
|
|
371
375
|
}
|
|
372
376
|
|
|
377
|
+
/** E3's view of every channel written in the last STALE_WATCH_MS, read now: `[{ key, file, state }]`. */
|
|
378
|
+
const channelStates = (now) => channelFiles(channelRoots, now - STALE_WATCH_MS).map((c) => ({ key: c.key, file: c.file, state: staleState(readPosts(c.file)) }));
|
|
379
|
+
|
|
380
|
+
/** What `scan` last read of each channel, by its mtime and size: an unchanged file is not read again. */
|
|
381
|
+
const marks = new Map();
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The same view as the sweep's own read, without blocking the event loop or holding a transaction: the walk and the
|
|
385
|
+
* reads are asynchronous, and only channels whose mtime or size moved since the last scan are read and parsed.
|
|
386
|
+
*/
|
|
387
|
+
async function scan(now = clock()) {
|
|
388
|
+
const out = [];
|
|
389
|
+
const live = new Set();
|
|
390
|
+
for (const c of await channelFilesAsync(channelRoots, now - STALE_WATCH_MS)) {
|
|
391
|
+
live.add(c.file);
|
|
392
|
+
const mark = marks.get(c.file);
|
|
393
|
+
let state = mark && mark.mtime === c.mtime && mark.size === c.size ? mark.state : undefined;
|
|
394
|
+
if (state === undefined) {
|
|
395
|
+
state = staleState(await readPostsAsync(c.file, CHANNEL_MAX_BYTES));
|
|
396
|
+
if (state) marks.set(c.file, { mtime: c.mtime, size: c.size, state });
|
|
397
|
+
else marks.delete(c.file);
|
|
398
|
+
}
|
|
399
|
+
out.push({ key: c.key, file: c.file, state });
|
|
400
|
+
}
|
|
401
|
+
for (const file of marks.keys()) if (!live.has(file)) marks.delete(file);
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
|
|
373
405
|
/**
|
|
374
406
|
* E3, from the lease holder's sweep: each channel written in the last STALE_WATCH_MS whose newest post is not
|
|
375
407
|
* terminal. Its age counts from when this runner first saw that post (never before the runner started), not from
|
|
376
|
-
* the header, whose zone and date are unknown.
|
|
408
|
+
* the header, whose zone and date are unknown. `seen` is a `scan` taken before the sweep's transaction; without it
|
|
409
|
+
* the channels are read here. Returns the messages it sent.
|
|
377
410
|
*/
|
|
378
|
-
function sweep(now = clock()) {
|
|
411
|
+
function sweep({ now = clock(), seen = null } = {}) {
|
|
379
412
|
const sent = [];
|
|
380
413
|
const live = new Set();
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
414
|
+
let dir = null;
|
|
415
|
+
const directory = () => (dir ??= registry.directory());
|
|
416
|
+
for (const c of seen ?? channelStates(now)) {
|
|
417
|
+
const last = c.state?.last;
|
|
384
418
|
if (!last) continue;
|
|
385
419
|
live.add(c.key);
|
|
386
420
|
if (isTerminal(last)) { q.staleDrop.run(c.key); continue; }
|
|
387
|
-
const sig = `${last.role}\0${last.head}\0${
|
|
421
|
+
const sig = `${last.role}\0${last.head}\0${c.state.count}`;
|
|
388
422
|
let row = q.stale.get(c.key);
|
|
389
423
|
if (!row || row.sig !== sig) {
|
|
390
|
-
q.staleSet.run(c.key, c.file, sig, Math.max(started, Math.min(now,
|
|
424
|
+
q.staleSet.run(c.key, c.file, sig, Math.max(started, Math.min(now, c.state.mtime)));
|
|
391
425
|
row = q.stale.get(c.key);
|
|
392
426
|
}
|
|
393
427
|
const age = now - row.first_seen;
|
|
394
428
|
const quiet = `${Math.floor(age / 60e3)} min`;
|
|
395
429
|
const lastLine = `\`## [${last.role}] … ${last.head.slice(0, 120)}\``;
|
|
396
430
|
if (age >= staleAskMs && !row.asked_at) {
|
|
397
|
-
const owner = ownerOf(c.key, last);
|
|
431
|
+
const owner = ownerOf(c.key, last, directory);
|
|
398
432
|
q.staleAsked.run(now, owner, c.key);
|
|
399
433
|
row.owner = owner;
|
|
400
434
|
if (owner) {
|
|
@@ -410,7 +444,7 @@ export function createChannelDiscipline(hub, {
|
|
|
410
444
|
if (age >= staleEscalateMs && !row.escalated_at) {
|
|
411
445
|
q.staleEscalated.run(now, c.key);
|
|
412
446
|
const body = `\`${c.key}\` has had no post for ${quiet}; its last post ${lastLine} is not terminal.${row.owner ? ` STATUS? went to \`${row.owner}\` with no post since.` : ' Pulse found no owner to ask.'}`;
|
|
413
|
-
const architect = architectOf(c.key, row.owner);
|
|
447
|
+
const architect = architectOf(c.key, row.owner, directory);
|
|
414
448
|
for (const topic of [...(architect && architect !== row.owner ? [`delivery/${architect}`] : []), 'user']) {
|
|
415
449
|
const env = publish({ from: 'manager', fromName: 'Pulse', topic, type: 'STALE', body, meta: { stale: c.key } });
|
|
416
450
|
if (env && !env.error) sent.push({ key: c.key, to: topic, type: 'STALE' });
|
|
@@ -421,5 +455,5 @@ export function createChannelDiscipline(hub, {
|
|
|
421
455
|
return sent;
|
|
422
456
|
}
|
|
423
457
|
|
|
424
|
-
return { onHook, sweep };
|
|
458
|
+
return { onHook, scan, sweep };
|
|
425
459
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parsePosts } from '../hub/lib/channels.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* hub/lib/channels.mjs `channelFiles` without blocking the event loop: every roots/<repo>/.agents/<task>/channel.md
|
|
8
|
+
* changed since sinceMs, in the same order, with its size as well, so a caller can tell an unchanged file by its
|
|
9
|
+
* mtime and size without reading it.
|
|
10
|
+
*/
|
|
11
|
+
export async function channelFilesAsync(roots, sinceMs) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const root of roots) {
|
|
14
|
+
let repos = [];
|
|
15
|
+
try { repos = await readdir(root); } catch { continue; }
|
|
16
|
+
for (const repo of repos) {
|
|
17
|
+
const dir = path.join(root, repo, '.agents');
|
|
18
|
+
let tasks = [];
|
|
19
|
+
try { tasks = await readdir(dir); } catch { continue; }
|
|
20
|
+
const found = await Promise.all(tasks.map(async (task) => {
|
|
21
|
+
const file = path.join(dir, task, 'channel.md');
|
|
22
|
+
try {
|
|
23
|
+
const st = await stat(file);
|
|
24
|
+
return st.isFile() && st.mtimeMs >= sinceMs ? { key: `${repo}/${task}`, task, repo, file, mtime: st.mtimeMs, size: st.size } : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
}));
|
|
27
|
+
out.push(...found.filter(Boolean));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A channel's posts, read without following a symlink: `{ posts, mtime }`, or null when it can't be read or is over maxBytes. */
|
|
34
|
+
export async function readPostsAsync(file, maxBytes) {
|
|
35
|
+
let handle;
|
|
36
|
+
try {
|
|
37
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
38
|
+
const st = await handle.stat();
|
|
39
|
+
if (!st.isFile() || st.size > maxBytes) return null;
|
|
40
|
+
return { posts: parsePosts(await handle.readFile('utf8'), st.mtimeMs), mtime: st.mtimeMs };
|
|
41
|
+
} catch { return null; } finally { await handle?.close(); }
|
|
42
|
+
}
|
|
@@ -134,18 +134,32 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
|
|
|
134
134
|
* waited BOUNCE_GRACE_MS for a recipient nobody can read: its sender gets one UNDELIVERABLE notice per recipient,
|
|
135
135
|
* from the manager, with an id derived from the message and recipient. A notice is never itself bounced.
|
|
136
136
|
*/
|
|
137
|
-
|
|
138
|
-
const rows = db.prepare(`SELECT d.message_id AS id, d.recipient, m.sender, m.type, m.body FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
137
|
+
const BOUNCE_FROM = `FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
139
138
|
WHERE d.state = 'queued' AND m.expires_at > ? AND m.created_at <= ? AND m.created_at > ?
|
|
140
139
|
AND m.sender <> 'manager' AND m.type <> 'UNDELIVERABLE' AND d.recipient NOT IN ('user', 'manager')
|
|
141
140
|
AND NOT EXISTS (SELECT 1 FROM pulse_reported r WHERE r.message_id = m.id)
|
|
142
|
-
AND NOT EXISTS (SELECT 1 FROM pulse_bounced b WHERE b.message_id = d.message_id AND b.recipient = d.recipient)
|
|
143
|
-
|
|
141
|
+
AND NOT EXISTS (SELECT 1 FROM pulse_bounced b WHERE b.message_id = d.message_id AND b.recipient = d.recipient)`;
|
|
142
|
+
const bounceRows = db.prepare(`SELECT d.message_id AS id, d.recipient, m.sender, m.type, m.body ${BOUNCE_FROM} ORDER BY m.created_at LIMIT ?`);
|
|
143
|
+
const stillBounceable = db.prepare(`SELECT 1 AS found ${BOUNCE_FROM} AND d.message_id = ? AND d.recipient = ?`);
|
|
144
|
+
const bounceWindow = (now) => [now, now - BOUNCE_GRACE_MS, now - DEFAULT_TTL_MS];
|
|
145
|
+
|
|
146
|
+
function bounce(now = clock()) {
|
|
144
147
|
const sent = [];
|
|
148
|
+
for (const step of bounceSteps({ now, sent })) step();
|
|
149
|
+
return sent;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The bounce as one step per candidate row, for capture's chunks: the candidates are read up front, and each step
|
|
154
|
+
* checks its row is still queued and not yet bounced or reported in the transaction it runs in. Pushes to `sent`.
|
|
155
|
+
*/
|
|
156
|
+
function bounceSteps({ now = clock(), sent = [] } = {}) {
|
|
157
|
+
const rows = bounceRows.all(...bounceWindow(now), BOUNCE_BATCH);
|
|
145
158
|
let dir = null;
|
|
146
|
-
|
|
159
|
+
return rows.map((r) => () => {
|
|
160
|
+
if (!stillBounceable.get(...bounceWindow(now), r.id, r.recipient)) return;
|
|
147
161
|
const why = bus.reach(r.recipient, now);
|
|
148
|
-
if (why.state !== 'undeliverable')
|
|
162
|
+
if (why.state !== 'undeliverable') return;
|
|
149
163
|
dir ??= registry.directory(now);
|
|
150
164
|
const id = edgeId('u', r.id, r.recipient);
|
|
151
165
|
const reason = why.reason === 'stale' ? `its session ${why.session} has had no activity for ${why.idleMin} min`
|
|
@@ -157,8 +171,7 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
|
|
|
157
171
|
}, dir);
|
|
158
172
|
q.bounced.run(r.id, r.recipient, env.error ? `error:${env.error}` : id, now);
|
|
159
173
|
if (!env.error) sent.push({ id: r.id, to: r.recipient, notice: id });
|
|
160
|
-
}
|
|
161
|
-
return sent;
|
|
174
|
+
});
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
/**
|
|
@@ -216,5 +229,5 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
|
|
|
216
229
|
return [...open].filter(([, settled]) => settled).map(([id]) => id);
|
|
217
230
|
}
|
|
218
231
|
|
|
219
|
-
return { readOnHook, managerFyi, reports, honest, withDelivery, status, senderOf, report, bounce, postedSince, settleNudges, settledNudgeRecipients };
|
|
232
|
+
return { readOnHook, managerFyi, reports, honest, withDelivery, status, senderOf, report, bounce, bounceSteps, postedSince, settleNudges, settledNudgeRecipients };
|
|
220
233
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ONLINE_MS } from '../hub/core/ids.mjs';
|
|
2
|
+
|
|
3
|
+
const WATCHED = ['actors', 'presence'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `registry.directory(now)` for the runner's connection, built again only when it could come out different: after a
|
|
7
|
+
* write to actors or presence on this connection (temp triggers set a new random token, which a rollback puts back),
|
|
8
|
+
* after a commit on another connection (`PRAGMA data_version`), or once `now` crosses a presence's online edge
|
|
9
|
+
* (`last_seen + ONLINE_MS`), the only way the clock changes it. Callers share the array and must not change it.
|
|
10
|
+
*/
|
|
11
|
+
export function cachedDirectory(db, registry) {
|
|
12
|
+
db.prepare('CREATE TEMP TABLE IF NOT EXISTS pulse_directory_token (one INTEGER PRIMARY KEY CHECK (one = 1), token TEXT NOT NULL)').run();
|
|
13
|
+
db.prepare('INSERT OR IGNORE INTO temp.pulse_directory_token (one, token) VALUES (1, hex(randomblob(8)))').run();
|
|
14
|
+
for (const table of WATCHED) {
|
|
15
|
+
for (const op of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
16
|
+
db.prepare(`CREATE TEMP TRIGGER IF NOT EXISTS pulse_directory_${table}_${op.toLowerCase()} AFTER ${op} ON main.${table}
|
|
17
|
+
BEGIN UPDATE temp.pulse_directory_token SET token = hex(randomblob(8)) WHERE one = 1; END`).run();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const token = db.prepare('SELECT token FROM temp.pulse_directory_token WHERE one = 1');
|
|
21
|
+
const version = db.prepare('PRAGMA data_version');
|
|
22
|
+
const lastEdge = db.prepare('SELECT MAX(last_seen) AS t FROM presence WHERE last_seen <= ?');
|
|
23
|
+
const nextEdge = db.prepare('SELECT MIN(last_seen) AS t FROM presence WHERE last_seen > ?');
|
|
24
|
+
let kept = null;
|
|
25
|
+
return (now = Date.now()) => {
|
|
26
|
+
const key = `${version.get().data_version}:${token.get().token}`;
|
|
27
|
+
if (kept && kept.key === key && now >= kept.from && now < kept.until) return kept.dir;
|
|
28
|
+
const dir = registry.directory(now);
|
|
29
|
+
const before = lastEdge.get(now - ONLINE_MS).t;
|
|
30
|
+
const after = nextEdge.get(now - ONLINE_MS).t;
|
|
31
|
+
kept = { key, dir, from: before == null ? -Infinity : before + ONLINE_MS, until: after == null ? Infinity : after + ONLINE_MS };
|
|
32
|
+
return dir;
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/runner/facade.js
CHANGED
|
@@ -39,6 +39,8 @@ import { execFileBounded } from '../lib/subprocess.js';
|
|
|
39
39
|
import { createBusIdentity, routedMessaging } from './busIdentity.js';
|
|
40
40
|
import { createChannelDiscipline } from './channelDiscipline.js';
|
|
41
41
|
import { createDeliveryStates } from './deliveryStates.js';
|
|
42
|
+
import { UNMONITORED } from './loopMonitor.js';
|
|
43
|
+
import { serial } from './serial.js';
|
|
42
44
|
import { statusPage } from './statusPage.js';
|
|
43
45
|
|
|
44
46
|
const WORKSPACE = path.resolve(HUB, '..');
|
|
@@ -133,6 +135,7 @@ export function plainCapture(db) {
|
|
|
133
135
|
tx: (fn, { guard = null, label = 'write' } = {}) => withTx(db, () => { const out = fn(); if (guard) guard(); return out; }, label),
|
|
134
136
|
applying: (fn) => fn(),
|
|
135
137
|
pump: () => 0,
|
|
138
|
+
chunks: async (steps, { label = 'write' } = {}) => { withTx(db, () => { for (const step of steps) step(); }, label); return 1; },
|
|
136
139
|
};
|
|
137
140
|
}
|
|
138
141
|
|
|
@@ -292,13 +295,31 @@ const hasTable = (db, name) => Boolean(db.prepare("SELECT 1 AS found FROM sqlite
|
|
|
292
295
|
/** Without a cloud lease (tests, a bare hub) this runner is the only one, as the local hub was. */
|
|
293
296
|
const SOLE_LEASE = Object.freeze({ held: () => true, view: () => ({ holder: 'local', here: true }), onChange: () => {} });
|
|
294
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A lease that lapses here only while this process stalled (a long runner.db transaction holds the event loop) comes back
|
|
300
|
+
* as soon as the queued report is read. The manager pauses meanwhile and is stopped, killing its runs, only past this.
|
|
301
|
+
*/
|
|
302
|
+
export const MANAGER_STOP_GRACE_MS = 60e3;
|
|
303
|
+
const MANAGER_LOG_MAX_BYTES = 1024 * 1024;
|
|
304
|
+
|
|
305
|
+
/** state/manager.log: what the manager did wrong, with its time, also on stderr; one older file is kept. */
|
|
306
|
+
export function managerLog(file, { echo = console.error } = {}) {
|
|
307
|
+
return (line) => {
|
|
308
|
+
echo(line);
|
|
309
|
+
try {
|
|
310
|
+
if ((fs.statSync(file, { throwIfNoEntry: false })?.size || 0) > MANAGER_LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`);
|
|
311
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
312
|
+
} catch { /* stderr has it */ }
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
295
316
|
export function serve({
|
|
296
317
|
hub, capture = plainCapture(hub.db), port = 8890, listenHost = '127.0.0.1', stateDir = path.join(HUB, 'state'), dbFile = null,
|
|
297
318
|
enableCollector = true, enableManager = true, collect = null, channelRoots = CHANNEL_ROOTS,
|
|
298
319
|
transcriptsDir = path.join(os.homedir(), '.cursor', 'projects'), watchDir = watchRecursive, identity = null,
|
|
299
320
|
lease = SOLE_LEASE, pulseUrl = null, health = () => ({ state: 'online' }), metrics = () => ({}), drivers = null,
|
|
300
321
|
hookBudgetMs = HOOK_BUDGET_MS, writeBudgetMs = WRITE_BUDGET_MS, onWrite = () => {}, managerOptions = {}, workspaceRoot = WORKSPACE,
|
|
301
|
-
discipline: disciplineOptions = {},
|
|
322
|
+
discipline: disciplineOptions = {}, loop = UNMONITORED,
|
|
302
323
|
} = {}) {
|
|
303
324
|
const me = () => {
|
|
304
325
|
const m = identity ? identity.current() : NOBODY;
|
|
@@ -320,8 +341,9 @@ export function serve({
|
|
|
320
341
|
channelRoots, workspaceRoot, append: appendNoFollow, ...thresholds,
|
|
321
342
|
publish: (message) => hub.messaging.publish(message, hub.registry.directory()),
|
|
322
343
|
});
|
|
344
|
+
const logManager = managerOptions.log || managerLog(path.join(stateDir, 'manager.log'));
|
|
323
345
|
const manager = enableManager
|
|
324
|
-
? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions })
|
|
346
|
+
? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions, log: logManager })
|
|
325
347
|
: { start() {}, stop() {}, view() { return { state: 'offline', online: false, pending: 0 }; } };
|
|
326
348
|
/** The manager's status, with the DONE and STATUS posts it has read (F2). */
|
|
327
349
|
const managerView = () => ({ ...manager.view(), reports: delivery.reports() });
|
|
@@ -334,30 +356,65 @@ export function serve({
|
|
|
334
356
|
}
|
|
335
357
|
if (serverLease.error) console.error(`another hub server holds the lease (${serverLease.holder}); taking over`);
|
|
336
358
|
const timers = [];
|
|
337
|
-
const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref(); timers.push(t); };
|
|
359
|
+
const every = (ms, fn, name = 'timer') => { const t = setInterval(() => loop.tick(name, fn), ms); t.unref(); timers.push(t); };
|
|
338
360
|
if (serverLease.token) {
|
|
339
|
-
every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } });
|
|
361
|
+
every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } }, 'server lease');
|
|
340
362
|
}
|
|
341
363
|
const releaseLease = () => { if (serverLease.token) { try { hub.leases.release('server', serverLease.token); } catch { /* closing */ } } };
|
|
342
364
|
const wrote = () => { feed.dbWrite(); onWrite(); };
|
|
343
365
|
/** The manager, the wake sweep and the scheduled nudge belong to the runner holding the user's manager lease (Q9). */
|
|
344
366
|
const leaseHeld = () => lease.held();
|
|
367
|
+
const deliverySweep = serial(async () => {
|
|
368
|
+
try {
|
|
369
|
+
const settled = capture.tx(() => delivery.settleNudges(), { label: 'delivery sweep' });
|
|
370
|
+
const bounced = [];
|
|
371
|
+
await capture.chunks(delivery.bounceSteps({ sent: bounced }), { label: 'delivery bounce' });
|
|
372
|
+
if (settled + bounced.length) { caches.clear(); wrote(); }
|
|
373
|
+
} catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
|
|
374
|
+
});
|
|
375
|
+
const staleSweep = serial(async () => {
|
|
376
|
+
try {
|
|
377
|
+
const seen = await discipline.scan();
|
|
378
|
+
if (leaseHeld() && capture.tx(() => discipline.sweep({ seen }).length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
|
|
379
|
+
} catch (e) { console.error(`stale sweep failed: ${e.message}`); }
|
|
380
|
+
});
|
|
345
381
|
if (enableManager) {
|
|
346
382
|
if (leaseHeld()) manager.start();
|
|
347
|
-
|
|
383
|
+
let lapsedAt = null;
|
|
384
|
+
let stopTimer = null;
|
|
385
|
+
lease.onChange((held) => {
|
|
386
|
+
const { fence } = lease.view();
|
|
387
|
+
if (held) {
|
|
388
|
+
clearTimeout(stopTimer);
|
|
389
|
+
stopTimer = null;
|
|
390
|
+
if (lapsedAt) {
|
|
391
|
+
logManager(`manager lease held again (fence ${fence})${fence !== lapsedAt.fence ? `; it moved from fence ${lapsedAt.fence}, so runs from before are stopped` : ''}`);
|
|
392
|
+
if (fence !== lapsedAt.fence) manager.stop();
|
|
393
|
+
}
|
|
394
|
+
lapsedAt = null;
|
|
395
|
+
manager.start();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (lapsedAt) return;
|
|
399
|
+
lapsedAt = { fence };
|
|
400
|
+
logManager(`manager lease lapsed here (fence ${fence}); runs in flight keep going, and are stopped if it is not back in ${MANAGER_STOP_GRACE_MS / 1000} s`);
|
|
401
|
+
stopTimer = setTimeout(() => {
|
|
402
|
+
stopTimer = null;
|
|
403
|
+
if (lease.held()) return;
|
|
404
|
+
logManager('manager lease still not held: the manager is stopped');
|
|
405
|
+
manager.stop();
|
|
406
|
+
}, MANAGER_STOP_GRACE_MS);
|
|
407
|
+
stopTimer.unref();
|
|
408
|
+
});
|
|
348
409
|
every(60e3, () => {
|
|
349
410
|
if (!leaseHeld()) return;
|
|
350
411
|
try { if (capture.tx(() => sweepWakes(hub), { label: 'wake sweep' }).length) { caches.clear(); wrote(); } } catch (e) { console.error(`wake sweep failed: ${e.message}`); }
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
} catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
|
|
354
|
-
});
|
|
412
|
+
return deliverySweep();
|
|
413
|
+
}, 'wake and delivery sweep');
|
|
355
414
|
every(disciplineSweepMs, () => {
|
|
356
415
|
if (!leaseHeld()) return;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
} catch (e) { console.error(`stale sweep failed: ${e.message}`); }
|
|
360
|
-
});
|
|
416
|
+
return staleSweep();
|
|
417
|
+
}, 'stale sweep');
|
|
361
418
|
}
|
|
362
419
|
const runner = createRunner({ hub, drivers: drivers || defaultDrivers(), workspaceRoot: WORKSPACE, onChange: () => { graphs.clear(); wrote(); } });
|
|
363
420
|
if (enableManager) runner.begin();
|
|
@@ -369,7 +426,7 @@ export function serve({
|
|
|
369
426
|
});
|
|
370
427
|
if (capture.tx(nudge, { label: 'scheduled nudge' })) { caches.clear(); wrote(); }
|
|
371
428
|
} catch (e) { console.error(`scheduled nudge failed: ${e.message}`); }
|
|
372
|
-
});
|
|
429
|
+
}, 'scheduled nudge');
|
|
373
430
|
|
|
374
431
|
const registeredAt = hasTable(hub.db, 'pulse_registered')
|
|
375
432
|
? hub.db.prepare('INSERT INTO pulse_registered (id, at) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET at = excluded.at')
|