@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.
- package/package.json +3 -2
- 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/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/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 +18 -2
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
|
@@ -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
|
|
85
|
-
try {
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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({
|
package/src/runner/spool.js
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
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))
|
|
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
|
}
|