@adhdev/daemon-core 0.9.82-rc.274 → 0.9.82-rc.275
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/dist/index.js +100 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +100 -15
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-coordinator.d.ts +1 -0
- package/dist/mesh/mesh-events-pending.d.ts +14 -2
- package/dist/mesh/mesh-runtime-store.d.ts +13 -1
- package/package.json +2 -2
- package/src/mesh/mesh-events-coordinator.ts +1 -1
- package/src/mesh/mesh-events-pending.ts +84 -6
- package/src/mesh/mesh-reconcile-loop.ts +34 -14
- package/src/mesh/mesh-runtime-store.ts +33 -8
|
@@ -28,6 +28,7 @@ export interface MeshQueueTriggerResult {
|
|
|
28
28
|
}
|
|
29
29
|
export declare function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<MeshQueueTriggerResult>;
|
|
30
30
|
export declare function isMeshCoordinatorEvent(eventName: unknown): eventName is string;
|
|
31
|
+
export declare const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string>;
|
|
31
32
|
export declare function shouldForceInjectMeshEvent(eventName: unknown): boolean;
|
|
32
33
|
export declare function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>): {
|
|
33
34
|
success: boolean;
|
|
@@ -20,8 +20,20 @@ export declare function readRefineJobId(event: {
|
|
|
20
20
|
export declare function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string;
|
|
21
21
|
export declare function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean;
|
|
22
22
|
export declare function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean;
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Drain and return pending coordinator events for meshId, removing the drained
|
|
25
|
+
* ones from both the SQLite inbox and the JSONL legacy file.
|
|
26
|
+
*
|
|
27
|
+
* When `opts.onlyEvents` is supplied, ONLY events whose name is in that set are
|
|
28
|
+
* drained; every other event stays queued (undrained in SQLite, rewritten back to
|
|
29
|
+
* the JSONL file). The reconcile loop uses this to force-drain terminal/force-inject
|
|
30
|
+
* events into a *generating* coordinator while leaving non-force progress events for
|
|
31
|
+
* the coordinator's next idle transition. The atomic SQLite drained=1 marking and the
|
|
32
|
+
* atomic JSONL rename keep force-drain and a concurrent full drain from double-consuming.
|
|
33
|
+
*/
|
|
34
|
+
export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string, opts?: {
|
|
35
|
+
onlyEvents?: ReadonlySet<string>;
|
|
36
|
+
}): PendingMeshCoordinatorEvent[];
|
|
25
37
|
/** Peek at pending coordinator events without draining (non-destructive). */
|
|
26
38
|
export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): readonly PendingMeshCoordinatorEvent[];
|
|
27
39
|
/**
|
|
@@ -288,7 +288,19 @@ export declare class MeshRuntimeStore {
|
|
|
288
288
|
fingerprint?: string | null;
|
|
289
289
|
queuedAt: number;
|
|
290
290
|
}): boolean;
|
|
291
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Drain undrained pending events for a mesh, atomically marking them drained.
|
|
293
|
+
* When `opts.onlyEvents` is supplied, ONLY rows whose `event` is in that set are
|
|
294
|
+
* drained — the rest stay queued (drained=0) for a later drain. This is how the
|
|
295
|
+
* reconcile loop force-drains terminal/force-inject events into a *generating*
|
|
296
|
+
* coordinator while leaving non-force progress events for the coordinator's next
|
|
297
|
+
* idle transition. Filtering happens inside the same transaction as the
|
|
298
|
+
* drained=1 marking, so force-drain + a concurrent full drain can never both
|
|
299
|
+
* consume the same row.
|
|
300
|
+
*/
|
|
301
|
+
drainPendingEvents(meshId: string, coordinatorDaemonId?: string | null, opts?: {
|
|
302
|
+
onlyEvents?: ReadonlySet<string>;
|
|
303
|
+
}): Array<{
|
|
292
304
|
id: string;
|
|
293
305
|
event: string;
|
|
294
306
|
payload: unknown;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.275",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.275",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -975,7 +975,7 @@ export function isMeshCoordinatorEvent(eventName: unknown): eventName is string
|
|
|
975
975
|
// only flushed on the coordinator's OWN idle transition. That transition can't
|
|
976
976
|
// happen until it receives this very event → deadlock. We force-inject these so
|
|
977
977
|
// they bypass the busy send-guard and land in the PTY while generating.
|
|
978
|
-
const MESH_FORCE_INJECT_EVENTS = new Set([
|
|
978
|
+
export const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string> = new Set([
|
|
979
979
|
'agent:generating_completed',
|
|
980
980
|
'agent:stopped',
|
|
981
981
|
'agent:waiting_approval',
|
|
@@ -279,10 +279,77 @@ function atomicDrainFile(path: string): string | null {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
// Selectively drain a JSONL pending-events file: atomically claim it (rename), then
|
|
283
|
+
// consume only the lines whose parsed event matches `predicate` and rewrite the
|
|
284
|
+
// remaining (kept) lines back to the original path. Unparseable lines are kept
|
|
285
|
+
// untouched. Returns the consumed events. The rename makes claiming exclusive —
|
|
286
|
+
// only one concurrent caller wins, so there is no double-consume of the same lines.
|
|
287
|
+
function selectiveDrainFile(
|
|
288
|
+
path: string,
|
|
289
|
+
predicate: (event: PendingMeshCoordinatorEvent) => boolean,
|
|
290
|
+
): PendingMeshCoordinatorEvent[] {
|
|
291
|
+
const tmpPath = `${path}.draining`;
|
|
292
|
+
try {
|
|
293
|
+
renameSync(path, tmpPath);
|
|
294
|
+
} catch {
|
|
295
|
+
return []; // another drain claimed it, or the file doesn't exist
|
|
296
|
+
}
|
|
297
|
+
let content: string;
|
|
298
|
+
try {
|
|
299
|
+
content = readFileSync(tmpPath, 'utf-8');
|
|
300
|
+
} catch {
|
|
301
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const consumed: PendingMeshCoordinatorEvent[] = [];
|
|
306
|
+
const keptLines: string[] = [];
|
|
307
|
+
for (const line of content.split('\n')) {
|
|
308
|
+
if (!line) continue;
|
|
309
|
+
let parsed: PendingMeshCoordinatorEvent | undefined;
|
|
310
|
+
try { parsed = JSON.parse(line) as PendingMeshCoordinatorEvent; } catch { parsed = undefined; }
|
|
311
|
+
if (parsed && predicate(parsed)) {
|
|
312
|
+
consumed.push(parsed);
|
|
313
|
+
} else {
|
|
314
|
+
keptLines.push(line); // non-matching or unparseable → leave queued
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
if (keptLines.length > 0) {
|
|
320
|
+
writeFileSync(path, keptLines.join('\n') + '\n', 'utf-8');
|
|
321
|
+
}
|
|
322
|
+
unlinkSync(tmpPath);
|
|
323
|
+
} catch {
|
|
324
|
+
// If the rewrite/cleanup fails, restore the claimed file so no events are
|
|
325
|
+
// lost — the next drain retries the whole file.
|
|
326
|
+
try { if (existsSync(tmpPath) && !existsSync(path)) renameSync(tmpPath, path); } catch { /* best-effort */ }
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
return consumed;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Drain and return pending coordinator events for meshId, removing the drained
|
|
334
|
+
* ones from both the SQLite inbox and the JSONL legacy file.
|
|
335
|
+
*
|
|
336
|
+
* When `opts.onlyEvents` is supplied, ONLY events whose name is in that set are
|
|
337
|
+
* drained; every other event stays queued (undrained in SQLite, rewritten back to
|
|
338
|
+
* the JSONL file). The reconcile loop uses this to force-drain terminal/force-inject
|
|
339
|
+
* events into a *generating* coordinator while leaving non-force progress events for
|
|
340
|
+
* the coordinator's next idle transition. The atomic SQLite drained=1 marking and the
|
|
341
|
+
* atomic JSONL rename keep force-drain and a concurrent full drain from double-consuming.
|
|
342
|
+
*/
|
|
343
|
+
export function drainPendingMeshCoordinatorEvents(
|
|
344
|
+
meshId?: string,
|
|
345
|
+
coordinatorDaemonId?: string,
|
|
346
|
+
opts?: { onlyEvents?: ReadonlySet<string> },
|
|
347
|
+
): PendingMeshCoordinatorEvent[] {
|
|
284
348
|
if (!meshId) return [];
|
|
285
349
|
|
|
350
|
+
const onlyEvents = opts?.onlyEvents;
|
|
351
|
+
const matchesFilter = (eventName: string): boolean => !onlyEvents || onlyEvents.has(eventName);
|
|
352
|
+
|
|
286
353
|
// Dual-write means SQLite and JSONL hold the same events. Both stores must be
|
|
287
354
|
// emptied in one drain call — draining only one leaves the other to re-deliver
|
|
288
355
|
// the same events on the next call. Merge with fingerprint dedup.
|
|
@@ -301,7 +368,7 @@ export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDa
|
|
|
301
368
|
try {
|
|
302
369
|
const store = MeshRuntimeStore.getInstance();
|
|
303
370
|
if (store.pendingEventCount(meshId) > 0) {
|
|
304
|
-
for (const row of store.drainPendingEvents(meshId, coordinatorDaemonId)) {
|
|
371
|
+
for (const row of store.drainPendingEvents(meshId, coordinatorDaemonId, onlyEvents ? { onlyEvents } : undefined)) {
|
|
305
372
|
const event = row.payload as PendingMeshCoordinatorEvent;
|
|
306
373
|
if (event) pushUnique(event);
|
|
307
374
|
}
|
|
@@ -315,15 +382,26 @@ export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDa
|
|
|
315
382
|
? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
|
|
316
383
|
: [getPendingEventsPath(meshId)];
|
|
317
384
|
for (const path of paths) {
|
|
385
|
+
const isSharedFile = coordinatorDaemonId && path === getPendingEventsPath(meshId);
|
|
386
|
+
// Targeting predicate for the shared (unscoped) file: only this coordinator's
|
|
387
|
+
// events (or legacy untargeted ones) are eligible.
|
|
388
|
+
const targets = (e: PendingMeshCoordinatorEvent): boolean =>
|
|
389
|
+
!isSharedFile || !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId;
|
|
390
|
+
|
|
391
|
+
if (onlyEvents) {
|
|
392
|
+
// Selective JSONL drain: consume only matching events, rewrite the rest back.
|
|
393
|
+
for (const event of selectiveDrainFile(path, e => targets(e) && matchesFilter(e.event))) {
|
|
394
|
+
pushUnique(event);
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
318
398
|
const content = atomicDrainFile(path);
|
|
319
399
|
if (!content) continue;
|
|
320
400
|
const parsed = content.split('\n').filter(Boolean).flatMap(line => {
|
|
321
401
|
try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
|
|
322
402
|
});
|
|
323
403
|
// If reading the shared file, filter to events that target this coordinator or are unscoped.
|
|
324
|
-
const filtered =
|
|
325
|
-
? parsed.filter(e => !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId)
|
|
326
|
-
: parsed;
|
|
404
|
+
const filtered = isSharedFile ? parsed.filter(targets) : parsed;
|
|
327
405
|
for (const event of filtered) pushUnique(event);
|
|
328
406
|
}
|
|
329
407
|
if (merged.length === 0) return [];
|
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
//
|
|
15
15
|
// This loop is that drainer. On a fixed interval it:
|
|
16
16
|
// 1. Finds live CLI coordinator sessions on THIS daemon (meshCoordinatorFor
|
|
17
|
-
// stamp). For each, drains the local queue scoped to this daemon and
|
|
18
|
-
// injects pending events into the coordinator
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// stamp). For each mesh, drains the local queue scoped to this daemon and
|
|
18
|
+
// injects pending events into the coordinator. When a coordinator is idle it
|
|
19
|
+
// receives every queued event. When ONLY generating coordinators exist (the
|
|
20
|
+
// common case while the coordinator is blocked awaiting a worker result), the
|
|
21
|
+
// loop force-drains ONLY the force-inject events (completion / approval / stop /
|
|
22
|
+
// refine·bootstrap terminal) and force-writes them into the generating PTY —
|
|
23
|
+
// the same busy-bypass send-guard escape the live-CLI inject used to use.
|
|
24
|
+
// Non-force progress events stay queued for the next idle tick (injecting them
|
|
25
|
+
// mid-generation would be noise). This is what makes a coordinator parked in
|
|
26
|
+
// `generating` while awaiting a worker's completion actually receive it.
|
|
21
27
|
// 2. In cloud mode (dispatchMeshCommand present), pulls each remote worker
|
|
22
28
|
// node daemon's queue over P2P (get_pending_mesh_events) and re-injects via
|
|
23
29
|
// handleMeshForwardEvent — the same pull the MCP drainCoordinatorPendingEvents
|
|
@@ -41,7 +47,7 @@ import { LOG } from '../logging/logger.js';
|
|
|
41
47
|
import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
|
|
42
48
|
import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
43
49
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
44
|
-
import { handleMeshForwardEvent, shouldForceInjectMeshEvent } from './mesh-events-coordinator.js';
|
|
50
|
+
import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS } from './mesh-events-coordinator.js';
|
|
45
51
|
import { readNonEmptyString } from './mesh-events-utils.js';
|
|
46
52
|
|
|
47
53
|
// Default reconcile cadence. approval/completion notifications to a live CLI
|
|
@@ -79,7 +85,9 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
|
79
85
|
return out;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
// Inject a drained pending event into a live
|
|
88
|
+
// Inject a drained pending event into a live coordinator session. Force-inject
|
|
89
|
+
// events carry force:true so they bypass the busy send-guard and land in the PTY
|
|
90
|
+
// even while the coordinator is generating (see shouldForceInjectMeshEvent).
|
|
83
91
|
function injectPendingIntoCoordinator(
|
|
84
92
|
coordinator: LiveCoordinator['instance'],
|
|
85
93
|
pending: PendingMeshCoordinatorEvent,
|
|
@@ -130,12 +138,19 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
|
|
133
|
-
// (b) Drain the local queue scoped to this coordinator daemon and inject
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
141
|
+
// (b) Drain the local queue scoped to this coordinator daemon and inject.
|
|
142
|
+
// - If an idle coordinator exists, FULL-drain and deliver every event to it
|
|
143
|
+
// (it can receive non-force progress events without deadlocking).
|
|
144
|
+
// - If only GENERATING coordinators exist, force-drain ONLY the force-inject
|
|
145
|
+
// events (completion/approval/stop/refine·bootstrap terminal) and force-inject
|
|
146
|
+
// them so a coordinator parked in `generating` while awaiting that very event
|
|
147
|
+
// is not deadlocked. Non-force progress events stay queued for the next idle
|
|
148
|
+
// tick — injecting them would be noise mid-generation. Both drains mark the
|
|
149
|
+
// consumed rows drained=1 atomically, so the pull path can't re-deliver.
|
|
137
150
|
const idleCoordinators = meshCoordinators.filter(c => c.idle);
|
|
138
|
-
|
|
151
|
+
const generatingCoordinators = meshCoordinators.filter(c => !c.idle);
|
|
152
|
+
const targetCoordinators = idleCoordinators.length > 0 ? idleCoordinators : generatingCoordinators;
|
|
153
|
+
const forceOnly = idleCoordinators.length === 0;
|
|
139
154
|
|
|
140
155
|
// O(1) guard: skip the drain entirely when the queue is empty.
|
|
141
156
|
if (store) {
|
|
@@ -146,16 +161,21 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
146
161
|
|
|
147
162
|
let pendingEvents: PendingMeshCoordinatorEvent[] = [];
|
|
148
163
|
try {
|
|
149
|
-
pendingEvents = drainPendingMeshCoordinatorEvents(
|
|
164
|
+
pendingEvents = drainPendingMeshCoordinatorEvents(
|
|
165
|
+
meshId,
|
|
166
|
+
localDaemonId,
|
|
167
|
+
forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
|
|
168
|
+
);
|
|
150
169
|
} catch (e: any) {
|
|
151
170
|
LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
|
|
152
171
|
continue;
|
|
153
172
|
}
|
|
154
173
|
if (pendingEvents.length === 0) continue;
|
|
155
174
|
|
|
156
|
-
|
|
175
|
+
const mode = forceOnly ? 'force-drain → generating' : 'inject → idle';
|
|
176
|
+
LOG.info('MeshReconcile', `Reconcile ${mode}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
|
|
157
177
|
for (const pending of pendingEvents) {
|
|
158
|
-
for (const c of
|
|
178
|
+
for (const c of targetCoordinators) {
|
|
159
179
|
injectPendingIntoCoordinator(c.instance, pending);
|
|
160
180
|
}
|
|
161
181
|
}
|
|
@@ -1241,16 +1241,41 @@ export class MeshRuntimeStore {
|
|
|
1241
1241
|
return result.changes > 0;
|
|
1242
1242
|
}
|
|
1243
1243
|
|
|
1244
|
-
|
|
1244
|
+
/**
|
|
1245
|
+
* Drain undrained pending events for a mesh, atomically marking them drained.
|
|
1246
|
+
* When `opts.onlyEvents` is supplied, ONLY rows whose `event` is in that set are
|
|
1247
|
+
* drained — the rest stay queued (drained=0) for a later drain. This is how the
|
|
1248
|
+
* reconcile loop force-drains terminal/force-inject events into a *generating*
|
|
1249
|
+
* coordinator while leaving non-force progress events for the coordinator's next
|
|
1250
|
+
* idle transition. Filtering happens inside the same transaction as the
|
|
1251
|
+
* drained=1 marking, so force-drain + a concurrent full drain can never both
|
|
1252
|
+
* consume the same row.
|
|
1253
|
+
*/
|
|
1254
|
+
drainPendingEvents(
|
|
1255
|
+
meshId: string,
|
|
1256
|
+
coordinatorDaemonId?: string | null,
|
|
1257
|
+
opts?: { onlyEvents?: ReadonlySet<string> },
|
|
1258
|
+
): Array<{ id: string; event: string; payload: unknown }> {
|
|
1245
1259
|
return this.transaction(() => {
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1260
|
+
const onlyEvents = opts?.onlyEvents;
|
|
1261
|
+
// An explicit-but-empty filter means "drain nothing" (no event name can match).
|
|
1262
|
+
if (onlyEvents && onlyEvents.size === 0) return [];
|
|
1263
|
+
const eventList = onlyEvents ? [...onlyEvents] : [];
|
|
1264
|
+
// Filter by event name IN-SQL when onlyEvents is set so the LIMIT applies to
|
|
1265
|
+
// matching rows — a long run of non-force events ahead in the queue must not
|
|
1266
|
+
// crowd a force event out of the 100-row window.
|
|
1267
|
+
const clauses = ['mesh_id = ?', 'drained = 0'];
|
|
1268
|
+
const params: unknown[] = [meshId];
|
|
1269
|
+
if (coordinatorDaemonId) {
|
|
1270
|
+
clauses.push('(coordinator_daemon_id IS NULL OR coordinator_daemon_id = ?)');
|
|
1271
|
+
params.push(coordinatorDaemonId);
|
|
1272
|
+
}
|
|
1273
|
+
if (eventList.length > 0) {
|
|
1274
|
+
clauses.push(`event IN (${eventList.map(() => '?').join(',')})`);
|
|
1275
|
+
params.push(...eventList);
|
|
1276
|
+
}
|
|
1252
1277
|
const rows = this.db.prepare(
|
|
1253
|
-
`SELECT id, event, payload FROM mesh_pending_events ${
|
|
1278
|
+
`SELECT id, event, payload FROM mesh_pending_events WHERE ${clauses.join(' AND ')} ORDER BY queued_at ASC LIMIT 100`
|
|
1254
1279
|
).all(...params) as Array<{ id: string; event: string; payload: string }>;
|
|
1255
1280
|
if (rows.length === 0) return [];
|
|
1256
1281
|
const ids = rows.map(r => r.id);
|