@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.
@@ -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
- * journal through `mapping.commands(change, db)` into the outbox, then runs `guard`; a guard that throws rolls the
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 outside any `tx` left in the journal.
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 clear = db.prepare('DELETE FROM pulse_capture WHERE id <= ?');
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
- function drain() {
51
- const rows = journal.all();
52
- if (!rows.length) return 0;
53
- const latest = new Map();
54
- for (const row of rows) {
55
- const id = `${row.tbl}\0${row.k1}\0${row.k2 ?? ''}`;
56
- latest.delete(id);
57
- latest.set(id, row);
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
- for (const { tbl, op, k1, k2 } of latest.values()) {
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(...mapping.commands(change, db));
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
- clear.run(rows.at(-1).id);
77
- return entries.length;
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 = registry.directory();
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 = registry.directory();
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. Returns the messages it sent.
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
- for (const c of channelFiles(channelRoots, now - STALE_WATCH_MS)) {
382
- const read = readPosts(c.file);
383
- const last = read && read.posts[read.posts.length - 1];
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${read.posts.length}`;
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, read.mtime)));
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
- function bounce(now = clock()) {
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
- ORDER BY m.created_at LIMIT ?`).all(now, now - BOUNCE_GRACE_MS, now - DEFAULT_TTL_MS, BOUNCE_BATCH);
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
- for (const r of rows) {
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') continue;
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
+ }
@@ -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
- lease.onChange((held) => { if (held) manager.start(); else manager.stop(); });
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
- try {
352
- if (capture.tx(() => delivery.settleNudges() + delivery.bounce().length, { label: 'delivery sweep' })) { caches.clear(); wrote(); }
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
- try {
358
- if (capture.tx(() => discipline.sweep().length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
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')