@agentpulselabs/cli-plugin-pulse 1.1.3 → 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.
@@ -2,17 +2,22 @@ import { mkdirSync, watch } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { RUNNER_DB_FILE } from '../lib/runnerStore.js';
4
4
  import { createApplier } from './applier.js';
5
+ import { CHUNK_MS } from './capture.js';
5
6
  import { createLeaseState } from './lease.js';
7
+ import { createLoopMonitor } from './loopMonitor.js';
6
8
  import { createRenderer } from './render.js';
7
9
  import { mappingFor } from './captureMapping.js';
8
10
  import { serve } from './facade.js';
9
11
  import { openRunnerHub } from './runnerHub.js';
12
+ import { serial } from './serial.js';
10
13
  import { importSpool } from './spool.js';
11
14
 
12
15
  const PUMP_MS = 250;
13
16
  const SPOOL_SCAN_MS = 5000;
14
17
  /** Actors coalesced by capture (a clock-only change goes up at most once a minute) and dropped facts. */
15
18
  const SWEEP_MS = 30_000;
19
+ /** How often the runner tells the daemon its event-loop delay (daemon-health.json `runner.loopDelay`). */
20
+ const LOOP_REPORT_MS = 5_000;
16
21
 
17
22
  /** Runs the runner in this process: runner.db as the hub, capture, the facade. Resolves once it is listening. */
