@adhdev/daemon-core 0.9.82-rc.314 → 0.9.82-rc.316

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.
@@ -21,6 +21,7 @@
21
21
  import * as fs from 'node:fs';
22
22
  import * as os from 'node:os';
23
23
  import * as path from 'node:path';
24
+ import { LOG } from '../../logging/logger.js';
24
25
  import type {
25
26
  NativeHistoryConfig,
26
27
  NativeHistoryJsonlSource,
@@ -138,8 +139,48 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
138
139
  || (requestedSessionId ? null : pickSessionBoundFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor, workspaceHint))
139
140
  || (requestedSessionId ? null : newestRecentFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor));
140
141
  }
142
+ // Raw-workspace slug candidate: `resolved` derives its {cwd*} slug from
143
+ // fs.realpathSync(workspace). On Windows realpath normalizes the path
144
+ // (drive-letter case D:↔d:, \\?\ long-path prefix, junction expansion)
145
+ // so the slug can diverge from the one the CLI actually wrote — and the
146
+ // concrete path above then misses with ENOENT. Retry with the slug built
147
+ // from the RAW workspace string before falling back to a scan; it's the
148
+ // cheap fix when realpath divergence is the only problem.
149
+ if (!sourcePath && !hasDateTemplateSegment(src.path)) {
150
+ const resolvedRaw = expandPath(src.path, input, { skipWorkspaceRealpath: true });
151
+ if (resolvedRaw && resolvedRaw !== resolved) {
152
+ try {
153
+ const rawStat = fs.statSync(resolvedRaw);
154
+ if (rawStat.isFile()) sourcePath = resolvedRaw;
155
+ else if (rawStat.isDirectory()) {
156
+ sourcePath = pickExactSessionFile(resolvedRaw, filePat, requestedSessionId)
157
+ || (requestedSessionId ? null : newestRecentFile(resolvedRaw, filePat, windowMs, sessionFloor));
158
+ }
159
+ } catch { /* raw slug also missed — fall through to scan */ }
160
+ }
161
+ }
162
+ // Last-resort scan: the slug-derived directory missed entirely (the
163
+ // dominant Windows failure: realpath/raw slug both diverge from the CLI's
164
+ // on-disk project dir → 0 messages, no PTY fallback for native-source
165
+ // providers). When we have an exact session id, walk the projects root
166
+ // for `<sessionId>.jsonl` regardless of which project subdir holds it.
167
+ // The session id is a UUID, so basename matching is unambiguous; mirrors
168
+ // the standalone reader's scan (claude-cli-transcript.ts resolveTranscriptPath).
169
+ if (!sourcePath && requestedSessionId) {
170
+ sourcePath = scanProjectsRootForSessionFile(src.path, input, requestedSessionId);
171
+ }
172
+ }
173
+ if (!sourcePath) {
174
+ // Was silent before — a slug miss produced 0 messages with no trace, so
175
+ // a live read_chat returning empty was indistinguishable from "no file"
176
+ // vs "wrong path". Log the attempted concrete path + both slug variants
177
+ // so the failure mode is greppable in daemon logs.
178
+ const wsRaw = typeof input.workspace === 'string' ? input.workspace : '';
179
+ let wsReal = wsRaw;
180
+ try { if (wsRaw) wsReal = fs.realpathSync(wsRaw); } catch { /* keep raw */ }
181
+ LOG.debug('NativeHistory', `jsonl unresolved: tried=${JSON.stringify(resolved)} sessionId=${requestedSessionId || '(none)'} wsRaw=${JSON.stringify(wsRaw)} wsReal=${JSON.stringify(wsReal)} rawSlug=${JSON.stringify(claudeProjectDirName(wsRaw))} realSlug=${JSON.stringify(claudeProjectDirName(wsReal))} (concrete miss + raw-slug retry + projects scan all failed)`);
182
+ return null;
141
183
  }
142
- if (!sourcePath) return null;
143
184
 
144
185
  const mtime = safeMtimeMs(sourcePath);
145
186
  const lines = readJsonlLines(sourcePath);
