@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.
@@ -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 the file was larger.
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 lines from the raw tail window. */
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
- * with optional grep (regex source) and sinceMs filters. Falls back to the
44
- * size-rotation backup (`*.1.log`) when the primary file does not exist.
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.371",
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.371",
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
- if (opts?.dir) {
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
  },
@@ -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 the file was larger.
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 lines from the raw tail window. */
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
- * with optional grep (regex source) and sinceMs filters. Falls back to the
128
- * size-rotation backup (`*.1.log`) when the primary file does not exist.
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
- let logPath = resolveLogPath(args.date);
134
-
135
- // Fall back to the size-rotation backup if the active file is absent.
136
- if (!fs.existsSync(logPath)) {
137
- const backup = logPath.replace(/\.log$/, '.1.log');
138
- if (fs.existsSync(backup)) {
139
- logPath = backup;
140
- } else {
141
- return {
142
- success: false,
143
- error: `No daemon log file at ${logPath} (dir: ${getDaemonLogDir()})`,
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
- let raw: { text: string; truncated: boolean; bytesReturned: number };
155
- try {
156
- raw = readByteBoundedTail(logPath, limitBytes);
157
- } catch (e: any) {
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: false,
160
- error: `Failed to read ${logPath}: ${e?.message ?? String(e)}`,
161
- lines: [],
162
- truncated: false,
271
+ success: true,
272
+ lines,
273
+ truncated: raw.truncated,
163
274
  logPath,
164
275
  platform,
165
- bytesReturned: 0,
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
- let lines = raw.text.split('\n');
171
- // A trailing newline yields a final empty element drop it.
172
- if (lines.length && lines[lines.length - 1] === '') lines.pop();
173
- const rawCount = lines.length;
174
-
175
- // since filter
176
- if (Number.isFinite(args.sinceMs)) {
177
- const fileDate = args.date instanceof Date
178
- ? args.date
179
- : typeof args.date === 'string' && args.date.trim()
180
- ? new Date(`${args.date.trim()}T00:00:00.000Z`)
181
- : new Date();
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 (typeof args.grep === 'string' && args.grep.trim()) {
193
- appliedGrep = args.grep.trim();
194
- let re: RegExp | null = null;
195
- try {
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: raw.truncated,
329
+ lines: capped.kept,
330
+ truncated: capped.truncated,
214
331
  logPath,
215
332
  platform,
216
- bytesReturned: raw.bytesReturned,
217
- filtered: lines.length !== rawCount,
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. meshId is omitted on purpose the worker can't resolve it; the coordinator
2660
- // recovers it from workspace. nodeId/workspace come from the worker envelope so the
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