@thehammer/danx-session-telemetry 0.1.13 → 0.1.14

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/README.md CHANGED
@@ -41,7 +41,7 @@ own header comments for the per-file reasoning.
41
41
  |---|---|---|
42
42
  | **danxbot worker** (root repo, `tsx src/index.ts`) | npm **workspace** link (`"workspaces": ["packages/*"]` in root `package.json`, `"@thehammer/danx-session-telemetry"` in root `dependencies`) | The worker and this package always ship from the SAME repo commit — a workspace link means the worker runs whatever source the current commit carries, with zero drift risk and no dependency on registry propagation timing. The Docker image builds `dist/` for it in a dedicated builder stage (mirroring `dashboard-builder`/`frontend-builder` in `Dockerfile`) since the final image's `npm ci --omit=dev` never installs this package's own `devDependencies` (`typescript`). |
43
43
  | **`danx-dashboard-mcp` desktop bridge** (separate npm-published package, DX-2954) | Ordinary npm dependency, **pinned to the exact published version** the worker's commit ships | The bridge is a genuinely separate installable artifact (`npx -y @thehammer/danx-dashboard-mcp`), so it cannot workspace-link into danxbot's own checkout — it needs the real published bytes. Pinning exact (not `^`/`~`) is what makes "worker and MCP run identical collector code for any given release" true: MCP's own version bump is the only thing that ever changes what collector code it runs. |