18
23
  export async function startRunner({
@@ -20,9 +25,16 @@ export async function startRunner({
20
25
  enableManager = true, pulseUrl = null, hookBudgetMs, writeBudgetMs, machineId = null, cutoverSeq = 0, lease: leaseMode = 'cloud', managerOptions, maxPending,
21
26
  seedFrom = null, seedSha256 = null, workspaceRoot = path.dirname(hubHome), discipline, send = process.send ? (message) => process.send(message) : null, log = (event, details) => {
22
27
  process.stdout.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...details })}\n`);
23
- },
28
+ }, loopStallMs,
24
29
  }) {
25
30
  if (path.basename(dbFile) !== RUNNER_DB_FILE) throw new Error(`the runner database must be named ${RUNNER_DB_FILE}`);
31
+ const loop = createLoopMonitor({
32
+ ...(loopStallMs ? { stallMs: loopStallMs } : {}),
33
+ log: (event, details) => {
34
+ log(event, details);
35
+ if (send) send({ type: event, details });
36
+ },
37
+ });
26
38
  const stateDir = path.join(hubHome, 'state');
27
39
  // The values capture scrubs besides its secret rules: the hub token, and the daemon's held credentials (IPC).
28
40
  let heldSecrets = [];
@@ -75,29 +87,58 @@ export async function startRunner({
75
87
  const facade = serve({
76
88
  hub: runner.hub, capture: runner.capture, port, listenHost: host, stateDir, dbFile, enableCollector, enableManager,
77
89
  pulseUrl, onWrite, health: () => health, ...(lease ? { lease } : {}), ...(managerOptions ? { managerOptions } : {}), ...(hookBudgetMs !== undefined ? { hookBudgetMs } : {}), ...(writeBudgetMs !== undefined ? { writeBudgetMs } : {}),
78
- channelRoots: roots, workspaceRoot, ...(discipline ? { discipline } : {}), ...(transcriptsDir !== undefined ? { transcriptsDir } : {}),
90
+ channelRoots: roots, workspaceRoot, loop, ...(discipline ? { discipline } : {}), ...(transcriptsDir !== undefined ? { transcriptsDir } : {}),
91
+ });
92
+ let closing = false;
93
+ const failed = (event, error) => log(event, { code: error.code || 'error', message: String(error.message).slice(0, 200) });
94
+ /** What other connections (the collect worker) left in the capture journal, a short transaction at a time. */
95
+ const drainJournal = serial(async () => {
96
+ try {
97
+ while (!closing) {
98
+ if (runner.capture.pump({ budgetMs: CHUNK_MS })) onWrite();
99
+ if (!runner.capture.pending()) return;
100
+ await runner.capture.yieldLock();
101
+ }
102
+ } catch (error) { failed('capture_pump_failed', error); }
79
103
  });
80
- const pump = setInterval(() => {
81
- try { if (runner.capture.pump()) onWrite(); } catch (error) { log('capture_pump_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) }); }
82
- }, PUMP_MS);
104
+ const pump = setInterval(() => loop.tick('capture pump', drainJournal), PUMP_MS);
83
105
  pump.unref();
84
- const sweep = setInterval(() => {
85
- try { runner.capture.tx(() => runner.mapping.sweep(runner.hub.db), { label: 'capture sweep' }); onWrite(); } catch (error) { log('capture_sweep_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) }); }
86
- }, SWEEP_MS);
106
+ const sweepAll = serial(async () => {
107
+ try {
108
+ const { db } = runner.hub;
109
+ const steps = runner.mapping.sweepSteps ? runner.mapping.sweepSteps(db) : [() => runner.mapping.sweep(db)];
110
+ await runner.capture.chunks(steps, { label: 'capture sweep' });
111
+ onWrite();
112
+ } catch (error) { failed('capture_sweep_failed', error); }
113
+ });
114
+ const sweep = setInterval(() => loop.tick('capture sweep', sweepAll), SWEEP_MS);
87
115
  sweep.unref();
88
116
  const spoolDir = path.join(stateDir, 'spool');
89
117
  mkdirSync(spoolDir, { recursive: true, mode: 0o700 });
90
- const drainSpool = () => {
118
+ const spoolMarks = new Map();
119
+ const spoolPass = (budgetMs) => importSpool({ hub: runner.hub, capture: runner.capture, spoolDir, log, marks: spoolMarks, budgetMs });
120
+ const drainSpool = serial(async () => {
91
121
  try {
92
- const { imported } = importSpool({ hub: runner.hub, capture: runner.capture, spoolDir, log });
122
+ let imported = 0;
123
+ while (!closing) {
124
+ const pass = spoolPass(CHUNK_MS);
125
+ imported += pass.imported;
126
+ if (!pass.more) break;
127
+ await runner.capture.yieldLock();
128
+ }
93
129
  if (imported) { log('spool_imported', { imported }); onWrite(); }
94
- } catch (error) { log('spool_import_failed', { code: error.code || 'error', message: String(error.message).slice(0, 200) }); }
95
- };
96
- drainSpool();
97
- const spoolWatch = watch(spoolDir, () => drainSpool());
130
+ } catch (error) { failed('spool_import_failed', error); }
131
+ });
132
+ try {
133
+ const { imported } = spoolPass(Infinity);
134
+ if (imported) { log('spool_imported', { imported }); onWrite(); }
135
+ } catch (error) { failed('spool_import_failed', error); }
136
+ const spoolWatch = watch(spoolDir, () => loop.tick('spool import', drainSpool));
98
137
  spoolWatch.on('error', () => {});
99
- const spoolScan = setInterval(drainSpool, SPOOL_SCAN_MS);
138
+ const spoolScan = setInterval(() => loop.tick('spool import', drainSpool), SPOOL_SCAN_MS);
100
139
  spoolScan.unref();
140
+ const loopReport = send ? setInterval(() => send({ type: 'loop', loopDelay: loop.view() }), LOOP_REPORT_MS) : null;
141
+ loopReport?.unref();
101
142
  await new Promise((resolve, reject) => {
102
143
  facade.server.once('listening', resolve);
103
144
  facade.server.once('error', reject);
@@ -105,13 +146,14 @@ export async function startRunner({
105
146
  const address = facade.server.address();
106
147
  log('runner_ready', { port: address.port, pid: process.pid });
107
148
  if (send) send({ type: 'ready', port: address.port, pid: process.pid });
108
- let closing = false;
109
149
  const close = () => {
110
150
  if (closing) return;
111
151
  closing = true;
112
152
  clearInterval(pump);
113
153
  clearInterval(sweep);
114
154
  clearInterval(spoolScan);
155
+ clearInterval(loopReport);
156
+ loop.close();
115
157
  spoolWatch.close();
116
158
  facade.server.close();
117
159
  facade.server.closeAllConnections?.();
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { readFile, writeFile } from 'node:fs/promises';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -15,6 +16,18 @@ export const RUNNER_CONFIG_FILE = 'runner.json';
15
16
  const RESTART_MIN_MS = 1_000;
16
17
  const RESTART_MAX_MS = 30_000;
17
18
  const SECRETS_EVERY_MS = 60_000;
19
+ /** What daemon-health.json shows of the runner's manager (state/manager-status.json): its state and what last went wrong. */
20
+ const MANAGER_FIELDS = ['state', 'error', 'messageId', 'lastFailure', 'gaveUp', 'lastReplyAt', 'at'];
21
+
22
+ /** The manager's status for daemon-health.json, or null while it has written none. */
23
+ export function managerHealth(file) {
24
+ let status;
25
+ try { status = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
26
+ if (!status || typeof status !== 'object') return null;
27
+ const out = Object.fromEntries(MANAGER_FIELDS.filter((key) => status[key] != null).map((key) => [key, status[key]]));
28
+ if (status.queue) out.queue = { depth: status.queue.depth, oldestAgeMs: status.queue.oldestAgeMs };
29
+ return out;
30
+ }
18
31
 
19
32
  /** Whether this machine has cut over: the daemon runs the hub (the runner) and serves the local API (design §11). */
20
33
  export const runnerMode = (config) => config?.cloudFirst?.mode === 'runner';
@@ -76,7 +89,8 @@ export class RunnerService {
76
89
 
77
90
  health() {
78
91
  const bridge = this.current?.bridge;
79
- return { ...this.state, ...(bridge ? { runner: bridge.health(), lease: bridge.lease ?? null } : {}) };
92
+ const manager = this.current?.managerStatusFile ? managerHealth(this.current.managerStatusFile) : null;
93
+ return { ...this.state, ...(bridge ? { runner: bridge.health(), lease: bridge.lease ?? null } : {}), ...(manager ? { manager } : {}) };
80
94
  }
81
95
 
82
96
  async #start(config) {
@@ -94,11 +108,13 @@ export class RunnerService {
94
108
  const store = await this.openStore(this.directory);
95
109
  const transport = await this.transportFactory(config);
96
110
  const held = () => transport.auth?.heldSecrets?.() ?? [];
97
- const current = { store, transport, stopping: false };
111
+ const current = { store, transport, stopping: false, managerStatusFile: path.join(hubHome, 'state', 'manager-status.json') };
98
112
  this.current = current;
99
113
  const launch = async () => {
100
114
  const runner = this.fork({ hubHome, dbFile, configFile });
101
115
  runner.child.stderr?.on('data', (chunk) => this.log('runner_stderr', { line: String(chunk).slice(0, 300) }));
116
+ // The runner's event-loop delay (runner/loopMonitor.js), as `runner.loopDelay` in daemon-health.json.
117
+ runner.on('loop', (message) => { if (this.current === current) this.state = { ...this.state, loopDelay: message.loopDelay ?? null }; });
102
118
  const ready = await runner.ready;
103
119
  const hubToken = (await readFile(path.join(hubHome, 'state', 'hub.token'), 'utf8').catch(() => '')).trim() || null;
104
120
  const fileScan = new HubScan({
@@ -0,0 +1,5 @@
1
+ /** An async tick that never runs twice at once: called while it still runs, it returns the run in progress. */
2
+ export function serial(fn) {
3
+ let running = null;
4
+ return () => (running ??= Promise.resolve().then(fn).finally(() => { running = null; }));
5
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, readdirSync } from 'node:fs';
2
+ import { closeSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
5
  const SPOOL_FILE = /^\d{8}\.jsonl$/;
@@ -42,28 +42,61 @@ function completeLines(text) {
42
42
  return end < 0 ? [] : text.slice(0, end).split('\n');
43
43
  }
44
44
 
45
+ /** A spool file from byte `offset` on: the whole file when there is no watermark. */
46
+ function readFrom(file, offset) {
47
+ if (!offset) return readFileSync(file, 'utf8');
48
+ const fd = openSync(file, 'r');
49
+ try {
50
+ const size = fstatSync(fd).size;
51
+ const buffer = Buffer.alloc(Math.max(0, size - offset));
52
+ readSync(fd, buffer, 0, buffer.length, offset);
53
+ return buffer.toString('utf8');
54
+ } finally { closeSync(fd); }
55
+ }
56
+
45
57
  /**
46
58
  * Imports every spool line not imported before, oldest file first, each in its own capture transaction with its
47
59
  * pulse_spool row, so a crash between lines imports the rest exactly once on the next pass.
60
+ *
61
+ * `marks` (a Map the caller keeps) remembers how far each file was read: a file whose size and mtime have not moved
62
+ * is not read, and one that grew is read from where the last pass stopped. A file that shrank or was replaced is read
63
+ * from the start again (its lines are known by their sha256). Once `budgetMs` has passed and the pass has applied a
64
+ * line, it stops before its next line with `more` set, so the caller can yield the event loop and go on from there.
48
65
  */
49
- export function importSpool({ hub, capture, spoolDir, now = Date.now, log = () => {} }) {
66
+ export function importSpool({ hub, capture, spoolDir, now = Date.now, log = () => {}, marks = null, budgetMs = Infinity }) {
50
67
  let files;
51
68
  try {
52
69
  files = readdirSync(spoolDir).filter((f) => SPOOL_FILE.test(f)).sort();
53
70
  } catch (error) {
54
- if (error.code === 'ENOENT') return { imported: 0, refused: 0 };
71
+ if (error.code === 'ENOENT') return { imported: 0, refused: 0, more: false };
55
72
  throw error;
56
73
  }
74
+ const started = performance.now();
57
75
  const seen = hub.db.prepare('SELECT 1 AS found FROM pulse_spool WHERE sha256 = ?');
58
76
  const record = hub.db.prepare('INSERT INTO pulse_spool (sha256, file, line, outcome, imported_at) VALUES (?, ?, ?, ?, ?)');
59
77
  let imported = 0;
60
78
  let refused = 0;
79
+ let applied = 0;
80
+ if (marks) for (const file of marks.keys()) if (!files.includes(file)) marks.delete(file);
61
81
  for (const file of files) {
62
- const lines = completeLines(readFileSync(path.join(spoolDir, file), 'utf8'));
63
- lines.forEach((line, index) => {
64
- if (!line.trim()) return;
82
+ const full = path.join(spoolDir, file);
83
+ const st = marks ? statSync(full) : null;
84
+ let mark = marks?.get(file);
85
+ if (mark && mark.ino === st.ino && mark.size === st.size && mark.mtimeMs === st.mtimeMs) continue;
86
+ if (mark && (mark.ino !== st.ino || st.size < mark.offset)) mark = null;
87
+ const lines = completeLines(readFrom(full, mark?.offset ?? 0));
88
+ let offset = mark?.offset ?? 0;
89
+ let line0 = mark?.lines ?? 0;
90
+ for (const [index, line] of lines.entries()) {
91
+ if (applied && performance.now() - started >= budgetMs) {
92
+ marks?.set(file, { ino: st.ino, size: null, mtimeMs: null, offset, lines: line0 + index });
93
+ return { imported, refused, more: true };
94
+ }
95
+ offset += Buffer.byteLength(line, 'utf8') + 1;
96
+ if (!line.trim()) continue;
65
97
  const sha = createHash('sha256').update(line).digest('hex');
66
- if (seen.get(sha)) return;
98
+ if (seen.get(sha)) continue;
99
+ applied += 1;
67
100
  capture.tx(() => {
68
101
  let outcome;
69
102
  try {
@@ -73,14 +106,16 @@ export function importSpool({ hub, capture, spoolDir, now = Date.now, log = () =
73
106
  } catch {
74
107
  outcome = 'refused: unreadable line';
75
108
  }
76
- record.run(sha, file, index + 1, outcome, now());
109
+ record.run(sha, file, line0 + index + 1, outcome, now());
77
110
  if (outcome === 'applied') imported += 1;
78
111
  else {
79
112
  refused += 1;
80
- log('spool_line_refused', { file, line: index + 1, outcome });
113
+ log('spool_line_refused', { file, line: line0 + index + 1, outcome });
81
114
  }
82
115
  }, { label: 'spool import' });
83
- });
116
+ }
117
+ line0 += lines.length;
118
+ marks?.set(file, { ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, offset, lines: line0 });
84
119
  }
85
- return { imported, refused };
120
+ return { imported, refused, more: false };
86
121
  }