@adhdev/daemon-core 0.9.82-rc.372 → 0.9.82-rc.374
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 +211 -65
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +214 -69
- package/dist/index.mjs.map +1 -1
- package/dist/logging/log-tail-reader.d.ts +40 -5
- package/dist/mesh/mesh-events-stale.d.ts +12 -0
- package/dist/providers/chat-message-normalization.d.ts +1 -1
- package/package.json +2 -2
- package/src/commands/low-family/mesh-node-logs.ts +6 -0
- package/src/commands/med-family/mesh-crud.ts +12 -2
- 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 +19 -0
- package/src/mesh/mesh-events-stale.ts +23 -0
- package/src/mesh/mesh-events-utils.ts +1 -1
- package/src/mesh/mesh-reconcile-loop.ts +31 -9
- package/src/providers/chat-message-normalization.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;
|
|
@@ -11,6 +11,18 @@ export declare function findRecentTerminalLedgerEvidence(args: {
|
|
|
11
11
|
} | null;
|
|
12
12
|
export declare function hasDispatchAfterTerminal(meshId: string, sessionId: string, terminalId: string): boolean;
|
|
13
13
|
export declare function hasUnterminalDirectDispatchLedgerEntry(meshId: string, sessionId: string): boolean;
|
|
14
|
+
export declare function findTerminalLedgerEvidenceForTask(args: {
|
|
15
|
+
meshId: string;
|
|
16
|
+
taskId?: string;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
nodeId?: string;
|
|
19
|
+
tail?: number;
|
|
20
|
+
}): {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: Extract<MeshLedgerKind, 'task_completed' | 'task_failed' | 'task_stalled'>;
|
|
23
|
+
payload: Record<string, unknown>;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
} | null;
|
|
14
26
|
export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
|
|
15
27
|
meshId: string;
|
|
16
28
|
nodeId?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ChatMessage } from '../types.js';
|
|
2
|
-
export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS =
|
|
2
|
+
export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 16000;
|
|
3
3
|
export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
|
|
4
4
|
/**
|
|
5
5
|
* Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
|
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.374",
|
|
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.374",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -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
|
},
|
|
@@ -289,9 +289,19 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
|
|
|
289
289
|
const nodeMachineId = readMeshNodeMachineId(node as Record<string, unknown>) || '';
|
|
290
290
|
const selfDaemonId = ctx.deps.statusInstanceId || '';
|
|
291
291
|
const selfMachineId = (() => { try { return loadConfig().machineId || ''; } catch { return ''; } })();
|
|
292
|
+
// Identity match is form-safe: a daemon answers to the same machine
|
|
293
|
+
// under interchangeable id forms (bare `mach_X`, cloud `daemon_mach_X`,
|
|
294
|
+
// standalone `standalone_mach_X`). statusInstanceId/loadConfig().machineId
|
|
295
|
+
// and the node's stored daemonId/machineId frequently hold DIFFERENT forms
|
|
296
|
+
// of the same machine, so a raw `===` would miss the self-match and let the
|
|
297
|
+
// coordinator delete its own live base node (the very accident this guard
|
|
298
|
+
// exists to prevent). daemonIdsEquivalent collapses every form to its
|
|
299
|
+
// machine core before comparing, so a same-machine match is caught
|
|
300
|
+
// regardless of which form each side carries. This only widens matches
|
|
301
|
+
// (every raw-`===` hit still matches) — fail-open → fail-closed.
|
|
292
302
|
const isCoordinatorBaseNode =
|
|
293
|
-
(!!selfDaemonId && (nodeDaemonId
|
|
294
|
-
|| (!!selfMachineId && (nodeDaemonId
|
|
303
|
+
(!!selfDaemonId && (daemonIdsEquivalent(nodeDaemonId, selfDaemonId) || daemonIdsEquivalent(nodeMachineId, selfDaemonId)))
|
|
304
|
+
|| (!!selfMachineId && (daemonIdsEquivalent(nodeDaemonId, selfMachineId) || daemonIdsEquivalent(nodeMachineId, selfMachineId)));
|
|
295
305
|
if (isCoordinatorBaseNode) {
|
|
296
306
|
return {
|
|
297
307
|
success: false,
|
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
|
}
|
|
@@ -23,6 +23,7 @@ import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
|
23
23
|
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
|
|
24
24
|
import {
|
|
25
25
|
findRecentTerminalLedgerEvidence,
|
|
26
|
+
findTerminalLedgerEvidenceForTask,
|
|
26
27
|
hasDispatchAfterTerminal,
|
|
27
28
|
hasUnterminalDirectDispatchLedgerEntry,
|
|
28
29
|
buildNoProgressCompletionReconciliation,
|
|
@@ -654,6 +655,24 @@ export function tryAssignQueueTask(
|
|
|
654
655
|
return false;
|
|
655
656
|
}
|
|
656
657
|
|
|
658
|
+
const terminal = findTerminalLedgerEvidenceForTask({
|
|
659
|
+
meshId,
|
|
660
|
+
taskId: task.id,
|
|
661
|
+
});
|
|
662
|
+
if (terminal) {
|
|
663
|
+
const status = terminal.kind === 'task_completed' ? 'completed' : 'failed';
|
|
664
|
+
updateTaskStatus(meshId, task.id, status);
|
|
665
|
+
LOG.info('MeshQueue', `Skipped dispatch for terminal task ${task.id} on mesh ${meshId}; ${terminal.kind} ledger evidence already exists`);
|
|
666
|
+
traceMeshEventDrop('dispatch_terminal_ledger', {
|
|
667
|
+
taskId: task.id,
|
|
668
|
+
sessionId,
|
|
669
|
+
nodeId,
|
|
670
|
+
meshId,
|
|
671
|
+
event: 'agent_command',
|
|
672
|
+
}, terminal.kind);
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
|
|
657
676
|
LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
|
|
658
677
|
|
|
659
678
|
if (node?.daemonId && components.dispatchMeshCommand) {
|
|
@@ -85,6 +85,29 @@ function isWeakCompletionLedgerPayload(payload: Record<string, unknown> | undefi
|
|
|
85
85
|
return diag?.finalAssistantPresent === false || diag?.blockReason === 'missing_final_assistant';
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export function findTerminalLedgerEvidenceForTask(args: {
|
|
89
|
+
meshId: string;
|
|
90
|
+
taskId?: string;
|
|
91
|
+
sessionId?: string;
|
|
92
|
+
nodeId?: string;
|
|
93
|
+
tail?: number;
|
|
94
|
+
}): { id: string; kind: Extract<MeshLedgerKind, 'task_completed' | 'task_failed' | 'task_stalled'>; payload: Record<string, unknown>; timestamp: string } | null {
|
|
95
|
+
const taskId = readNonEmptyString(args.taskId);
|
|
96
|
+
if (!taskId) return null;
|
|
97
|
+
const entries = readLedgerEntries(args.meshId, { tail: args.tail ?? 500 });
|
|
98
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
99
|
+
const entry = entries[i];
|
|
100
|
+
if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed' && entry.kind !== 'task_stalled') continue;
|
|
101
|
+
const terminalTaskId = readNonEmptyString(entry.payload?.taskId);
|
|
102
|
+
if (terminalTaskId !== taskId) continue;
|
|
103
|
+
if (entry.kind === 'task_completed' && isWeakCompletionLedgerPayload(entry.payload)) continue;
|
|
104
|
+
if (args.sessionId && entry.sessionId && entry.sessionId !== args.sessionId) continue;
|
|
105
|
+
if (!args.sessionId && args.nodeId && entry.nodeId && !meshNodeIdMatches(entry as unknown as MeshNodeIdentified, args.nodeId)) continue;
|
|
106
|
+
return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
function findDirectDispatchLedgerEntry(args: {
|
|
89
112
|
meshId: string;
|
|
90
113
|
taskId: string;
|
|
@@ -109,7 +109,7 @@ const MESH_SURFACED_PREVIEW_MAX_CHARS = 512;
|
|
|
109
109
|
// coordinator-facing payload that replaces a "go call mesh_read_chat" instruction —
|
|
110
110
|
// it should carry enough of the worker's result to act on without a second round-trip,
|
|
111
111
|
// while still bounding what is written into the coordinator PTY.
|
|
112
|
-
const MESH_COMPLETION_SURFACE_MAX_CHARS =
|
|
112
|
+
const MESH_COMPLETION_SURFACE_MAX_CHARS = 16000;
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* The worker's final assistant text carried on a completion event — read from
|