44
- | **Dashboard server / dashboard SPA** (`src/dashboard/*`, `dashboard/*`) | Same npm dependency shape as the worker (dashboard's `src/` is root-repo code, so it resolves through the SAME root workspace link as the worker; `dashboard/` is its own separate npm package — DX-2963, the first real consumer there, adds an ordinary `dashboard/package.json` dependency, resolved by Vite/Vitest/`vue-tsc` through plain node_modules resolution, no alias needed) | No `@backend`-style alias is needed for an npm dependency the way one is needed for `dashboard/`'s reach into root `src/` — that alias exists because `dashboard/` is a SEPARATE package with no other way to reach root source; a real npm dependency resolves through ordinary node_modules lookup in every one of Vite's two configs, Vitest, `vue-tsc`, and the `dashboard-builder` Dockerfile stage (which already runs `cd dashboard && npm ci` AFTER the root repo — including `packages/`— is copied in, so the dependency is resolvable with zero Dockerfile change). `dashboard/` is FROZEN (`.claude/rules/ui-deprecation.md`) — this card adds no dependency there since nothing consumes it yet; DX-2963 adds the one line when it wires the real counter into `DispatchDetail.vue`/`DispatchList.vue`. |
44
+ | **Dashboard server / dashboard SPA** (`src/dashboard/*`, `dashboard/*`) | Same npm dependency shape as the worker (dashboard's `src/` is root-repo code, so it resolves through the SAME root workspace link as the worker; `dashboard/` is its own separate npm package — DX-2963, the first real consumer there, adds an ordinary `dashboard/package.json` dependency, resolved by Vite/Vitest/`vue-tsc` through plain node_modules resolution, no alias needed) | No `@backend`-style alias is needed for an npm dependency the way one is needed for `dashboard/`'s reach into root `src/` — that alias exists because `dashboard/` is a SEPARATE package with no other way to reach root source; a real npm dependency resolves through ordinary node_modules lookup in every one of Vite's two configs, Vitest, `vue-tsc`, and the Dockerfile builder stages (`dashboard-deps-builder` runs `cd dashboard && npm ci` from `dashboard/`'s own manifests, so a `dashboard/package.json` dependency is resolvable with zero Dockerfile change). `dashboard/` is FROZEN (`.claude/rules/ui-deprecation.md`) — this card adds no dependency there since nothing consumes it yet; DX-2963 adds the one line when it wires the real counter into `DispatchDetail.vue`/`DispatchList.vue`. |
45
45
 
46
46
  ## Release order (DX-2962 AC3)
47
47
 
@@ -219,52 +219,29 @@ export async function findSessionFileByDispatchId(dir, dispatchId) {
219
219
  try {
220
220
  const files = await readdir(dir);
221
221
  const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
222
- for (const file of jsonlFiles) {
223
- const filePath = join(dir, file);
224
- let fd;
225
- try {
226
- fd = await open(filePath, "r");
227
- const fileStats = await stat(filePath);
228
- if (await windowIncludesTag(fd, 0, fileStats.size, SCAN_BYTES, tag)) {
229
- return filePath;
230
- }
231
- // Resume: the new tag is at the tail of a large prior-session file.
232
- // Skip when the file fits in one head window (already fully read).
233
- if (fileStats.size > SCAN_BYTES) {
234
- const tailStart = fileStats.size - SCAN_BYTES;
235
- if (await windowIncludesTag(fd, tailStart, fileStats.size, SCAN_BYTES, tag)) {
236
- return filePath;
237
- }
238
- }
239
- }
240
- catch (err) {
241
- // DX-1201 — a single unreadable SIBLING file must NOT abort the
242
- // whole scan. Dispatched agents share a busy session dir (e.g.
243
- // every issue-worker dispatch of one repo writing under the same
244
- // encoded `~/.claude/projects/<cwd>/` dir), so any one of the
245
- // co-located multi-MB files can momentarily fail an `open`/`stat`/
246
- // `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
247
- // worktree torn down under the agent), EACCES (perms race), EMFILE
248
- // (fd pressure across N concurrent watchers). Before this guard the
249
- // throw propagated out of the per-file body and rejected the entire
250
- // `findSessionFileByDispatchId` call → `discoverSessionFile` →
251
- // `start()` (fire-and-forget, no `.catch`) → the watcher silently
252
- // never attached → null `session_uuid` + zero usage/tool-call
253
- // counters on ~20% of host dispatches (intermittent because the
254
- // outcome hinged on `readdir` ordering vs the failing sibling).
255
- // Skip this file and keep scanning the rest — the tagged file the
256
- // caller actually wants may be later in the directory.
257
- const code = err.code;
258
- if (code !== "ENOENT") {
259
- log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
260
- }
261
- continue;
262
- }
263
- finally {
264
- await fd?.close();
265
- }
266
- }
267
- return null;
222
+ // DX-3395 — scan every sibling CONCURRENTLY instead of one at a time.
223
+ // A dispatched agent's cwd is a busy, SHARED session dir (DX-1201's own
224
+ // comment below: every issue-worker dispatch of one card writes under
225
+ // the same encoded `~/.claude/projects/<cwd>/` dir), so this directory
226
+ // accumulates one multi-MB `.jsonl` per distinct session ever launched
227
+ // there. The previous `for...of` paid each sibling's bounded
228
+ // open+stat+read (<=2*SCAN_BYTES) back-to-back even though they are
229
+ // independent filesystem operations the OS can service in parallel —
230
+ // this is the ONE stage DX-3395's resume-latency investigation found
231
+ // that is both real, sequential, and entirely danxbot-owned (unlike the
232
+ // dominant cost, `claude --resume`'s own session reload, which lives
233
+ // outside this codebase — see the card for the measured comparison).
234
+ // `Promise.all` lets every sibling's I/O overlap; each check keeps its
235
+ // own try/catch (unchanged from the DX-1201 per-file isolation below),
236
+ // so one unreadable sibling still can never abort the others. At most
237
+ // ONE sibling can ever contain a given dispatch's unique tag, so which
238
+ // promise settles first is never ambiguous — the result is looked up by
239
+ // `files`' ORIGINAL index, so the return value stays deterministic
240
+ // regardless of resolution order (proven by the multi-sibling test in
241
+ // `session-log-watcher.test.ts`).
242
+ const hits = await Promise.all(jsonlFiles.map((file) => fileContainsTag(dir, file, tag, SCAN_BYTES)));
243
+ const index = hits.findIndex(Boolean);
244
+ return index === -1 ? null : join(dir, jsonlFiles[index]);
268
245
  }
269
246
  catch (err) {
270
247
  if (err.code === "ENOENT")
@@ -272,6 +249,56 @@ export async function findSessionFileByDispatchId(dir, dispatchId) {
272
249
  throw err;
273
250
  }
274
251
  }
252
+ /**
253
+ * One sibling's head+tail scan for `findSessionFileByDispatchId`'s
254
+ * concurrent `Promise.all` — factored out so each file's try/catch/finally
255
+ * is self-contained and never affects any other sibling's check.
256
+ */
257
+ async function fileContainsTag(dir, file, tag, scanBytes) {
258
+ const filePath = join(dir, file);
259
+ let fd;
260
+ try {
261
+ fd = await open(filePath, "r");
262
+ const fileStats = await stat(filePath);
263
+ if (await windowIncludesTag(fd, 0, fileStats.size, scanBytes, tag)) {
264
+ return true;
265
+ }
266
+ // Resume: the new tag is at the tail of a large prior-session file.
267
+ // Skip when the file fits in one head window (already fully read).
268
+ if (fileStats.size > scanBytes) {
269
+ const tailStart = fileStats.size - scanBytes;
270
+ if (await windowIncludesTag(fd, tailStart, fileStats.size, scanBytes, tag)) {
271
+ return true;
272
+ }
273
+ }
274
+ return false;
275
+ }
276
+ catch (err) {
277
+ // DX-1201 — a single unreadable SIBLING file must NOT abort the
278
+ // whole scan. Dispatched agents share a busy session dir (e.g.
279
+ // every issue-worker dispatch of one repo writing under the same
280
+ // encoded `~/.claude/projects/<cwd>/` dir), so any one of the
281
+ // co-located multi-MB files can momentarily fail an `open`/`stat`/
282
+ // `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
283
+ // worktree torn down under the agent), EACCES (perms race), EMFILE
284
+ // (fd pressure across N concurrent watchers). Before this guard the
285
+ // throw propagated out of the per-file body and rejected the entire
286
+ // `findSessionFileByDispatchId` call → `discoverSessionFile` →
287
+ // `start()` (fire-and-forget, no `.catch`) → the watcher silently
288
+ // never attached → null `session_uuid` + zero usage/tool-call
289
+ // counters on ~20% of host dispatches (intermittent because the
290
+ // outcome hinged on `readdir` ordering vs the failing sibling).
291
+ // Report this sibling as a non-match and let the others resolve.
292
+ const code = err.code;
293
+ if (code !== "ENOENT") {
294
+ log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
295
+ }
296
+ return false;
297
+ }
298
+ finally {
299
+ await fd?.close();
300
+ }
301
+ }
275
302
  /**
276
303
  * Read a `<= maxBytes` window starting at `from` (clamped to the file) and
277
304
  * report whether `tag` appears in it. Factored out so the head + tail scans
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp desktop bridge, so both run identical collector code.",
5
5
  "license": "MIT",
6
6
  "type": "module",