@adhdev/daemon-core 0.9.82-rc.371 → 0.9.82-rc.373
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/commands/router.d.ts +28 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +218 -64
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +221 -68
- package/dist/index.mjs.map +1 -1
- package/dist/logging/log-tail-reader.d.ts +40 -5
- package/dist/mesh/mesh-events-coordinator.d.ts +1 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +1 -0
- package/dist/mesh/mesh-runtime-store.d.ts +1 -0
- package/dist/mesh/mesh-work-queue.d.ts +1 -0
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +11 -2
- package/src/commands/low-family/mesh-node-logs.ts +6 -0
- package/src/commands/router.ts +45 -0
- package/src/index.ts +1 -1
- package/src/logging/log-tail-reader.ts +187 -66
- package/src/mesh/mesh-events-coordinator.ts +67 -3
- package/src/mesh/mesh-reconcile-loop.ts +58 -6
- package/src/mesh/mesh-runtime-store.ts +26 -1
- package/src/mesh/mesh-work-queue.ts +1 -1
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
* grep the file by hand. Because the mesh RPC envelope is sent as a single
|
|
8
8
|
* datachannel message (~256KB SCTP ceiling, no chunking), the returned tail is
|
|
9
9
|
* HARD-bounded by `tailBytes` (default 64KB, capped at MAX_TAIL_BYTES=128KB) and
|
|
10
|
-
* flags `truncated:true` when
|
|
10
|
+
* flags `truncated:true` when more content existed than fit.
|
|
11
|
+
*
|
|
12
|
+
* Two read modes:
|
|
13
|
+
* - No filter (no grep/sinceMs): byte-bounded tail of the active file — read the
|
|
14
|
+
* last `tailBytes` bytes only. Cheap, backward-compatible.
|
|
15
|
+
* - Filtered (grep and/or sinceMs given): FULL-FILE scan. The filter is applied
|
|
16
|
+
* across the ENTIRE file (plus the size-rotation `.1.log` backup) BEFORE the
|
|
17
|
+
* byte cap, then the last `tailBytes` worth of MATCHING lines are returned.
|
|
18
|
+
* This is the fix for "matches hidden behind polling spam": when the recent
|
|
19
|
+
* tail window is saturated with high-frequency lines (e.g. coordinator polling
|
|
20
|
+
* `get_pending_mesh_events`/`read_chat` every few seconds), a grep for a rarer
|
|
21
|
+
* earlier line (dispatch/inject/forward) previously matched 0 because the line
|
|
22
|
+
* had already scrolled out of the tail window before the filter ran. Filtering
|
|
23
|
+
* the whole file first surfaces those matches regardless of how much unrelated
|
|
24
|
+
* spam followed them.
|
|
11
25
|
*
|
|
12
26
|
* Boundary-safe: lines are cut on the newline byte (0x0A) only, which never
|
|
13
27
|
* appears inside a multibyte UTF-8 sequence, so decoding each complete byte
|
|
@@ -29,18 +43,39 @@ export interface DaemonLogTailResult {
|
|
|
29
43
|
success: boolean;
|
|
30
44
|
error?: string;
|
|
31
45
|
lines: string[];
|
|
46
|
+
/**
|
|
47
|
+
* No-filter mode: true when the file was larger than the byte window.
|
|
48
|
+
* Filter mode: true when matching lines were dropped from the FRONT to fit
|
|
49
|
+
* the byte cap (i.e. there are older matches than the ones returned).
|
|
50
|
+
*/
|
|
32
51
|
truncated: boolean;
|
|
33
52
|
logPath: string;
|
|
34
53
|
platform: NodeJS.Platform;
|
|
35
54
|
bytesReturned: number;
|
|
36
|
-
/** True when a grep/since filter dropped
|
|
55
|
+
/** True when a grep/since filter dropped at least one line. */
|
|
37
56
|
filtered: boolean;
|
|
38
57
|
/** The grep source actually applied (echoed back for clarity). */
|
|
39
58
|
grep?: string;
|
|
59
|
+
/** True when the filtered full-file scan path ran (grep/sinceMs given). */
|
|
60
|
+
fullScan: boolean;
|
|
61
|
+
/** Total bytes read while scanning (filter mode scans the whole file + backup). */
|
|
62
|
+
scannedBytes: number;
|
|
63
|
+
/** Number of lines that matched the filter across the full scan (filter mode). */
|
|
64
|
+
matchedLineCount: number;
|
|
65
|
+
/** Number of scanned lines dropped by the filter ("N lines excluded by filter"). */
|
|
66
|
+
excludedByFilter: number;
|
|
40
67
|
}
|
|
41
68
|
/**
|
|
42
|
-
* Read the daemon log tail for `date` (default today), bounded to `tailBytes
|
|
43
|
-
*
|
|
44
|
-
*
|
|
69
|
+
* Read the daemon log tail for `date` (default today), bounded to `tailBytes`.
|
|
70
|
+
*
|
|
71
|
+
* - No grep/sinceMs → byte-bounded tail of the active file (legacy behaviour).
|
|
72
|
+
* - grep and/or sinceMs given → FULL-FILE scan: the filter is applied across the
|
|
73
|
+
* whole file (plus the `*.1.log` size-rotation backup) BEFORE the byte cap, so
|
|
74
|
+
* matches that have scrolled out of the recent tail window (e.g. behind
|
|
75
|
+
* coordinator polling spam) are still returned. Only the last `tailBytes` worth
|
|
76
|
+
* of matching lines ship over P2P.
|
|
77
|
+
*
|
|
78
|
+
* Falls back to the size-rotation backup (`*.1.log`) when the primary file does
|
|
79
|
+
* not exist.
|
|
45
80
|
*/
|
|
46
81
|
export declare function readDaemonLogTail(args?: ReadDaemonLogTailArgs): DaemonLogTailResult;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
2
|
import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
3
|
+
export declare function resolveForwardEventMeshId(components: DaemonComponents, payload: Record<string, unknown>): string;
|
|
3
4
|
export declare function __resetIdleAutoFastForwardForTests(): void;
|
|
4
5
|
export declare function __resetMeshWorkspaceCacheForTests(): void;
|
|
5
6
|
export declare function tryAssignQueueTask(components: DaemonComponents, meshId: string, nodeId: string, sessionId: string, providerType: string): boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
2
|
export declare function runMeshReconcileTick(components: DaemonComponents): Promise<void>;
|
|
3
|
+
export declare function __resetUnresolvedForwardRejectionCountsForTests(): void;
|
|
3
4
|
interface ReconcileLoopHandle {
|
|
4
5
|
stop(): void;
|
|
5
6
|
}
|
|
@@ -73,6 +73,7 @@ export declare class MeshRuntimeStore {
|
|
|
73
73
|
claimNextQueueTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[], opts?: {
|
|
74
74
|
providerType?: string;
|
|
75
75
|
providerMaxParallel?: number;
|
|
76
|
+
nodeIsWorktree?: boolean;
|
|
76
77
|
}): MeshWorkQueueEntry | null;
|
|
77
78
|
getQueueStatsByStatus(meshId: string): {
|
|
78
79
|
status: string;
|
|
@@ -195,6 +195,7 @@ export declare function getMeshQueueRevision(meshId: string): string;
|
|
|
195
195
|
export declare function claimNextTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[], opts?: {
|
|
196
196
|
providerType?: string;
|
|
197
197
|
providerMaxParallel?: number;
|
|
198
|
+
nodeIsWorktree?: boolean;
|
|
198
199
|
}): MeshWorkQueueEntry | null;
|
|
199
200
|
export type DependencyFailurePolicy = 'block' | 'cancel';
|
|
200
201
|
/**
|
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.373",
|
|
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.373",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -1215,8 +1215,17 @@ export class DaemonCliManager {
|
|
|
1215
1215
|
const adapter = this.adapters.get(ik);
|
|
1216
1216
|
if (adapter) return { adapter, key: ik };
|
|
1217
1217
|
}
|
|
1218
|
-
// 1. agentType + dir match
|
|
1219
|
-
|
|
1218
|
+
// 1. agentType + dir match.
|
|
1219
|
+
// FAIL-CLOSED when an explicit instanceKey/targetSessionId was named (step 0) but
|
|
1220
|
+
// did not resolve: the caller pinned a SPECIFIC session, so healing by workspace must
|
|
1221
|
+
// not silently redirect the command into a co-located SIBLING worktree session. The
|
|
1222
|
+
// remote mesh relay (ipcDispatchToRemoteAgent) carries `dir: node.workspace` alongside
|
|
1223
|
+
// targetSessionId for the sessionless-scope case; when a session WAS named, that dir
|
|
1224
|
+
// fallback is the WTDISPATCH-FANOUT (a) leak — a stale/relaunched session_id would
|
|
1225
|
+
// dir-match whatever session lives in that workspace instead of failing. The sessionless
|
|
1226
|
+
// node-scoped path uses findMeshNodeAdapter, not this fallback, so gating dir on
|
|
1227
|
+
// !instanceKey loses no legitimate routing. Mirror step 2's fail-closed rule.
|
|
1228
|
+
if (opts?.dir && !opts?.instanceKey) {
|
|
1220
1229
|
for (const [k, a] of this.adapters) {
|
|
1221
1230
|
if (a.cliType === agentType && a.workingDir === opts.dir) {
|
|
1222
1231
|
return { adapter: a, key: k };
|
|
@@ -75,6 +75,12 @@ export const meshNodeLogsHandlers: Record<string, LowFamilyHandler> = {
|
|
|
75
75
|
truncated: tail.truncated,
|
|
76
76
|
filtered: tail.filtered,
|
|
77
77
|
bytesReturned: tail.bytesReturned,
|
|
78
|
+
// Transparency meta — lets the coordinator see that a full-file grep
|
|
79
|
+
// ran past the recent tail window, and how much was scanned/excluded.
|
|
80
|
+
fullScan: tail.fullScan,
|
|
81
|
+
scannedBytes: tail.scannedBytes,
|
|
82
|
+
matchedLineCount: tail.matchedLineCount,
|
|
83
|
+
excludedByFilter: tail.excludedByFilter,
|
|
78
84
|
...(tail.grep ? { grep: tail.grep } : {}),
|
|
79
85
|
} as CommandRouterResult;
|
|
80
86
|
},
|
package/src/commands/router.ts
CHANGED
|
@@ -1216,6 +1216,51 @@ export function buildMeshNodeDataFreshness(args: {
|
|
|
1216
1216
|
};
|
|
1217
1217
|
}
|
|
1218
1218
|
|
|
1219
|
+
/**
|
|
1220
|
+
* Canonical live-probe → freshness adapter. The coordinator-facing mesh_status
|
|
1221
|
+
* (mcp-server `meshStatus`) builds each node entry from a SINGLE fresh git_status
|
|
1222
|
+
* probe that either returns (live truth) or throws (peer unreachable). It used to
|
|
1223
|
+
* hand-reconstruct the freshness INPUT inline — a synthetic `{ git, connection }`
|
|
1224
|
+
* status plus the directTruthUnavailable/liveTruthProbed wiring — which is exactly
|
|
1225
|
+
* how a field added to `buildMeshNodeDataFreshness`'s input contract ends up "wired
|
|
1226
|
+
* on the daemon surface, null on the coordinator surface" (the rc.371
|
|
1227
|
+
* null-everywhere regression). Routing every live-probe surface through this one
|
|
1228
|
+
* adapter keeps the marker derivation canonical: there is a SINGLE place that turns
|
|
1229
|
+
* a probe outcome into freshness args, so the two mesh_status surfaces cannot drift.
|
|
1230
|
+
*
|
|
1231
|
+
* `liveTruthProbed` true → the probe returned (live/self truth); false → it threw,
|
|
1232
|
+
* so a configured peer is unreachable while an unconfigured node (no daemonId) falls
|
|
1233
|
+
* through to the classifier's `unconfigured` branch.
|
|
1234
|
+
*/
|
|
1235
|
+
export function buildMeshNodeProbeFreshness(args: {
|
|
1236
|
+
/** The git snapshot this probe stamped on the node entry (entry.git). */
|
|
1237
|
+
git: unknown;
|
|
1238
|
+
/** True when the fresh git_status probe RETURNED (live truth); false when it threw. */
|
|
1239
|
+
liveTruthProbed: boolean;
|
|
1240
|
+
isSelfNode: boolean;
|
|
1241
|
+
/** The node's resolved daemonId; absent → unconfigured node. */
|
|
1242
|
+
daemonId?: string;
|
|
1243
|
+
/** The mesh node record, for held-git fallback when the probe did not return live. */
|
|
1244
|
+
node?: any;
|
|
1245
|
+
now?: () => number;
|
|
1246
|
+
}): Record<string, unknown> {
|
|
1247
|
+
const { git, liveTruthProbed, isSelfNode, daemonId, node, now } = args;
|
|
1248
|
+
const status: Record<string, unknown> = {
|
|
1249
|
+
git,
|
|
1250
|
+
connection: { state: liveTruthProbed ? 'connected' : 'disconnected' },
|
|
1251
|
+
};
|
|
1252
|
+
if (liveTruthProbed) status[MESH_NODE_LIVE_TRUTH_MARKER] = true;
|
|
1253
|
+
return buildMeshNodeDataFreshness({
|
|
1254
|
+
status,
|
|
1255
|
+
node,
|
|
1256
|
+
isSelfNode,
|
|
1257
|
+
daemonId,
|
|
1258
|
+
liveTruthProbed,
|
|
1259
|
+
directTruthUnavailable: !liveTruthProbed && !!daemonId,
|
|
1260
|
+
now,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1219
1264
|
export function finalizeMeshNodeStatus(args: {
|
|
1220
1265
|
status: Record<string, unknown>;
|
|
1221
1266
|
node: any;
|
package/src/index.ts
CHANGED
|
@@ -312,7 +312,7 @@ export type { CdpInitializerConfig } from './cdp/initializer.js';
|
|
|
312
312
|
// ── Commands ──
|
|
313
313
|
export { DaemonCommandHandler } from './commands/handler.js';
|
|
314
314
|
export type { CommandResult, CommandContext } from './commands/handler.js';
|
|
315
|
-
export { DaemonCommandRouter, readCachedInlineMeshActiveSessionDetails, resolveMeshNodeAttribution, buildMeshNodeDataFreshness, MESH_NODE_LIVE_TRUTH_MARKER } from './commands/router.js';
|
|
315
|
+
export { DaemonCommandRouter, readCachedInlineMeshActiveSessionDetails, resolveMeshNodeAttribution, buildMeshNodeDataFreshness, buildMeshNodeProbeFreshness, MESH_NODE_LIVE_TRUTH_MARKER } from './commands/router.js';
|
|
316
316
|
export type { CommandRouterDeps, CommandRouterResult } from './commands/router.js';
|
|
317
317
|
export {
|
|
318
318
|
maybeRunDaemonUpgradeHelperFromEnv,
|
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
* grep the file by hand. Because the mesh RPC envelope is sent as a single
|
|
8
8
|
* datachannel message (~256KB SCTP ceiling, no chunking), the returned tail is
|
|
9
9
|
* HARD-bounded by `tailBytes` (default 64KB, capped at MAX_TAIL_BYTES=128KB) and
|
|
10
|
-
* flags `truncated:true` when
|
|
10
|
+
* flags `truncated:true` when more content existed than fit.
|
|
11
|
+
*
|
|
12
|
+
* Two read modes:
|
|
13
|
+
* - No filter (no grep/sinceMs): byte-bounded tail of the active file — read the
|
|
14
|
+
* last `tailBytes` bytes only. Cheap, backward-compatible.
|
|
15
|
+
* - Filtered (grep and/or sinceMs given): FULL-FILE scan. The filter is applied
|
|
16
|
+
* across the ENTIRE file (plus the size-rotation `.1.log` backup) BEFORE the
|
|
17
|
+
* byte cap, then the last `tailBytes` worth of MATCHING lines are returned.
|
|
18
|
+
* This is the fix for "matches hidden behind polling spam": when the recent
|
|
19
|
+
* tail window is saturated with high-frequency lines (e.g. coordinator polling
|
|
20
|
+
* `get_pending_mesh_events`/`read_chat` every few seconds), a grep for a rarer
|
|
21
|
+
* earlier line (dispatch/inject/forward) previously matched 0 because the line
|
|
22
|
+
* had already scrolled out of the tail window before the filter ran. Filtering
|
|
23
|
+
* the whole file first surfaces those matches regardless of how much unrelated
|
|
24
|
+
* spam followed them.
|
|
11
25
|
*
|
|
12
26
|
* Boundary-safe: lines are cut on the newline byte (0x0A) only, which never
|
|
13
27
|
* appears inside a multibyte UTF-8 sequence, so decoding each complete byte
|
|
@@ -36,14 +50,27 @@ export interface DaemonLogTailResult {
|
|
|
36
50
|
success: boolean;
|
|
37
51
|
error?: string;
|
|
38
52
|
lines: string[];
|
|
53
|
+
/**
|
|
54
|
+
* No-filter mode: true when the file was larger than the byte window.
|
|
55
|
+
* Filter mode: true when matching lines were dropped from the FRONT to fit
|
|
56
|
+
* the byte cap (i.e. there are older matches than the ones returned).
|
|
57
|
+
*/
|
|
39
58
|
truncated: boolean;
|
|
40
59
|
logPath: string;
|
|
41
60
|
platform: NodeJS.Platform;
|
|
42
61
|
bytesReturned: number;
|
|
43
|
-
/** True when a grep/since filter dropped
|
|
62
|
+
/** True when a grep/since filter dropped at least one line. */
|
|
44
63
|
filtered: boolean;
|
|
45
64
|
/** The grep source actually applied (echoed back for clarity). */
|
|
46
65
|
grep?: string;
|
|
66
|
+
/** True when the filtered full-file scan path ran (grep/sinceMs given). */
|
|
67
|
+
fullScan: boolean;
|
|
68
|
+
/** Total bytes read while scanning (filter mode scans the whole file + backup). */
|
|
69
|
+
scannedBytes: number;
|
|
70
|
+
/** Number of lines that matched the filter across the full scan (filter mode). */
|
|
71
|
+
matchedLineCount: number;
|
|
72
|
+
/** Number of scanned lines dropped by the filter ("N lines excluded by filter"). */
|
|
73
|
+
excludedByFilter: number;
|
|
47
74
|
}
|
|
48
75
|
|
|
49
76
|
function resolveLogPath(date?: string | Date): string {
|
|
@@ -102,6 +129,39 @@ function readByteBoundedTail(filePath: string, limitBytes: number): { text: stri
|
|
|
102
129
|
}
|
|
103
130
|
}
|
|
104
131
|
|
|
132
|
+
/** Split decoded text into lines, dropping the trailing empty element a final
|
|
133
|
+
* newline produces. Pure helper shared by both read modes. */
|
|
134
|
+
function splitLogLines(text: string): string[] {
|
|
135
|
+
const lines = text.split('\n');
|
|
136
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Keep the LAST lines whose cumulative UTF-8 byte size (counting one byte per
|
|
142
|
+
* line for the joining newline) stays within `limitBytes`. Always keeps at least
|
|
143
|
+
* the final line, even if it alone exceeds the cap (matches the no-filter path's
|
|
144
|
+
* "never return nothing when there is content" behaviour). `truncated` is true
|
|
145
|
+
* when earlier lines were dropped to fit.
|
|
146
|
+
*/
|
|
147
|
+
function takeLastLinesWithinBytes(lines: string[], limitBytes: number): { kept: string[]; truncated: boolean; bytesReturned: number } {
|
|
148
|
+
if (lines.length === 0) return { kept: [], truncated: false, bytesReturned: 0 };
|
|
149
|
+
let total = 0;
|
|
150
|
+
let firstKept = lines.length;
|
|
151
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
152
|
+
const lineBytes = Buffer.byteLength(lines[i], 'utf-8') + 1; // +1 ≈ joining newline
|
|
153
|
+
// Once at least one line is kept, stop before overflowing the cap.
|
|
154
|
+
if (firstKept !== lines.length && total + lineBytes > limitBytes) break;
|
|
155
|
+
total += lineBytes;
|
|
156
|
+
firstKept = i;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
kept: lines.slice(firstKept),
|
|
160
|
+
truncated: firstKept > 0,
|
|
161
|
+
bytesReturned: total,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
105
165
|
// Parse a leading timestamp from a log line into epoch ms. The unified logger
|
|
106
166
|
// writes `[HH:MM:SS.mmm]` (local time, today's date) and the startup banner uses
|
|
107
167
|
// a full timestamp; we best-effort parse `[HH:MM:SS...]` against the file's date.
|
|
@@ -122,99 +182,160 @@ function parseLineEpochMs(line: string, fileDate: Date): number | null {
|
|
|
122
182
|
return d.getTime();
|
|
123
183
|
}
|
|
124
184
|
|
|
185
|
+
/** Build the case-insensitive line predicate for a grep source, falling back to
|
|
186
|
+
* a literal (lowercased substring) match when the source is not a valid regex. */
|
|
187
|
+
function buildGrepPredicate(grepSource: string): (line: string) => boolean {
|
|
188
|
+
let re: RegExp | null = null;
|
|
189
|
+
try {
|
|
190
|
+
re = new RegExp(grepSource, 'i');
|
|
191
|
+
} catch {
|
|
192
|
+
re = null;
|
|
193
|
+
}
|
|
194
|
+
if (re) {
|
|
195
|
+
const compiled = re;
|
|
196
|
+
return (line: string) => compiled.test(line);
|
|
197
|
+
}
|
|
198
|
+
const needle = grepSource.toLowerCase();
|
|
199
|
+
return (line: string) => line.toLowerCase().includes(needle);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function fileDateFor(date?: string | Date): Date {
|
|
203
|
+
if (date instanceof Date) return date;
|
|
204
|
+
if (typeof date === 'string' && date.trim()) return new Date(`${date.trim()}T00:00:00.000Z`);
|
|
205
|
+
return new Date();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function errorResult(error: string, logPath: string, platform: NodeJS.Platform): DaemonLogTailResult {
|
|
209
|
+
return {
|
|
210
|
+
success: false,
|
|
211
|
+
error,
|
|
212
|
+
lines: [],
|
|
213
|
+
truncated: false,
|
|
214
|
+
logPath,
|
|
215
|
+
platform,
|
|
216
|
+
bytesReturned: 0,
|
|
217
|
+
filtered: false,
|
|
218
|
+
fullScan: false,
|
|
219
|
+
scannedBytes: 0,
|
|
220
|
+
matchedLineCount: 0,
|
|
221
|
+
excludedByFilter: 0,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
125
225
|
/**
|
|
126
|
-
* Read the daemon log tail for `date` (default today), bounded to `tailBytes
|
|
127
|
-
*
|
|
128
|
-
*
|
|
226
|
+
* Read the daemon log tail for `date` (default today), bounded to `tailBytes`.
|
|
227
|
+
*
|
|
228
|
+
* - No grep/sinceMs → byte-bounded tail of the active file (legacy behaviour).
|
|
229
|
+
* - grep and/or sinceMs given → FULL-FILE scan: the filter is applied across the
|
|
230
|
+
* whole file (plus the `*.1.log` size-rotation backup) BEFORE the byte cap, so
|
|
231
|
+
* matches that have scrolled out of the recent tail window (e.g. behind
|
|
232
|
+
* coordinator polling spam) are still returned. Only the last `tailBytes` worth
|
|
233
|
+
* of matching lines ship over P2P.
|
|
234
|
+
*
|
|
235
|
+
* Falls back to the size-rotation backup (`*.1.log`) when the primary file does
|
|
236
|
+
* not exist.
|
|
129
237
|
*/
|
|
130
238
|
export function readDaemonLogTail(args: ReadDaemonLogTailArgs = {}): DaemonLogTailResult {
|
|
131
239
|
const platform = process.platform;
|
|
132
240
|
const limitBytes = clampTailBytes(args.tailBytes);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
lines: [],
|
|
145
|
-
truncated: false,
|
|
146
|
-
logPath,
|
|
147
|
-
platform,
|
|
148
|
-
bytesReturned: 0,
|
|
149
|
-
filtered: false,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
241
|
+
const primaryPath = resolveLogPath(args.date);
|
|
242
|
+
const backupPath = primaryPath.replace(/\.log$/, '.1.log');
|
|
243
|
+
const primaryExists = fs.existsSync(primaryPath);
|
|
244
|
+
const backupExists = fs.existsSync(backupPath);
|
|
245
|
+
|
|
246
|
+
if (!primaryExists && !backupExists) {
|
|
247
|
+
return errorResult(
|
|
248
|
+
`No daemon log file at ${primaryPath} (dir: ${getDaemonLogDir()})`,
|
|
249
|
+
primaryPath,
|
|
250
|
+
platform,
|
|
251
|
+
);
|
|
152
252
|
}
|
|
153
253
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
254
|
+
// Reported log path: the active file when present, else the backup.
|
|
255
|
+
const logPath = primaryExists ? primaryPath : backupPath;
|
|
256
|
+
|
|
257
|
+
const hasGrep = typeof args.grep === 'string' && args.grep.trim().length > 0;
|
|
258
|
+
const hasSince = Number.isFinite(args.sinceMs);
|
|
259
|
+
const filterMode = hasGrep || hasSince;
|
|
260
|
+
|
|
261
|
+
// ── No-filter mode: cheap byte-bounded tail of the active file ──────────
|
|
262
|
+
if (!filterMode) {
|
|
263
|
+
let raw: { text: string; truncated: boolean; bytesReturned: number };
|
|
264
|
+
try {
|
|
265
|
+
raw = readByteBoundedTail(logPath, limitBytes);
|
|
266
|
+
} catch (e: any) {
|
|
267
|
+
return errorResult(`Failed to read ${logPath}: ${e?.message ?? String(e)}`, logPath, platform);
|
|
268
|
+
}
|
|
269
|
+
const lines = splitLogLines(raw.text);
|
|
158
270
|
return {
|
|
159
|
-
success:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
truncated: false,
|
|
271
|
+
success: true,
|
|
272
|
+
lines,
|
|
273
|
+
truncated: raw.truncated,
|
|
163
274
|
logPath,
|
|
164
275
|
platform,
|
|
165
|
-
bytesReturned:
|
|
276
|
+
bytesReturned: raw.bytesReturned,
|
|
166
277
|
filtered: false,
|
|
278
|
+
fullScan: false,
|
|
279
|
+
scannedBytes: raw.bytesReturned,
|
|
280
|
+
matchedLineCount: lines.length,
|
|
281
|
+
excludedByFilter: 0,
|
|
167
282
|
};
|
|
168
283
|
}
|
|
169
284
|
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
285
|
+
// ── Filter mode: scan the WHOLE file (backup then primary, chronological)
|
|
286
|
+
// so matches behind a saturated tail window are still found. ────────────
|
|
287
|
+
let scannedBytes = 0;
|
|
288
|
+
let allLines: string[] = [];
|
|
289
|
+
try {
|
|
290
|
+
for (const p of [backupExists ? backupPath : null, primaryExists ? primaryPath : null]) {
|
|
291
|
+
if (!p) continue;
|
|
292
|
+
const buf = fs.readFileSync(p);
|
|
293
|
+
scannedBytes += buf.length;
|
|
294
|
+
allLines = allLines.concat(splitLogLines(buf.toString('utf-8')));
|
|
295
|
+
}
|
|
296
|
+
} catch (e: any) {
|
|
297
|
+
return errorResult(`Failed to read ${logPath}: ${e?.message ?? String(e)}`, logPath, platform);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const scannedLineCount = allLines.length;
|
|
301
|
+
let lines = allLines;
|
|
302
|
+
|
|
303
|
+
// since filter — keep lines at/after the floor (and lines with no timestamp).
|
|
304
|
+
if (hasSince) {
|
|
305
|
+
const fileDate = fileDateFor(args.date);
|
|
182
306
|
const floor = args.sinceMs as number;
|
|
183
307
|
lines = lines.filter((line) => {
|
|
184
308
|
const ts = parseLineEpochMs(line, fileDate);
|
|
185
|
-
// Keep lines with no parseable timestamp (continuation/stack lines).
|
|
186
309
|
return ts === null || ts >= floor;
|
|
187
310
|
});
|
|
188
311
|
}
|
|
189
312
|
|
|
190
313
|
// grep filter
|
|
191
314
|
let appliedGrep: string | undefined;
|
|
192
|
-
if (
|
|
193
|
-
appliedGrep = args.grep.trim();
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
re = new RegExp(appliedGrep, 'i');
|
|
197
|
-
} catch {
|
|
198
|
-
re = null;
|
|
199
|
-
}
|
|
200
|
-
if (re) {
|
|
201
|
-
const compiled = re;
|
|
202
|
-
lines = lines.filter((line) => compiled.test(line));
|
|
203
|
-
} else {
|
|
204
|
-
// Invalid regex → fall back to a literal substring match.
|
|
205
|
-
const needle = appliedGrep.toLowerCase();
|
|
206
|
-
lines = lines.filter((line) => line.toLowerCase().includes(needle));
|
|
207
|
-
}
|
|
315
|
+
if (hasGrep) {
|
|
316
|
+
appliedGrep = (args.grep as string).trim();
|
|
317
|
+
const matches = buildGrepPredicate(appliedGrep);
|
|
318
|
+
lines = lines.filter(matches);
|
|
208
319
|
}
|
|
209
320
|
|
|
321
|
+
const matchedLineCount = lines.length;
|
|
322
|
+
const excludedByFilter = scannedLineCount - matchedLineCount;
|
|
323
|
+
|
|
324
|
+
// Byte cap applies to the MATCHING lines: keep the newest matches that fit.
|
|
325
|
+
const capped = takeLastLinesWithinBytes(lines, limitBytes);
|
|
326
|
+
|
|
210
327
|
return {
|
|
211
328
|
success: true,
|
|
212
|
-
lines,
|
|
213
|
-
truncated:
|
|
329
|
+
lines: capped.kept,
|
|
330
|
+
truncated: capped.truncated,
|
|
214
331
|
logPath,
|
|
215
332
|
platform,
|
|
216
|
-
bytesReturned:
|
|
217
|
-
filtered:
|
|
333
|
+
bytesReturned: capped.bytesReturned,
|
|
334
|
+
filtered: excludedByFilter > 0,
|
|
335
|
+
fullScan: true,
|
|
336
|
+
scannedBytes,
|
|
337
|
+
matchedLineCount,
|
|
338
|
+
excludedByFilter,
|
|
218
339
|
...(appliedGrep ? { grep: appliedGrep } : {}),
|
|
219
340
|
};
|
|
220
341
|
}
|
|
@@ -91,6 +91,51 @@ function recoverMeshIdByNodeId(nodeId: string): string {
|
|
|
91
91
|
return '';
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// RECONCILE-MESHID-DROP: WORKER-side meshId resolution for an unresolved-delegate
|
|
95
|
+
// forward payload. forwardUnresolvedDelegateEvent omits meshId by design (the worker
|
|
96
|
+
// "can't resolve it") and relies on the COORDINATOR recovering it from workspace/nodeId.
|
|
97
|
+
// That recovery fails when the no_node_binding session's payload has an empty nodeId AND
|
|
98
|
+
// the coordinator's workspace→mesh lookup misses (a worktree clone whose repoIdentity
|
|
99
|
+
// differs / a cache miss) — leaving the reconcile retry rejected with "meshId required"
|
|
100
|
+
// every 4s forever. The worker actually has MORE context than the stripped payload gives
|
|
101
|
+
// the coordinator: it hosts the node as a member and holds the LIVE session, whose
|
|
102
|
+
// settings.meshNodeFor / meshNodeId are authoritative even when they were not stamped
|
|
103
|
+
// onto the original event. Resolve here (worker side) and stamp meshId onto the payload so
|
|
104
|
+
// the coordinator accepts it. Mirrors the receiver's recovery order, then adds the live-
|
|
105
|
+
// session fallback. Returns '' when even the worker cannot resolve it (truly unresolvable —
|
|
106
|
+
// the retry cap then drops it instead of looping). No side effects; safe to call per retry.
|
|
107
|
+
export function resolveForwardEventMeshId(
|
|
108
|
+
components: DaemonComponents,
|
|
109
|
+
payload: Record<string, unknown>,
|
|
110
|
+
): string {
|
|
111
|
+
const direct = readNonEmptyString(payload.meshId);
|
|
112
|
+
if (direct) return direct;
|
|
113
|
+
const workspace = readNonEmptyString(payload.workspace);
|
|
114
|
+
const byWorkspace = workspace ? readNonEmptyString(getCachedMeshByWorkspace(workspace)?.id) : '';
|
|
115
|
+
if (byWorkspace) return byWorkspace;
|
|
116
|
+
const byNode = recoverMeshIdByNodeId(readNonEmptyString(payload.nodeId));
|
|
117
|
+
if (byNode) return byNode;
|
|
118
|
+
// Live-session fallback: the worker session may carry meshNodeFor / meshNodeId now even
|
|
119
|
+
// though the original event didn't (a late stamp, or an event that fired before binding).
|
|
120
|
+
const sessionId = readNonEmptyString(payload.targetSessionId)
|
|
121
|
+
|| readNonEmptyString(payload.sessionId)
|
|
122
|
+
|| readNonEmptyString(payload.instanceId);
|
|
123
|
+
if (sessionId) {
|
|
124
|
+
try {
|
|
125
|
+
const state = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
|
|
126
|
+
const settings = (state?.settings as Record<string, unknown>) || {};
|
|
127
|
+
const meshNodeFor = readNonEmptyString(settings.meshNodeFor);
|
|
128
|
+
if (meshNodeFor) return meshNodeFor;
|
|
129
|
+
const byStamp = recoverMeshIdByNodeId(readNonEmptyString(settings.meshNodeId));
|
|
130
|
+
if (byStamp) return byStamp;
|
|
131
|
+
const sessionWorkspace = readNonEmptyString(state?.workspace);
|
|
132
|
+
const bySessionWorkspace = sessionWorkspace ? readNonEmptyString(getCachedMeshByWorkspace(sessionWorkspace)?.id) : '';
|
|
133
|
+
if (bySessionWorkspace) return bySessionWorkspace;
|
|
134
|
+
} catch { /* best-effort — fall through to unresolved */ }
|
|
135
|
+
}
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
94
139
|
export function __resetIdleAutoFastForwardForTests(): void {
|
|
95
140
|
idleAutoFastForwardLastAttempt.clear();
|
|
96
141
|
}
|
|
@@ -595,9 +640,15 @@ export function tryAssignQueueTask(
|
|
|
595
640
|
// claiming session's providerType + node policy are both known, then enforced
|
|
596
641
|
// inside the atomic claim transaction so concurrent claims can't overshoot it.
|
|
597
642
|
const providerMaxParallel = resolveProviderMaxParallel(node?.policy, providerType);
|
|
643
|
+
// WTDISPATCH-FANOUT: tell the atomic claim whether the claiming node is a worktree
|
|
644
|
+
// clone so a `convergence` task (base-only: merge → push → cleanup) is refused for
|
|
645
|
+
// worktree sessions. Without it, every sibling worktree session on this daemon could
|
|
646
|
+
// claim the same convergence intent and race push/production-deploy (the 4-way fan-out).
|
|
647
|
+
const nodeIsWorktree = node?.isLocalWorktree === true;
|
|
598
648
|
const task = claimNextTask(meshId, nodeId, sessionId, capabilityTags, {
|
|
599
649
|
providerType,
|
|
600
650
|
...(providerMaxParallel !== undefined ? { providerMaxParallel } : {}),
|
|
651
|
+
nodeIsWorktree,
|
|
601
652
|
});
|
|
602
653
|
if (!task) {
|
|
603
654
|
return false;
|
|
@@ -1189,6 +1240,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1189
1240
|
// dropped a target node whose identity arrived under a different form (a freshly
|
|
1190
1241
|
// mesh_clone_node'd worktree), emptying candidateNodes and mislabelling the skip.
|
|
1191
1242
|
if (task.targetNodeId && !meshNodeIdMatches(node, task.targetNodeId)) return false;
|
|
1243
|
+
// WTDISPATCH-FANOUT: a convergence task is base-only (it merges/pushes onto
|
|
1244
|
+
// base). Never auto-launch a worktree-clone session for it — that is the very
|
|
1245
|
+
// fan-out the claim guard refuses, so spinning the session up would only waste
|
|
1246
|
+
// a launch that can never claim. Mirrors claimNextQueueTask's convergence gate.
|
|
1247
|
+
if (task.taskMode === 'convergence' && node?.isLocalWorktree === true) return false;
|
|
1192
1248
|
// Skip nodes that can never satisfy requiredTags regardless of which provider
|
|
1193
1249
|
// from providerPriority is selected. A node satisfies tags if at least one
|
|
1194
1250
|
// provider in its priority list would produce matching capability tags.
|
|
@@ -2656,15 +2712,23 @@ function forwardUnresolvedDelegateEvent(
|
|
|
2656
2712
|
if (!eventName) return false;
|
|
2657
2713
|
|
|
2658
2714
|
// Flat payload mirroring buildForwardPayloadFromPending / what handleMeshForwardEvent
|
|
2659
|
-
// reads.
|
|
2660
|
-
//
|
|
2661
|
-
// coordinator can name and locate the node.
|
|
2715
|
+
// reads. nodeId/workspace come from the worker envelope so the coordinator can name and
|
|
2716
|
+
// locate the node.
|
|
2662
2717
|
const payload: Record<string, unknown> = {
|
|
2663
2718
|
...event,
|
|
2664
2719
|
event: eventName,
|
|
2665
2720
|
nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId) || undefined,
|
|
2666
2721
|
workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
|
|
2667
2722
|
};
|
|
2723
|
+
// RECONCILE-MESHID-DROP: stamp meshId when the WORKER can resolve it (member node /
|
|
2724
|
+
// live-session meshNodeFor). Historically omitted "because the worker can't resolve
|
|
2725
|
+
// it", but for a member-hosted node a no_node_binding session's coordinator-side
|
|
2726
|
+
// recovery (empty payload nodeId + workspace cache miss) fails and the retry is
|
|
2727
|
+
// rejected "meshId required" forever. Resolving here makes the forward self-sufficient;
|
|
2728
|
+
// when unresolvable even here it stays absent and the coordinator's own workspace/nodeId
|
|
2729
|
+
// recovery still runs (unchanged), with the retry cap as the loop backstop.
|
|
2730
|
+
const resolvedMeshId = resolveForwardEventMeshId(components, payload);
|
|
2731
|
+
if (resolvedMeshId) payload.meshId = resolvedMeshId;
|
|
2668
2732
|
|
|
2669
2733
|
// Self-addressed fallback: the resolved coordinator IS this daemon (a self-
|
|
2670
2734
|
// coordinating / single-node mesh, or a delegate whose coordinator anchor resolved
|