@@ -278,7 +319,7 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
278
319
  // Path expansion + globbing
279
320
  // ────────────────────────────────────────────────────────────────────────────
280
321
 
281
- function expandPath(template: string, input: NativeHistoryInput): string | null {
322
+ function expandPath(template: string, input: NativeHistoryInput, opts?: { skipWorkspaceRealpath?: boolean }): string | null {
282
323
  if (!template) return null;
283
324
  let out = template;
284
325
  if (out.startsWith('~/') || out === '~') {
@@ -301,7 +342,11 @@ function expandPath(template: string, input: NativeHistoryInput): string | null
301
342
  // `-`. Realpath also handles aliases such as /tmp -> /private/tmp.
302
343
  const workspaceRaw = input.workspace ?? '';
303
344
  let workspaceResolved = workspaceRaw;
304
- if (workspaceRaw) {
345
+ // The caller may request the RAW slug (skip realpath) to recover from
346
+ // Windows realpath normalization diverging the {cwd*} slug from the dir
347
+ // the CLI actually created. Default keeps realpath (handles /tmp ->
348
+ // /private/tmp aliasing that the CLI itself resolves on macOS).
349
+ if (workspaceRaw && !opts?.skipWorkspaceRealpath) {
305
350
  try { workspaceResolved = fs.realpathSync(workspaceRaw); }
306
351
  catch { /* path may not exist yet — keep the raw value */ }
307
352
  }
@@ -333,6 +378,62 @@ function claudeProjectDirName(workspace: string): string {
333
378
  return workspace.replace(/[^A-Za-z0-9_-]/g, '-');
334
379
  }
335
380
 
381
+ /**
382
+ * Last-resort lookup for a transcript by its exact session id, ignoring the
383
+ * per-cwd slug entirely. The slug-derived directory above can miss completely
384
+ * when the CLI's on-disk project dir disagrees with the slug we reconstruct
385
+ * (notably on Windows, where fs.realpathSync normalizes drive-letter case and
386
+ * adds a \\?\ prefix). Since the session id is a UUID, scanning the projects
387
+ * root for `<sessionId>.jsonl` is unambiguous.
388
+ *
389
+ * Derives the scan base from the template's segments up to (but excluding) the
390
+ * first one that references a per-session variable ({cwd*} or {session_id}) —
391
+ * e.g. `~/.claude/projects/{cwd_claude_project}/{session_id}.jsonl` → scan
392
+ * `~/.claude/projects`. Returns the matching file path, or null.
393
+ */
394
+ function scanProjectsRootForSessionFile(template: string, input: NativeHistoryInput, requestedSessionId: string): string | null {
395
+ if (!requestedSessionId) return null;
396
+ // Resolve the leading static portion of the template (everything before the
397
+ // first {var} segment) into a concrete base directory.
398
+ let head = template;
399
+ if (head.startsWith('~/') || head === '~') head = path.join(os.homedir(), head.slice(2));
400
+ const segs = head.split('/');
401
+ const baseParts: string[] = [];
402
+ for (const seg of segs) {
403
+ if (/[{}*?]/.test(seg)) break;
404
+ baseParts.push(seg);
405
+ }
406
+ const base = baseParts.join('/');
407
+ if (!base) return null;
408
+ let baseStat: fs.Stats | null = null;
409
+ try { baseStat = fs.statSync(base); } catch { return null; }
410
+ if (!baseStat.isDirectory()) return null;
411
+
412
+ const needle = `${requestedSessionId.toLowerCase()}.jsonl`;
413
+ // Bounded walk: project layouts are <root>/<projectDir>/<uuid>.jsonl, so a
414
+ // shallow scan (root + one level of subdirs) suffices and avoids walking an
415
+ // unbounded tree. Check the root itself first, then each immediate subdir.
416
+ const dirsToScan: string[] = [base];
417
+ try {
418
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
419
+ if (entry.isDirectory()) dirsToScan.push(path.join(base, entry.name));
420
+ }
421
+ } catch { /* readdir failed — fall back to scanning base only */ }
422
+
423
+ for (const dir of dirsToScan) {
424
+ let entries: fs.Dirent[];
425
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
426
+ for (const entry of entries) {
427
+ if (!entry.isFile()) continue;
428
+ if (entry.name.toLowerCase() !== needle) continue;
429
+ const found = path.join(dir, entry.name);
430
+ LOG.debug('NativeHistory', `jsonl scan-fallback hit: sessionId=${requestedSessionId} resolved via projects-root scan → ${JSON.stringify(found)} (slug-derived path missed; likely realpath/slug divergence)`);
431
+ return found;
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+
336
437
  function globToRegex(pattern: string): RegExp {
337
438
  // Minimal glob: `*` → `[^/]*`, `?` → `[^/]`, `.` → `\.`. Anchored.
338
439
  const re = pattern
@@ -165,6 +165,106 @@ export function resolveNodeSchedulingPriority(
165
165
  export const MESH_CONVERGE_REFINE_TAG = 'converge=refine';
166
166
  export const MESH_CONVERGE_FAST_FORWARD_TAG = 'converge=fast_forward';
167
167
 
168
+ /**
169
+ * Standard mesh resource-pool roles. These are the dashboard-provided defaults for
170
+ * the role=<x> routing tag (advertised by node policy.providerRoles and matched
171
+ * through nodeSatisfiesRequiredTags). A mesh is free to declare additional custom
172
+ * role strings in policy.taskAffinity; the dashboard role dropdown exposes the union
173
+ * of these four standards plus any config-declared roles. Roles are lowercased
174
+ * everywhere (the role=<x> tag is lowercased in roleCapabilityTags).
175
+ */
176
+ export const STANDARD_MESH_ROLES = ['investigator', 'coder', 'validator', 'converger'] as const;
177
+ export type StandardMeshRole = typeof STANDARD_MESH_ROLES[number];
178
+
179
+ /**
180
+ * Default taskMode → role mapping (all five task modes). A task enqueued with a
181
+ * given taskMode auto-routes to a node advertising the mapped role=<x> tag, unless
182
+ * the mesh overrides the mapping in policy.taskAffinity.byTaskMode. Keyed by the
183
+ * MeshTaskMode string literals (the canonical union lives in mesh-work-queue.ts;
184
+ * this map is keyed by string to avoid a types ↔ queue import cycle). Kept in sync
185
+ * with MeshTaskMode by the exhaustive lookup in resolveTaskAffinityRole.
186
+ */
187
+ export const DEFAULT_TASKMODE_ROLE_MAP: Readonly<Record<string, StandardMeshRole>> = {
188
+ live_debug_readonly: 'investigator',
189
+ code_change: 'coder',
190
+ launch_app: 'coder',
191
+ validation: 'validator',
192
+ convergence: 'converger',
193
+ };
194
+
195
+ /**
196
+ * Declarative task_mode → role affinity policy. When set, enqueueTask auto-injects a
197
+ * `role=<role>` required tag (see resolveTaskAffinityRequiredTags) so the task
198
+ * hard-filters onto nodes advertising that role — but only when the caller did NOT
199
+ * already pin the task with an explicit target_node_id or its own required_tags.
200
+ *
201
+ * - `enabled`: master switch. Defaults to true so the built-in DEFAULT_TASKMODE_ROLE_MAP
202
+ * takes effect even when a mesh declares no taskAffinity block at all. Set false to
203
+ * fully disable affinity routing (today's pre-affinity behavior).
204
+ * - `byTaskMode`: per-taskMode override of the default mapping. A role string here may
205
+ * be any of STANDARD_MESH_ROLES or a custom role declared by the operator. An empty
206
+ * string disables affinity for that one task mode.
207
+ * - `customRoles`: extra (non-standard) role strings to surface in the dashboard role
208
+ * dropdown. Routing itself does not require a role to be pre-declared here — any role
209
+ * referenced in byTaskMode is injected regardless; customRoles only widens the UI list.
210
+ */
211
+ export interface RepoMeshTaskAffinityPolicy {
212
+ enabled?: boolean;
213
+ byTaskMode?: Record<string, string>;
214
+ customRoles?: string[];
215
+ }
216
+
217
+ /**
218
+ * Resolve the affinity role for a task mode against a mesh's taskAffinity policy.
219
+ * Returns the lowercased role string to inject as `role=<role>`, or null when no
220
+ * affinity applies (affinity disabled, an explicit empty override, or an unknown mode).
221
+ *
222
+ * Precedence: policy.byTaskMode[mode] (operator override) → DEFAULT_TASKMODE_ROLE_MAP[mode].
223
+ * A blank override string explicitly opts that mode out of affinity routing.
224
+ */
225
+ export function resolveTaskAffinityRole(
226
+ taskMode: string | undefined,
227
+ policy: RepoMeshTaskAffinityPolicy | null | undefined,
228
+ ): string | null {
229
+ if (!taskMode) return null;
230
+ if (policy?.enabled === false) return null;
231
+ const override = policy?.byTaskMode && Object.prototype.hasOwnProperty.call(policy.byTaskMode, taskMode)
232
+ ? policy.byTaskMode[taskMode]
233
+ : undefined;
234
+ if (typeof override === 'string') {
235
+ const trimmed = override.trim().toLowerCase();
236
+ // An explicit blank override opts this task mode out of affinity routing.
237
+ return trimmed ? trimmed : null;
238
+ }
239
+ const fallback = DEFAULT_TASKMODE_ROLE_MAP[taskMode];
240
+ return fallback ?? null;
241
+ }
242
+
243
+ /**
244
+ * Union of the standard roles plus any operator-declared roles (byTaskMode values +
245
+ * customRoles), lowercased and deduped, preserving standard-first ordering. Used by
246
+ * the dashboard role dropdown so operators can pick a standard or a config-declared
247
+ * custom role. Pure/UI-facing — does not gate routing.
248
+ */
249
+ export function resolveMeshRoleOptions(policy: RepoMeshTaskAffinityPolicy | null | undefined): string[] {
250
+ const out: string[] = [...STANDARD_MESH_ROLES];
251
+ const seen = new Set<string>(out);
252
+ const add = (raw: unknown) => {
253
+ if (typeof raw !== 'string') return;
254
+ const role = raw.trim().toLowerCase();
255
+ if (!role || seen.has(role)) return;
256
+ seen.add(role);
257
+ out.push(role);
258
+ };
259
+ if (policy?.byTaskMode) {
260
+ for (const value of Object.values(policy.byTaskMode)) add(value);
261
+ }
262
+ if (Array.isArray(policy?.customRoles)) {
263
+ for (const value of policy.customRoles) add(value);
264
+ }
265
+ return out;
266
+ }
267
+
168
268
  /**
169
269
  * Resolve whether the load-balancing scheduler should auto-inject a
170
270
  * `converge=refine` required tag onto code_change tasks so they hard-filter onto
@@ -220,6 +320,18 @@ export interface RepoMeshPolicy {
220
320
  * Defaults to false: code_change routing is unchanged unless opted in.
221
321
  */
222
322
  autoConvergeCodeChange?: boolean;
323
+ /**
324
+ * Declarative task_mode → role affinity routing. When present (and not disabled),
325
+ * enqueueTask auto-injects a `role=<role>` required tag for the task's taskMode so
326
+ * the work hard-filters onto nodes advertising that role. Explicit target_node_id
327
+ * routing and any caller-supplied required_tags are preserved (auto-injection only
328
+ * applies when the caller pinned neither). When the mapped role has no advertising
329
+ * node in the mesh, the task is NOT blocked — the role tag is skipped so the work
330
+ * falls back to ordinary least_loaded eligibility (soft affinity). Defaults to the
331
+ * built-in DEFAULT_TASKMODE_ROLE_MAP even when this block is omitted; set
332
+ * `{ enabled: false }` to fully restore pre-affinity routing.
333
+ */
334
+ taskAffinity?: RepoMeshTaskAffinityPolicy;
223
335
  /**
224
336
  * Whether sessions spawned by mesh/coordinator policy should auto-open as visible
225
337
  * dashboard tabs or start hidden. Defaults to 'visible' to preserve existing