@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.6
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 +40 -5
- package/src/hub/core/wake.mjs +5 -3
- package/src/hub/workers/collector.mjs +59 -19
- 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/mirrorSource.js +19 -6
- package/src/lib/runnerStore.js +61 -49
- package/src/runner/bridge.js +8 -3
- package/src/runner/busIdentity.js +11 -1
- package/src/runner/capture.js +83 -20
- package/src/runner/channelDiscipline.js +48 -14
- package/src/runner/channelFiles.js +39 -0
- package/src/runner/channelWalk.js +42 -0
- package/src/runner/collectWorker.js +92 -0
- package/src/runner/deliveryStates.js +22 -9
- package/src/runner/directoryCache.js +34 -0
- package/src/runner/facade.js +149 -87
- package/src/runner/fileReads.js +64 -0
- package/src/runner/fileReadsWorker.js +61 -0
- 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
- package/src/runner/transcriptFeed.js +88 -0
package/src/runner/facade.js
CHANGED
|
@@ -23,12 +23,9 @@ import { isBusy } from '../hub/core/ticks.mjs';
|
|
|
23
23
|
import { handleHook } from '../hub/hooks/hub-bridge.mjs';
|
|
24
24
|
import { defaultDrivers, diagnoseClaude } from '../hub/runners/drivers.mjs';
|
|
25
25
|
import { createRunner } from '../hub/workers/runner.mjs';
|
|
26
|
-
import { readTaskBrief } from '../hub/lib/brief.mjs';
|
|
27
26
|
import { decisionPost, parsePosts } from '../hub/lib/channels.mjs';
|
|
28
27
|
import { STEP_IDS, auditPost, movePost, overrideHolds, overrideKey, overrideText, statusRequest, stepHolds, withOverride, workMoved } from '../hub/lib/status.mjs';
|
|
29
|
-
import {
|
|
30
|
-
import { findAgentQuery } from '../hub/lib/transcripts.mjs';
|
|
31
|
-
import { watchTranscripts } from '../hub/lib/transcript-feed.mjs';
|
|
28
|
+
import { channelParticipant, hasChatInbox, participantPost, postToProject } from '../hub/lib/chat.mjs';
|
|
32
29
|
import { HUB, acceptDelivery, keepAwakeStatus, readSettings, validSettings, writeSettings } from '../hub/lib/store.mjs';
|
|
33
30
|
import { createManager } from '../hub/workers/manager.mjs';
|
|
34
31
|
import { NOBODY, aliasesFor } from '../hub/lib/me.mjs';
|
|
@@ -37,8 +34,12 @@ import { projectDeliveries } from '../hub/projections/deliveries.mjs';
|
|
|
37
34
|
import { projectGraph } from '../hub/projections/graph.mjs';
|
|
38
35
|
import { execFileBounded } from '../lib/subprocess.js';
|
|
39
36
|
import { createBusIdentity, routedMessaging } from './busIdentity.js';
|
|
37
|
+
import { channelFileFor as channelFileIn } from './channelFiles.js';
|
|
40
38
|
import { createChannelDiscipline } from './channelDiscipline.js';
|
|
41
39
|
import { createDeliveryStates } from './deliveryStates.js';
|
|
40
|
+
import { createFileReads } from './fileReads.js';
|
|
41
|
+
import { UNMONITORED } from './loopMonitor.js';
|
|
42
|
+
import { serial } from './serial.js';
|
|
42
43
|
import { statusPage } from './statusPage.js';
|
|
43
44
|
|
|
44
45
|
const WORKSPACE = path.resolve(HUB, '..');
|
|
@@ -46,6 +47,8 @@ const CHANNEL_ROOTS = ['AgentPulseApps', 'Google_Cloud'].map((d) => path.join(WO
|
|
|
46
47
|
const HOURS = [1, 3, 6, 12, 24, 72, 168];
|
|
47
48
|
const CACHE_MS = 4000;
|
|
48
49
|
const CHANGE_DEBOUNCE_MS = 600;
|
|
50
|
+
/** A change feed news of transcripts alone starts a new discovery pass at most this often. */
|
|
51
|
+
const TRANSCRIPT_RESCAN_MS = 15e3;
|
|
49
52
|
/** Steady writes (heartbeats every few hundred ms) keep restarting the debounce; a change still goes out this soon. */
|
|
50
53
|
const CHANGE_MAX_WAIT_MS = 2000;
|
|
51
54
|
/** A graph read re-registers the user at most this often: often enough to stay online, rarely enough not to wake the change feed on every read. */
|
|
@@ -64,10 +67,12 @@ const LOCAL_HOST = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
|
|
|
64
67
|
const LOCAL_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/;
|
|
65
68
|
const NO_FRAME = { 'x-frame-options': 'DENY', 'content-security-policy': "frame-ancestors 'none'", 'x-content-type-options': 'nosniff' };
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
/** sendJson for a body already serialized (by the file reads worker): the same headers and bytes. */
|
|
71
|
+
const sendJsonText = (res, text, code = 200, extra = {}) => {
|
|
68
72
|
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', ...NO_FRAME, ...extra });
|
|
69
|
-
res.end(
|
|
73
|
+
res.end(text);
|
|
70
74
|
};
|
|
75
|
+
const sendJson = (res, body, code = 200, extra = {}) => sendJsonText(res, JSON.stringify(body), code, extra);
|
|
71
76
|
|
|
72
77
|
function trustedWrite(req) {
|
|
73
78
|
const origin = req.headers.origin;
|
|
@@ -133,40 +138,11 @@ export function plainCapture(db) {
|
|
|
133
138
|
tx: (fn, { guard = null, label = 'write' } = {}) => withTx(db, () => { const out = fn(); if (guard) guard(); return out; }, label),
|
|
134
139
|
applying: (fn) => fn(),
|
|
135
140
|
pump: () => 0,
|
|
141
|
+
chunks: async (steps, { label = 'write' } = {}) => { withTx(db, () => { for (const step of steps) step(); }, label); return 1; },
|
|
136
142
|
};
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
const
|
|
140
|
-
const briefCache = new Map();
|
|
141
|
-
|
|
142
|
-
export function channelFileFor(key, roots = CHANNEL_ROOTS) {
|
|
143
|
-
const m = String(key || '').match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
|
|
144
|
-
if (!m || m[1].startsWith('.') || m[2].startsWith('.')) return null;
|
|
145
|
-
for (const root of roots) {
|
|
146
|
-
const file = path.join(root, m[1], '.agents', m[2], 'channel.md');
|
|
147
|
-
try {
|
|
148
|
-
const st = fs.lstatSync(file);
|
|
149
|
-
if (!st.isFile()) continue;
|
|
150
|
-
const realRoot = fs.realpathSync(root);
|
|
151
|
-
if (fs.realpathSync(file) !== path.join(realRoot, m[1], '.agents', m[2], 'channel.md')) continue;
|
|
152
|
-
return file;
|
|
153
|
-
} catch { /* not here */ }
|
|
154
|
-
}
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function taskBriefFor(key, roots) {
|
|
159
|
-
const hit = briefCache.get(key);
|
|
160
|
-
if (hit && Date.now() - hit.at < BRIEF_CACHE_MS) return hit.body;
|
|
161
|
-
const file = channelFileFor(key, roots);
|
|
162
|
-
if (!file) return null;
|
|
163
|
-
const fromFiles = readTaskBrief(file);
|
|
164
|
-
const thin = !fromFiles || !fromFiles.sections.some((s) => s.body && s.body.trim());
|
|
165
|
-
const body = thin ? (readTaskBrief(file, { query: findAgentQuery(key) }) || fromFiles || { title: null, source: null, sections: [] }) : fromFiles;
|
|
166
|
-
if (briefCache.size > 200) briefCache.clear();
|
|
167
|
-
briefCache.set(key, { at: Date.now(), body });
|
|
168
|
-
return body;
|
|
169
|
-
}
|
|
145
|
+
export const channelFileFor = (key, roots = CHANNEL_ROOTS) => channelFileIn(key, roots);
|
|
170
146
|
|
|
171
147
|
/** How many posts a channel holds now: the position of the one just appended. */
|
|
172
148
|
const postsIn = (file) => parsePosts(fs.readFileSync(file, 'utf8'), Date.now()).length;
|
|
@@ -182,14 +158,15 @@ export function appendNoFollow(file, text) {
|
|
|
182
158
|
} finally { fs.closeSync(fd); }
|
|
183
159
|
}
|
|
184
160
|
|
|
185
|
-
function collectWorker() {
|
|
186
|
-
const worker = new Worker(new URL('
|
|
161
|
+
function collectWorker({ onDiscovered = () => {} } = {}) {
|
|
162
|
+
const worker = new Worker(new URL('./collectWorker.js', import.meta.url));
|
|
187
163
|
const waiting = new Map();
|
|
188
164
|
let seq = 0;
|
|
189
|
-
worker.on('message', ({ id,
|
|
165
|
+
worker.on('message', ({ type, id, text, error }) => {
|
|
166
|
+
if (type === 'discovered') { onDiscovered(); return; }
|
|
190
167
|
const w = waiting.get(id);
|
|
191
168
|
waiting.delete(id);
|
|
192
|
-
if (w) { if (error) w.reject(new Error(error)); else w.resolve(
|
|
169
|
+
if (w) { if (error) w.reject(new Error(error)); else w.resolve(text); }
|
|
193
170
|
});
|
|
194
171
|
worker.on('error', (e) => { console.error(`collector crashed: ${e && e.stack ? e.stack : e}`); process.exit(1); });
|
|
195
172
|
return (hours, managerView, aliases, { discoveryEpoch } = {}) => {
|
|
@@ -200,12 +177,23 @@ function collectWorker() {
|
|
|
200
177
|
};
|
|
201
178
|
}
|
|
202
179
|
|
|
203
|
-
function collector(run) {
|
|
180
|
+
function collector(run, { rescanMs = TRANSCRIPT_RESCAN_MS } = {}) {
|
|
204
181
|
const cache = new Map();
|
|
205
182
|
/** Bumped by clear() and reproject(): a collect started before a change must not cache its older body. */
|
|
206
183
|
let generation = 0;
|
|
207
184
|
/** Bumped by clear() only: the collector may reuse a discovery pass from the same epoch (workers/collector.mjs). */
|
|
208
185
|
let discoveryEpoch = 0;
|
|
186
|
+
let epochAt = -Infinity;
|
|
187
|
+
let rescan = null;
|
|
188
|
+
const clear = () => {
|
|
189
|
+
clearTimeout(rescan);
|
|
190
|
+
rescan = null;
|
|
191
|
+
generation++;
|
|
192
|
+
discoveryEpoch++;
|
|
193
|
+
epochAt = Date.now();
|
|
194
|
+
cache.clear();
|
|
195
|
+
};
|
|
196
|
+
const reproject = () => { generation++; cache.clear(); };
|
|
209
197
|
return {
|
|
210
198
|
graph(hours, managerView, aliases = []) {
|
|
211
199
|
const hit = cache.get(hours);
|
|
@@ -218,9 +206,20 @@ function collector(run) {
|
|
|
218
206
|
cache.set(hours, { ...(hit || {}), running });
|
|
219
207
|
return running;
|
|
220
208
|
},
|
|
221
|
-
clear
|
|
209
|
+
clear,
|
|
222
210
|
/** Presence moved and nothing else: refresh the bodies without a new discovery pass. */
|
|
223
|
-
reproject
|
|
211
|
+
reproject,
|
|
212
|
+
/**
|
|
213
|
+
* Only Cursor transcripts moved, which they do every second while agents work: a new discovery pass at most every
|
|
214
|
+
* `rescanMs`, the last change still picked up by a pass at the end of the wait; reads meanwhile reproject.
|
|
215
|
+
*/
|
|
216
|
+
transcriptsMoved() {
|
|
217
|
+
const wait = epochAt + rescanMs - Date.now();
|
|
218
|
+
if (wait <= 0) return clear();
|
|
219
|
+
reproject();
|
|
220
|
+
if (!rescan) { rescan = setTimeout(clear, wait); rescan.unref(); }
|
|
221
|
+
return undefined;
|
|
222
|
+
},
|
|
224
223
|
};
|
|
225
224
|
}
|
|
226
225
|
|
|
@@ -231,7 +230,7 @@ function watchRecursive(dir, onFile, onError) {
|
|
|
231
230
|
return fs.watch(dir, { recursive: true }, (e, f) => { if (f) onFile(String(f)); }).on('error', onError);
|
|
232
231
|
}
|
|
233
232
|
|
|
234
|
-
function changeFeed(onChange, { stateDir, dbFile, channelRoots,
|
|
233
|
+
function changeFeed(onChange, { stateDir, dbFile, channelRoots, hubChanged, watchDir }) {
|
|
235
234
|
const clients = new Set();
|
|
236
235
|
const pending = new Set();
|
|
237
236
|
let timer = null;
|
|
@@ -264,7 +263,6 @@ function changeFeed(onChange, { stateDir, dbFile, channelRoots, transcriptsDir,
|
|
|
264
263
|
for (const root of channelRoots) watch(root, (f) => (/\.agents[\\/][^\\/]+[\\/](channel|brief)\.md$/.test(f) ? 'channels' : null));
|
|
265
264
|
watch(stateDir, (f) => (/(events\.jsonl|requests|chat[\\/]|inbox[\\/]|bus[\\/]|accepted\.json|personas\.json)/.test(f) ? 'hub' : null));
|
|
266
265
|
if (dbFile) watch(path.dirname(dbFile), (f) => (f.startsWith(dbName) ? DB_WRITE : null));
|
|
267
|
-
if (transcriptsDir) watchTranscripts(transcriptsDir, () => notify('agents'));
|
|
268
266
|
const keepAlive = setInterval(() => { for (const res of clients) res.write(': keep-alive\n\n'); }, 25e3);
|
|
269
267
|
keepAlive.unref();
|
|
270
268
|
return {
|
|
@@ -292,23 +290,53 @@ const hasTable = (db, name) => Boolean(db.prepare("SELECT 1 AS found FROM sqlite
|
|
|
292
290
|
/** Without a cloud lease (tests, a bare hub) this runner is the only one, as the local hub was. */
|
|
293
291
|
const SOLE_LEASE = Object.freeze({ held: () => true, view: () => ({ holder: 'local', here: true }), onChange: () => {} });
|
|
294
292
|
|
|
293
|
+
/**
|
|
294
|
+
* A lease that lapses here only while this process stalled (a long runner.db transaction holds the event loop) comes back
|
|
295
|
+
* as soon as the queued report is read. The manager pauses meanwhile and is stopped, killing its runs, only past this.
|
|
296
|
+
*/
|
|
297
|
+
export const MANAGER_STOP_GRACE_MS = 60e3;
|
|
298
|
+
const MANAGER_LOG_MAX_BYTES = 1024 * 1024;
|
|
299
|
+
|
|
300
|
+
/** state/manager.log: what the manager did wrong, with its time, also on stderr; one older file is kept. */
|
|
301
|
+
export function managerLog(file, { echo = console.error } = {}) {
|
|
302
|
+
return (line) => {
|
|
303
|
+
echo(line);
|
|
304
|
+
try {
|
|
305
|
+
if ((fs.statSync(file, { throwIfNoEntry: false })?.size || 0) > MANAGER_LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`);
|
|
306
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
307
|
+
} catch { /* stderr has it */ }
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
295
311
|
export function serve({
|
|
296
312
|
hub, capture = plainCapture(hub.db), port = 8890, listenHost = '127.0.0.1', stateDir = path.join(HUB, 'state'), dbFile = null,
|
|
297
313
|
enableCollector = true, enableManager = true, collect = null, channelRoots = CHANNEL_ROOTS,
|
|
298
314
|
transcriptsDir = path.join(os.homedir(), '.cursor', 'projects'), watchDir = watchRecursive, identity = null,
|
|
299
315
|
lease = SOLE_LEASE, pulseUrl = null, health = () => ({ state: 'online' }), metrics = () => ({}), drivers = null,
|
|
300
316
|
hookBudgetMs = HOOK_BUDGET_MS, writeBudgetMs = WRITE_BUDGET_MS, onWrite = () => {}, managerOptions = {}, workspaceRoot = WORKSPACE,
|
|
301
|
-
discipline: disciplineOptions = {},
|
|
317
|
+
discipline: disciplineOptions = {}, loop = UNMONITORED,
|
|
302
318
|
} = {}) {
|
|
303
319
|
const me = () => {
|
|
304
320
|
const m = identity ? identity.current() : NOBODY;
|
|
305
321
|
return m === NOBODY ? NOBODY : { ...m, aliases: aliasesFor(m, personaUsernames()) };
|
|
306
322
|
};
|
|
307
|
-
|
|
323
|
+
// A discovery pass the worker saved after answering: later reads project its rows, and change-feed clients read again.
|
|
324
|
+
const graphs = enableCollector ? collector(collect || collectWorker({ onDiscovered: () => { graphs.reproject(); feed.notify('presence'); } })) : { graph: async () => ({ nodes: [], links: [], events: [], deliveries: [], agents: [], totals: {} }), clear() {}, reproject() {}, transcriptsMoved() {} };
|
|
308
325
|
const caches = { clear: () => graphs.clear() };
|
|
326
|
+
const onFeedChange = (kinds) => {
|
|
327
|
+
if (kinds.every((k) => k === 'presence')) return graphs.reproject();
|
|
328
|
+
if (kinds.every((k) => k === 'presence' || k === 'agents')) return graphs.transcriptsMoved();
|
|
329
|
+
return graphs.clear();
|
|
330
|
+
};
|
|
309
331
|
const feed = enableCollector
|
|
310
|
-
? changeFeed(
|
|
332
|
+
? changeFeed(onFeedChange, { stateDir, dbFile, channelRoots, hubChanged: changeTracker(hub.db), watchDir })
|
|
311
333
|
: { attach(req, res) { res.writeHead(200); res.end(); }, dbWrite() {}, notify() {}, close() {} };
|
|
334
|
+
// Channel, brief and transcript files are read and parsed off the event loop (fileReadsWorker.js).
|
|
335
|
+
const fileReads = createFileReads({ roots: channelRoots, transcriptsDir: enableCollector ? transcriptsDir : null, onTranscripts: () => feed.notify('agents') });
|
|
336
|
+
/** Answers a route from the file reads worker; `fallback` answers what the worker leaves to the hub (null). */
|
|
337
|
+
const fromFiles = (res, op, args, fallback = null) => fileReads.read(op, args, { text: true })
|
|
338
|
+
.then((answer) => (answer ? sendJsonText(res, ...answer) : fallback()))
|
|
339
|
+
.catch((e) => { console.error(`file read failed: ${op}: ${e.message}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); });
|
|
312
340
|
if (identity) {
|
|
313
341
|
identity.listen(() => { graphs.clear(); feed.notify('hub'); });
|
|
314
342
|
identity.refresh();
|
|
@@ -320,8 +348,9 @@ export function serve({
|
|
|
320
348
|
channelRoots, workspaceRoot, append: appendNoFollow, ...thresholds,
|
|
321
349
|
publish: (message) => hub.messaging.publish(message, hub.registry.directory()),
|
|
322
350
|
});
|
|
351
|
+
const logManager = managerOptions.log || managerLog(path.join(stateDir, 'manager.log'));
|
|
323
352
|
const manager = enableManager
|
|
324
|
-
? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions })
|
|
353
|
+
? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions, log: logManager })
|
|
325
354
|
: { start() {}, stop() {}, view() { return { state: 'offline', online: false, pending: 0 }; } };
|
|
326
355
|
/** The manager's status, with the DONE and STATUS posts it has read (F2). */
|
|
327
356
|
const managerView = () => ({ ...manager.view(), reports: delivery.reports() });
|
|
@@ -334,30 +363,65 @@ export function serve({
|
|
|
334
363
|
}
|
|
335
364
|
if (serverLease.error) console.error(`another hub server holds the lease (${serverLease.holder}); taking over`);
|
|
336
365
|
const timers = [];
|
|
337
|
-
const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref(); timers.push(t); };
|
|
366
|
+
const every = (ms, fn, name = 'timer') => { const t = setInterval(() => loop.tick(name, fn), ms); t.unref(); timers.push(t); };
|
|
338
367
|
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}`); } });
|
|
368
|
+
every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } }, 'server lease');
|
|
340
369
|
}
|
|
341
370
|
const releaseLease = () => { if (serverLease.token) { try { hub.leases.release('server', serverLease.token); } catch { /* closing */ } } };
|
|
342
371
|
const wrote = () => { feed.dbWrite(); onWrite(); };
|
|
343
372
|
/** The manager, the wake sweep and the scheduled nudge belong to the runner holding the user's manager lease (Q9). */
|
|
344
373
|
const leaseHeld = () => lease.held();
|
|
374
|
+
const deliverySweep = serial(async () => {
|
|
375
|
+
try {
|
|
376
|
+
const settled = capture.tx(() => delivery.settleNudges(), { label: 'delivery sweep' });
|
|
377
|
+
const bounced = [];
|
|
378
|
+
await capture.chunks(delivery.bounceSteps({ sent: bounced }), { label: 'delivery bounce' });
|
|
379
|
+
if (settled + bounced.length) { caches.clear(); wrote(); }
|
|
380
|
+
} catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
|
|
381
|
+
});
|
|
382
|
+
const staleSweep = serial(async () => {
|
|
383
|
+
try {
|
|
384
|
+
const seen = await discipline.scan();
|
|
385
|
+
if (leaseHeld() && capture.tx(() => discipline.sweep({ seen }).length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
|
|
386
|
+
} catch (e) { console.error(`stale sweep failed: ${e.message}`); }
|
|
387
|
+
});
|
|
345
388
|
if (enableManager) {
|
|
346
389
|
if (leaseHeld()) manager.start();
|
|
347
|
-
|
|
390
|
+
let lapsedAt = null;
|
|
391
|
+
let stopTimer = null;
|
|
392
|
+
lease.onChange((held) => {
|
|
393
|
+
const { fence } = lease.view();
|
|
394
|
+
if (held) {
|
|
395
|
+
clearTimeout(stopTimer);
|
|
396
|
+
stopTimer = null;
|
|
397
|
+
if (lapsedAt) {
|
|
398
|
+
logManager(`manager lease held again (fence ${fence})${fence !== lapsedAt.fence ? `; it moved from fence ${lapsedAt.fence}, so runs from before are stopped` : ''}`);
|
|
399
|
+
if (fence !== lapsedAt.fence) manager.stop();
|
|
400
|
+
}
|
|
401
|
+
lapsedAt = null;
|
|
402
|
+
manager.start();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (lapsedAt) return;
|
|
406
|
+
lapsedAt = { fence };
|
|
407
|
+
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`);
|
|
408
|
+
stopTimer = setTimeout(() => {
|
|
409
|
+
stopTimer = null;
|
|
410
|
+
if (lease.held()) return;
|
|
411
|
+
logManager('manager lease still not held: the manager is stopped');
|
|
412
|
+
manager.stop();
|
|
413
|
+
}, MANAGER_STOP_GRACE_MS);
|
|
414
|
+
stopTimer.unref();
|
|
415
|
+
});
|
|
348
416
|
every(60e3, () => {
|
|
349
417
|
if (!leaseHeld()) return;
|
|
350
418
|
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
|
-
});
|
|
419
|
+
return deliverySweep();
|
|
420
|
+
}, 'wake and delivery sweep');
|
|
355
421
|
every(disciplineSweepMs, () => {
|
|
356
422
|
if (!leaseHeld()) return;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
} catch (e) { console.error(`stale sweep failed: ${e.message}`); }
|
|
360
|
-
});
|
|
423
|
+
return staleSweep();
|
|
424
|
+
}, 'stale sweep');
|
|
361
425
|
}
|
|
362
426
|
const runner = createRunner({ hub, drivers: drivers || defaultDrivers(), workspaceRoot: WORKSPACE, onChange: () => { graphs.clear(); wrote(); } });
|
|
363
427
|
if (enableManager) runner.begin();
|
|
@@ -369,7 +433,7 @@ export function serve({
|
|
|
369
433
|
});
|
|
370
434
|
if (capture.tx(nudge, { label: 'scheduled nudge' })) { caches.clear(); wrote(); }
|
|
371
435
|
} catch (e) { console.error(`scheduled nudge failed: ${e.message}`); }
|
|
372
|
-
});
|
|
436
|
+
}, 'scheduled nudge');
|
|
373
437
|
|
|
374
438
|
const registeredAt = hasTable(hub.db, 'pulse_registered')
|
|
375
439
|
? hub.db.prepare('INSERT INTO pulse_registered (id, at) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET at = excluded.at')
|
|
@@ -469,7 +533,12 @@ export function serve({
|
|
|
469
533
|
capture.tx(() => hub.registry.register({ id: 'user', role: 'USER', name: 'You', topics: ['user'], node_type: 'user' }), { label: 'graph user' });
|
|
470
534
|
wrote();
|
|
471
535
|
}
|
|
472
|
-
return graphs.graph(hours, managerView(), me().aliases).then((body) =>
|
|
536
|
+
return graphs.graph(hours, managerView(), me().aliases).then((body) => {
|
|
537
|
+
const settings = { ...settingsOf(hub), me: me() };
|
|
538
|
+
// The collect worker answers JSON text of an object without `settings`; appended last, the bytes are those of the spread.
|
|
539
|
+
if (typeof body === 'string' && body.length > 2 && body.endsWith('}')) return sendJsonText(res, `${body.slice(0, -1)},"settings":${JSON.stringify(settings)}}`);
|
|
540
|
+
return sendJson(res, { ...(typeof body === 'string' ? JSON.parse(body) : body), settings });
|
|
541
|
+
})
|
|
473
542
|
.catch((e) => { console.error(`collect failed: ${e.message}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); });
|
|
474
543
|
}
|
|
475
544
|
if (p === '/api/settings' && req.method === 'GET') return sendJson(res, { ...settingsOf(hub), me: me() });
|
|
@@ -495,8 +564,7 @@ export function serve({
|
|
|
495
564
|
if (p === '/api/task/brief' && req.method === 'GET') {
|
|
496
565
|
const key = String(url.searchParams.get('key') || '');
|
|
497
566
|
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key)) return sendJson(res, { error: 'no such channel' }, 404);
|
|
498
|
-
|
|
499
|
-
return brief ? sendJson(res, brief) : sendJson(res, { error: 'no such channel' }, 404);
|
|
567
|
+
return fromFiles(res, 'brief', { key });
|
|
500
568
|
}
|
|
501
569
|
if (p === '/api/runs' && req.method === 'GET') return sendJson(res, { runs: runner.list(), drivers: runner.drivers() });
|
|
502
570
|
if (p === '/api/runs/diagnose' && req.method === 'GET') {
|
|
@@ -505,12 +573,10 @@ export function serve({
|
|
|
505
573
|
}
|
|
506
574
|
if (p === '/api/task/ask' && req.method === 'GET') {
|
|
507
575
|
const key = String(url.searchParams.get('key') || '');
|
|
508
|
-
|
|
509
|
-
if (!file) return sendJson(res, { error: 'no such channel' }, 404);
|
|
576
|
+
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key)) return sendJson(res, { error: 'no such channel' }, 404);
|
|
510
577
|
const ref = String(url.searchParams.get('ref') || '');
|
|
511
578
|
const at = Number(url.searchParams.get('at')) || null;
|
|
512
|
-
|
|
513
|
-
return detail ? sendJson(res, detail) : sendJson(res, { error: 'no open ask in this channel' }, 404);
|
|
579
|
+
return fromFiles(res, 'ask', { key, ref: /^[A-Z]?\d+$/.test(ref) ? ref : null, at, aliases: me().aliases });
|
|
514
580
|
}
|
|
515
581
|
if (p === '/api/inbox' && req.method === 'GET') {
|
|
516
582
|
const since = Math.max(Date.now() - 24 * 3600e3, Number(url.searchParams.get('since')) || 0);
|
|
@@ -520,29 +586,16 @@ export function serve({
|
|
|
520
586
|
return sendJson(res, { messages, at: Date.now() });
|
|
521
587
|
}
|
|
522
588
|
if (p === '/api/chat/channels' && req.method === 'GET') {
|
|
523
|
-
|
|
524
|
-
|
|
589
|
+
return fileReads.read('channels', { sinceMs: Date.now() - CHAT_CHANNELS_MS })
|
|
590
|
+
.then(([listed]) => sendJson(res, { ...listed, dms: hub.conversations.dmPartners() }))
|
|
591
|
+
.catch((e) => { console.error(`file read failed: channels: ${e.message}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); });
|
|
525
592
|
}
|
|
526
593
|
if (p === '/api/chat/messages' && req.method === 'GET') {
|
|
527
594
|
const ch = String(url.searchParams.get('ch') || '');
|
|
528
|
-
|
|
529
|
-
const file = channelFileFor(ch.slice(5), channelRoots);
|
|
530
|
-
return file ? sendJson(res, { messages: taskMessages(file) }) : sendJson(res, { error: 'no such channel' }, 404);
|
|
531
|
-
}
|
|
532
|
-
if (ch.startsWith('project:')) {
|
|
533
|
-
const messages = projectMessages(channelRoots, ch.slice(8), Date.now() - CHAT_HISTORY_MS);
|
|
534
|
-
return messages ? sendJson(res, { messages }) : sendJson(res, { error: 'no such channel' }, 404);
|
|
535
|
-
}
|
|
536
|
-
if (ch.startsWith('dm:')) {
|
|
537
|
-
const part = channelParticipant(ch.slice(3));
|
|
538
|
-
if (part) {
|
|
539
|
-
const file = channelFileFor(part.key, channelRoots);
|
|
540
|
-
return file ? sendJson(res, { messages: participantThread(file, part.role) }) : sendJson(res, { error: 'no such person' }, 404);
|
|
541
|
-
}
|
|
595
|
+
return fromFiles(res, 'messages', { ch, historyMs: CHAT_HISTORY_MS }, () => {
|
|
542
596
|
const dm = hub.conversations.thread(ch.slice(3));
|
|
543
597
|
return dm ? sendJson(res, dm) : sendJson(res, { error: 'no such person' }, 404);
|
|
544
|
-
}
|
|
545
|
-
return sendJson(res, { error: 'no such channel' }, 404);
|
|
598
|
+
});
|
|
546
599
|
}
|
|
547
600
|
if (p === '/api/deliveries' && req.method === 'GET') return sendJson(res, projectDeliveries(hub));
|
|
548
601
|
if (p === '/api/legacy/map' && req.method === 'GET') {
|
|
@@ -579,9 +632,17 @@ export function serve({
|
|
|
579
632
|
const pollStarted = Date.now();
|
|
580
633
|
let beat = pollStarted;
|
|
581
634
|
const inbox = routedMessaging(hub.messaging, box.addresses);
|
|
635
|
+
const claimsFor = box.addresses.length > 1 && box.addresses[0] === bound.principal ? box.addresses : [bound.principal];
|
|
582
636
|
// Re-polls run from a timer, outside readBody's try/catch: a busy database waits for the next poll, anything else answers 503.
|
|
583
637
|
const poll = () => {
|
|
584
638
|
if (!connected(req, res)) return undefined;
|
|
639
|
+
let idle = false;
|
|
640
|
+
try { idle = !(body.heartbeat === true && Date.now() - beat >= LISTEN_HEARTBEAT_MS) && !bus.claimable(claimsFor); } catch { /* the claim below says why */ }
|
|
641
|
+
if (idle) {
|
|
642
|
+
if (Date.now() - pollStarted >= timeout) return sendJson(res, { messages: [], at: Date.now() });
|
|
643
|
+
setTimeout(poll, 250).unref();
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
585
646
|
let messages = [];
|
|
586
647
|
try {
|
|
587
648
|
messages = commit(req, res, pollStarted, Infinity, () => {
|
|
@@ -867,6 +928,7 @@ export function serve({
|
|
|
867
928
|
});
|
|
868
929
|
server.on('close', () => {
|
|
869
930
|
feed.close();
|
|
931
|
+
fileReads.close();
|
|
870
932
|
for (const t of timers) clearInterval(t);
|
|
871
933
|
manager.stop();
|
|
872
934
|
releaseLease();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
|
|
3
|
+
/** A worker that died is started again after this long, so a crash can't turn into a busy loop. */
|
|
4
|
+
const RESTART_MS = 1000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The facade's side of fileReadsWorker.js. `read(op, args)` resolves to the route's [body, status], or null for a
|
|
8
|
+
* `dm:` that is the hub's own thread; with `{ text: true }` the body comes as its JSON text. The worker also runs the transcript feed when given `transcriptsDir`, calling
|
|
9
|
+
* `onTranscripts` once per pass that saw a transcript move. A worker that dies fails the reads it had in flight and is
|
|
10
|
+
* started again; `close()` stops it.
|
|
11
|
+
*/
|
|
12
|
+
export function createFileReads({ roots, transcriptsDir = null, onTranscripts = () => {} }) {
|
|
13
|
+
const waiting = new Map();
|
|
14
|
+
let seq = 0;
|
|
15
|
+
let worker = null;
|
|
16
|
+
let closed = false;
|
|
17
|
+
let restart = null;
|
|
18
|
+
|
|
19
|
+
const start = () => {
|
|
20
|
+
const w = new Worker(new URL('./fileReadsWorker.js', import.meta.url), { workerData: { roots, transcriptsDir } });
|
|
21
|
+
w.unref();
|
|
22
|
+
w.on('message', (m) => {
|
|
23
|
+
if (m.type === 'agents') { onTranscripts(); return; }
|
|
24
|
+
const pending = waiting.get(m.id);
|
|
25
|
+
if (!pending) return;
|
|
26
|
+
waiting.delete(m.id);
|
|
27
|
+
if (m.error) pending.reject(new Error(m.error)); else pending.resolve(m.answer);
|
|
28
|
+
});
|
|
29
|
+
const failed = (error) => {
|
|
30
|
+
if (worker !== w) return;
|
|
31
|
+
worker = null;
|
|
32
|
+
for (const pending of waiting.values()) pending.reject(error);
|
|
33
|
+
waiting.clear();
|
|
34
|
+
if (transcriptsDir && !closed && !restart) {
|
|
35
|
+
restart = setTimeout(() => { restart = null; if (!closed && !worker) worker = start(); }, RESTART_MS);
|
|
36
|
+
restart.unref();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
w.on('error', (e) => { console.error(`file reads worker failed: ${e && e.stack ? e.stack : e}`); failed(e); });
|
|
40
|
+
w.on('exit', (code) => failed(new Error(`the file reads worker exited (${code})`)));
|
|
41
|
+
return w;
|
|
42
|
+
};
|
|
43
|
+
if (transcriptsDir) worker = start();
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
read(op, args, { text = false } = {}) {
|
|
47
|
+
if (closed) return Promise.reject(new Error('the file reads worker is closed'));
|
|
48
|
+
worker ??= start();
|
|
49
|
+
const id = ++seq;
|
|
50
|
+
const answer = new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
|
|
51
|
+
worker.postMessage({ id, op, args, text });
|
|
52
|
+
return answer;
|
|
53
|
+
},
|
|
54
|
+
close() {
|
|
55
|
+
closed = true;
|
|
56
|
+
clearTimeout(restart);
|
|
57
|
+
const w = worker;
|
|
58
|
+
worker = null;
|
|
59
|
+
for (const pending of waiting.values()) pending.reject(new Error('the file reads worker is closed'));
|
|
60
|
+
waiting.clear();
|
|
61
|
+
if (w) w.terminate();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The facade's file reads, off the runner's event loop: channel and brief files and Cursor transcripts are read and
|
|
2
|
+
// parsed here with the same hub functions the routes called inline, so each answer is the same, byte for byte.
|
|
3
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
4
|
+
import { askDetail, channelParticipant, listChannels, participantThread, projectMessages, taskMessages } from '../hub/lib/chat.mjs';
|
|
5
|
+
import { channelFileFor, taskBriefFor } from './channelFiles.js';
|
|
6
|
+
import { watchTranscripts } from './transcriptFeed.js';
|
|
7
|
+
|
|
8
|
+
const { roots, transcriptsDir } = workerData;
|
|
9
|
+
const NOT_FOUND = (error) => [{ error }, 404];
|
|
10
|
+
|
|
11
|
+
/** Each op answers [body, status]; a dm: that isn't a channel participant is the hub's own (null: the caller reads it). */
|
|
12
|
+
const OPS = {
|
|
13
|
+
brief: ({ key }) => {
|
|
14
|
+
const brief = taskBriefFor(key, roots);
|
|
15
|
+
return brief ? [brief, 200] : NOT_FOUND('no such channel');
|
|
16
|
+
},
|
|
17
|
+
ask: ({ key, ref, at, aliases }) => {
|
|
18
|
+
const file = channelFileFor(key, roots);
|
|
19
|
+
if (!file) return NOT_FOUND('no such channel');
|
|
20
|
+
const detail = askDetail(file, { ref, at, aliases });
|
|
21
|
+
return detail ? [detail, 200] : NOT_FOUND('no open ask in this channel');
|
|
22
|
+
},
|
|
23
|
+
channels: ({ sinceMs }) => [listChannels(roots, sinceMs), 200],
|
|
24
|
+
messages: ({ ch, historyMs }) => {
|
|
25
|
+
if (ch.startsWith('task:')) {
|
|
26
|
+
const file = channelFileFor(ch.slice(5), roots);
|
|
27
|
+
return file ? [{ messages: taskMessages(file) }, 200] : NOT_FOUND('no such channel');
|
|
28
|
+
}
|
|
29
|
+
if (ch.startsWith('project:')) {
|
|
30
|
+
const messages = projectMessages(roots, ch.slice(8), Date.now() - historyMs);
|
|
31
|
+
return messages ? [{ messages }, 200] : NOT_FOUND('no such channel');
|
|
32
|
+
}
|
|
33
|
+
if (ch.startsWith('dm:')) {
|
|
34
|
+
const part = channelParticipant(ch.slice(3));
|
|
35
|
+
if (!part) return null;
|
|
36
|
+
const file = channelFileFor(part.key, roots);
|
|
37
|
+
return file ? [{ messages: participantThread(file, part.role) }, 200] : NOT_FOUND('no such person');
|
|
38
|
+
}
|
|
39
|
+
return NOT_FOUND('no such channel');
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
parentPort.on('message', ({ id, op, args, text = false }) => {
|
|
44
|
+
try {
|
|
45
|
+
const answer = OPS[op](args);
|
|
46
|
+
// As JSON text the answer crosses to the main thread as one string, not an object graph it must clone and stringify.
|
|
47
|
+
parentPort.postMessage({ id, answer: text && answer ? [JSON.stringify(answer[0]), answer[1]] : answer });
|
|
48
|
+
} catch (e) {
|
|
49
|
+
parentPort.postMessage({ id, error: String((e && e.stack) || e) });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (transcriptsDir) {
|
|
54
|
+
// One message per pass of the feed, however many transcript files it saw move.
|
|
55
|
+
let queued = false;
|
|
56
|
+
watchTranscripts(transcriptsDir, () => {
|
|
57
|
+
if (queued) return;
|
|
58
|
+
queued = true;
|
|
59
|
+
queueMicrotask(() => { queued = false; parentPort.postMessage({ type: 'agents' }); });
|
|
60
|
+
});
|
|
61
|
+
}
|