@newrelic/preflight 1.4.46 → 1.5.0

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.
Files changed (60) hide show
  1. package/dist/dashboard/index.d.ts +1 -1
  2. package/dist/dashboard/index.d.ts.map +1 -1
  3. package/dist/dashboard/live-event-bus.d.ts +32 -0
  4. package/dist/dashboard/live-event-bus.d.ts.map +1 -1
  5. package/dist/dashboard/live-event-bus.js.map +1 -1
  6. package/dist/dashboard/routes/api-handler.d.ts +46 -0
  7. package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
  8. package/dist/dashboard/routes/api-handler.js +188 -5
  9. package/dist/dashboard/routes/api-handler.js.map +1 -1
  10. package/dist/dashboard/subagent-timeline-store.d.ts +156 -0
  11. package/dist/dashboard/subagent-timeline-store.d.ts.map +1 -0
  12. package/dist/dashboard/subagent-timeline-store.js +674 -0
  13. package/dist/dashboard/subagent-timeline-store.js.map +1 -0
  14. package/dist/dashboard/workflow-store.d.ts +85 -0
  15. package/dist/dashboard/workflow-store.d.ts.map +1 -0
  16. package/dist/dashboard/workflow-store.js +330 -0
  17. package/dist/dashboard/workflow-store.js.map +1 -0
  18. package/dist/hooks/event-processor.d.ts +86 -1
  19. package/dist/hooks/event-processor.d.ts.map +1 -1
  20. package/dist/hooks/event-processor.js +182 -0
  21. package/dist/hooks/event-processor.js.map +1 -1
  22. package/dist/hooks/subagent-watcher.d.ts +182 -0
  23. package/dist/hooks/subagent-watcher.d.ts.map +1 -0
  24. package/dist/hooks/subagent-watcher.js +765 -0
  25. package/dist/hooks/subagent-watcher.js.map +1 -0
  26. package/dist/hooks/workflow-script-parser.d.ts +44 -0
  27. package/dist/hooks/workflow-script-parser.d.ts.map +1 -0
  28. package/dist/hooks/workflow-script-parser.js +255 -0
  29. package/dist/hooks/workflow-script-parser.js.map +1 -0
  30. package/dist/hooks/workflow-watcher.d.ts +65 -0
  31. package/dist/hooks/workflow-watcher.d.ts.map +1 -0
  32. package/dist/hooks/workflow-watcher.js +402 -0
  33. package/dist/hooks/workflow-watcher.js.map +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +315 -11
  36. package/dist/index.js.map +1 -1
  37. package/dist/metrics/cost-tracker.d.ts +113 -17
  38. package/dist/metrics/cost-tracker.d.ts.map +1 -1
  39. package/dist/metrics/cost-tracker.js +151 -8
  40. package/dist/metrics/cost-tracker.js.map +1 -1
  41. package/dist/metrics/workflow-run-tracker.d.ts +140 -0
  42. package/dist/metrics/workflow-run-tracker.d.ts.map +1 -0
  43. package/dist/metrics/workflow-run-tracker.js +306 -0
  44. package/dist/metrics/workflow-run-tracker.js.map +1 -0
  45. package/dist/storage/session-store.d.ts +8 -0
  46. package/dist/storage/session-store.d.ts.map +1 -1
  47. package/dist/storage/session-store.js +1 -1
  48. package/dist/storage/session-store.js.map +1 -1
  49. package/dist/storage/types.d.ts +40 -1
  50. package/dist/storage/types.d.ts.map +1 -1
  51. package/dist/transport/nr-ingest.d.ts +153 -2
  52. package/dist/transport/nr-ingest.d.ts.map +1 -1
  53. package/dist/transport/nr-ingest.js +276 -1
  54. package/dist/transport/nr-ingest.js.map +1 -1
  55. package/dist/web/assets/index-B4DkT4Go.css +2 -0
  56. package/dist/web/assets/index-CWwZdwYX.js +64 -0
  57. package/dist/web/index.html +2 -2
  58. package/package.json +1 -1
  59. package/dist/web/assets/index-C5S1WHiP.js +0 -64
  60. package/dist/web/assets/index-DwBQxRYb.css +0 -2
package/dist/index.js CHANGED
@@ -42,6 +42,11 @@ import { LiveSessionRegistry } from './metrics/live-session-registry.js';
42
42
  import { TurnCostAttributor } from './metrics/turn-cost-attributor.js';
43
43
  import { TurnTracker } from './metrics/turn-tracker.js';
44
44
  import { GitEfficiencyTracker, parseDefaultBranchFromSymbolicRef, } from './metrics/git-efficiency-tracker.js';
45
+ import { WorkflowRunTracker } from './metrics/workflow-run-tracker.js';
46
+ import { SubagentWatcher } from './hooks/subagent-watcher.js';
47
+ import { WorkflowWatcher } from './hooks/workflow-watcher.js';
48
+ import { WorkflowStore } from './dashboard/workflow-store.js';
49
+ import { SubagentTimelineStore } from './dashboard/subagent-timeline-store.js';
45
50
  import { NrIngestManager } from './transport/nr-ingest.js';
46
51
  import { AuditTrailManager } from './security/audit-trail.js';
47
52
  import { LiveEventBus } from './dashboard/index.js';
@@ -426,10 +431,19 @@ async function main() {
426
431
  let alertRulesWatchTimer;
427
432
  let localStoreForShutdown;
428
433
  let gcInterval;
434
+ // Periodic local session-JSON flush so a non-clean exit (crash/SIGKILL)
435
+ // loses at most one interval of session data — persistSession otherwise runs
436
+ // only on clean shutdown. Cleared in the shutdown handler. Synthetic /
437
+ // provisional ids (pending-/local-/proxy-) are skipped inside persistSession.
438
+ let sessionPersistInterval;
429
439
  // When this MCP starts headless (EADDRINUSE skip), this interval retries
430
440
  // dashboardServer.start() periodically so we can take over if the current
431
441
  // owner exits. Cleared in the shutdown handler.
432
442
  let dashboardRepollInterval;
443
+ // Watcher instances are declared here so the shutdown handler can stop them
444
+ // regardless of which mode (stdio vs. local) is active.
445
+ let activeSubagentWatcher = null;
446
+ let activeWorkflowWatcher = null;
433
447
  // Aborts the async resolveSessionId polling loop when shutdown fires so
434
448
  // the breadcrumb poll does not outlive the process.
435
449
  let sessionResolutionAbort;
@@ -456,6 +470,8 @@ async function main() {
456
470
  clearInterval(gcInterval);
457
471
  if (dashboardRepollInterval)
458
472
  clearInterval(dashboardRepollInterval);
473
+ if (sessionPersistInterval)
474
+ clearInterval(sessionPersistInterval);
459
475
  // Remove this MCP's heartbeat so the next dashboard-owner GC pass
460
476
  // doesn't have to mtime-archive our buffer file.
461
477
  localStoreForShutdown?.removeHeartbeat();
@@ -480,6 +496,8 @@ async function main() {
480
496
  alertRulesWatcher = undefined;
481
497
  }
482
498
  eventProcessor?.stop();
499
+ activeSubagentWatcher?.stop();
500
+ activeWorkflowWatcher?.stop();
483
501
  liveSessionRegistry?.stopSampling();
484
502
  // Use allSettled so a failure in one stop() doesn't prevent the others.
485
503
  const stopResults = await Promise.allSettled([
@@ -649,6 +667,19 @@ async function main() {
649
667
  const turnCostAttributor = new TurnCostAttributor();
650
668
  const turnTracker = new TurnTracker();
651
669
  const gitEfficiencyTracker = new GitEfficiencyTracker();
670
+ const workflowRunTracker = new WorkflowRunTracker();
671
+ // Read-only filesystem reader for `/api/workflows` routes.
672
+ // Constructed eagerly (not just inside the dashboard block) so when only
673
+ // the stdio MCP is running, the cost tracker still gets per-run lookups
674
+ // when the watcher's reconciliation pass needs them.
675
+ const workflowStoreInstance = new WorkflowStore({
676
+ getCostForRun: (runId) => costTracker.getCostForWorkflowRun(runId),
677
+ });
678
+ // Per-session subagent timeline reader — backs the "agent fan-out"
679
+ // swimlane chart (GET /api/sessions/:sessionId/subagents). On-demand,
680
+ // bounded, and mtime-cached; reads the same subagent JSONL transcripts the
681
+ // watcher tails, but only for the one session the dashboard asks about.
682
+ const subagentTimelineInstance = new SubagentTimelineStore({});
652
683
  const toolCallBuffer = [];
653
684
  const toolCallBufferAccessor = {
654
685
  getRecords: () => toolCallBuffer,
@@ -967,6 +998,48 @@ async function main() {
967
998
  localStore: {
968
999
  peekAllBuffers: () => localStore.peekAllBuffers(),
969
1000
  },
1001
+ // Workflow store reads on-disk wf_*.json rollups so
1002
+ // the /api/workflows endpoints work even when the watcher is
1003
+ // disabled — dashboard surfaces are functional from day one.
1004
+ workflowStore: workflowStoreInstance,
1005
+ // Agent fan-out swimlane data for one session (on-demand, bounded,
1006
+ // mtime-cached). Wrapped so the dashboard tree only sees the single
1007
+ // method it needs.
1008
+ subagentTimeline: {
1009
+ getSubagentsForSession: (id) => subagentTimelineInstance.getSubagentsForSession(id),
1010
+ getAgentCalls: (s, a) => subagentTimelineInstance.getAgentCalls(s, a),
1011
+ },
1012
+ // Wire the observability-health snapshot so GET
1013
+ // /api/observability-health returns live watcher state instead of
1014
+ // always 503-ing, and /api/cost's `reconciliationDeltaPct` resolves
1015
+ // to a real value instead of always null. Read lazily at request time
1016
+ // via the `activeSubagentWatcher` binding (this `api` object is built
1017
+ // before the watcher is constructed, but getSnapshot only fires on an
1018
+ // HTTP request — long after startWatchers() has run).
1019
+ //
1020
+ // The SubagentWatcher does not expose a public health accessor today,
1021
+ // so we report the honest minimum: whether the watcher is active. When
1022
+ // it is disabled (binding null) we return a zeroed "disabled" snapshot
1023
+ // rather than throw, so the endpoint degrades gracefully. The 1h cost
1024
+ // self-check delta is not surfaced through a readable accessor yet, so
1025
+ // costSelfCheckDeltaPct is null (honest) — it leaves the dashboard's
1026
+ // reconciliation banner hidden until that plumbing lands.
1027
+ observabilityHealth: {
1028
+ getSnapshot: () => {
1029
+ // Read live counters off the SubagentWatcher when it's running.
1030
+ // A null binding => watcher disabled (env flag off / wrong mode)
1031
+ // => a zeroed "disabled" snapshot. costSelfCheckDeltaPct stays
1032
+ // null until the 1h self-check is wired.
1033
+ const stats = activeSubagentWatcher?.getHealthStats();
1034
+ return {
1035
+ watcherActive: activeSubagentWatcher !== null,
1036
+ filesWatched: stats?.filesWatched ?? 0,
1037
+ parseErrors: stats?.parseErrors ?? 0,
1038
+ watcherDisabledByLock: stats?.watcherDisabledByLock ?? false,
1039
+ costSelfCheckDeltaPct: null,
1040
+ };
1041
+ },
1042
+ },
970
1043
  },
971
1044
  alertEngine,
972
1045
  alertLog,
@@ -1317,8 +1390,88 @@ async function main() {
1317
1390
  });
1318
1391
  }
1319
1392
  },
1393
+ // Feed each Agent-tool ToolCallRecord into the workflow tracker
1394
+ // so AiWorkflowRun events ship for `run_source='agent_tool'`.
1395
+ onWorkflowAgent: (record) => {
1396
+ workflowRunTracker.recordToolCall(record);
1397
+ // Drain immediately — recordToolCall already pushes the completed run
1398
+ // into the drainable queue, so each Agent call yields exactly one
1399
+ // AiWorkflowRun event with no harvest-tick latency.
1400
+ for (const run of workflowRunTracker.drainCompleted()) {
1401
+ capturedNrIngest?.ingestWorkflowRun(run);
1402
+ }
1403
+ },
1404
+ // Subagent JSONL transcripts are the only place per-agent
1405
+ // tokens (cache_read 91.5% of total!) are visible. Route through the
1406
+ // CostTracker with the entry's `timestamp_ms` as the `ctx.timestampMs`
1407
+ // override so cross-midnight runs bucket correctly, AND emit one
1408
+ // `AiSubagentTurn` event per turn for NR-side queryability.
1409
+ onSubagentTurn: (turn) => {
1410
+ if (!costTracker || !config)
1411
+ return;
1412
+ const usage = {
1413
+ inputTokens: turn.inputTokens,
1414
+ outputTokens: turn.outputTokens,
1415
+ // Subagent reasoning tokens (extended thinking) live under
1416
+ // `output_tokens_details.reasoning_tokens`; map to `thinkingTokens`
1417
+ // so the existing `thinkingPerMTok` rate column charges correctly.
1418
+ thinkingTokens: turn.reasoningTokens,
1419
+ cacheReadTokens: turn.cacheReadTokens,
1420
+ cacheCreationTokens: turn.cacheCreationTokens,
1421
+ totalTokens: turn.inputTokens +
1422
+ turn.outputTokens +
1423
+ turn.reasoningTokens +
1424
+ turn.cacheReadTokens +
1425
+ turn.cacheCreationTokens,
1426
+ };
1427
+ const breakdown = costTracker.recordTokenUsage(usage, turn.model, {
1428
+ timestampMs: turn.timestampMs,
1429
+ workflowRunId: turn.workflowRunId,
1430
+ agentId: turn.agentId,
1431
+ });
1432
+ // Pricing miss → usd:null on the wire; we recompute here so
1433
+ // the breakdown view distinguishes "0 because pricing absent" from
1434
+ // "0 because the turn truly had zero cost".
1435
+ const usd = breakdown.totalUsd > 0 ? breakdown.totalUsd : null;
1436
+ capturedNrIngest?.ingestSubagentTurn({
1437
+ workflow_run_id: turn.workflowRunId,
1438
+ agent_id: turn.agentId,
1439
+ parent_session_id: turn.parentSessionId,
1440
+ message_id: turn.messageId,
1441
+ turn_uuid: turn.turnUuid,
1442
+ timestamp_ms: turn.timestampMs,
1443
+ model: turn.model,
1444
+ input_tokens: turn.inputTokens,
1445
+ output_tokens: turn.outputTokens,
1446
+ cache_creation_tokens: turn.cacheCreationTokens,
1447
+ cache_read_tokens: turn.cacheReadTokens,
1448
+ reasoning_tokens: turn.reasoningTokens,
1449
+ usd,
1450
+ stop_reason: turn.stopReason,
1451
+ schema_fingerprint: turn.schemaFingerprint,
1452
+ });
1453
+ },
1454
+ onObservabilityHealth: (health) => {
1455
+ capturedNrIngest?.ingestObservabilityHealth({
1456
+ timestamp: health.timestamp,
1457
+ watcher: health.watcher,
1458
+ files_watched: health.filesWatched,
1459
+ lines_read: health.linesRead,
1460
+ bytes_read: health.bytesRead,
1461
+ parse_errors: health.parseErrors,
1462
+ schema_drifts: health.schemaDrifts,
1463
+ last_error: health.lastError,
1464
+ ...(health.event ? { event: health.event } : {}),
1465
+ ...(health.dimension ? { dimension: health.dimension } : {}),
1466
+ ...(health.fingerprint ? { fingerprint: health.fingerprint } : {}),
1467
+ ...(health.workflowRunId ? { workflow_run_id: health.workflowRunId } : {}),
1468
+ ...(typeof health.costSelfCheckDeltaPct === 'number'
1469
+ ? { cost_self_check_delta_pct: health.costSelfCheckDeltaPct }
1470
+ : {}),
1471
+ });
1472
+ },
1320
1473
  });
1321
- persistSession = () => {
1474
+ persistSession = (opts) => {
1322
1475
  if (!sessionStore || !sessionTracker || !taskDetector || !config)
1323
1476
  return;
1324
1477
  try {
@@ -1330,6 +1483,10 @@ async function main() {
1330
1483
  efficiencyScorer,
1331
1484
  developer: config.developer ?? 'unknown',
1332
1485
  repoName: currentRepoName,
1486
+ // A periodic checkpoint is a live, in-progress session — persisting it
1487
+ // as 'completed' makes the dashboard render a still-running session as
1488
+ // done. Only the terminal (shutdown) save marks it completed.
1489
+ outcome: opts?.periodic ? 'in progress' : 'completed',
1333
1490
  platform: eventProcessor?.activePlatform,
1334
1491
  instructionPromptHash: instructionDriftTracker.promptHash,
1335
1492
  });
@@ -1338,27 +1495,156 @@ async function main() {
1338
1495
  instructionDriftTracker.recordSessionOutcome(driftRecord);
1339
1496
  }
1340
1497
  // Skip persisting the synthetic session JSON written by --local /
1341
- // proxy modes. These IDs (local-<ts>, proxy-<ts>) are MCP-internal
1342
- // bookkeeping; they don't correspond to a real Claude Code session
1343
- // and produce confusing `local-...` rows in the dashboard's history
1344
- // view that have no useful content to show.
1498
+ // proxy modes and the provisional pending-<ts> id. These IDs are
1499
+ // MCP-internal bookkeeping; they don't correspond to a real Claude
1500
+ // Code session and produce confusing rows in the dashboard history.
1501
+ // On the periodic path this is a silent no-op (no log spam while the
1502
+ // real session id is still being resolved).
1345
1503
  const isSyntheticId = isSyntheticSessionId(summary.sessionId);
1346
1504
  if (isSyntheticId) {
1347
- logger.info('Skipping synthetic session JSON persistence', {
1348
- sessionId: summary.sessionId,
1349
- });
1505
+ if (!opts?.periodic) {
1506
+ logger.info('Skipping synthetic session JSON persistence', {
1507
+ sessionId: summary.sessionId,
1508
+ });
1509
+ }
1510
+ return;
1511
+ }
1512
+ sessionStore.saveSession(summary);
1513
+ // checkAndGenerateLastWeek() is idempotent (existsSync check before
1514
+ // any real work), so calling it on every periodic checkpoint too is
1515
+ // cheap — and necessary: it otherwise only ran on the clean-shutdown
1516
+ // path, so a SIGKILL (common in containers under memory pressure)
1517
+ // after a periodic write meant the weekly summary never ran for that
1518
+ // week at all.
1519
+ weeklySummaryGenerator?.checkAndGenerateLastWeek();
1520
+ if (opts?.periodic) {
1521
+ // Lightweight checkpoint: log at debug so the cadence stays quiet.
1522
+ logger.debug('Session checkpointed', { sessionId: summary.sessionId });
1350
1523
  }
1351
1524
  else {
1352
- sessionStore.saveSession(summary);
1353
- weeklySummaryGenerator?.checkAndGenerateLastWeek();
1354
1525
  logger.info('Session saved', { sessionId: summary.sessionId });
1355
1526
  }
1356
1527
  }
1357
1528
  catch (err) {
1358
- logger.warn('Failed to save session on shutdown', { error: String(err) });
1529
+ logger.warn('Failed to save session', { error: String(err) });
1359
1530
  }
1360
1531
  };
1361
1532
  eventProcessor.start();
1533
+ // Checkpoint the in-progress session to local JSON every 30s so a non-clean
1534
+ // exit (crash / SIGKILL) loses at most ~30s of data instead of the whole
1535
+ // session. persistSession() no-ops for synthetic / provisional ids, so this
1536
+ // is safe to arm immediately. unref'd so it never keeps the process alive.
1537
+ const SESSION_PERSIST_INTERVAL_MS = 30_000;
1538
+ sessionPersistInterval = setInterval(() => {
1539
+ try {
1540
+ persistSession?.({ periodic: true });
1541
+ }
1542
+ catch (err) {
1543
+ logger.warn('Periodic session persist failed', { error: String(err) });
1544
+ }
1545
+ }, SESSION_PERSIST_INTERVAL_MS);
1546
+ sessionPersistInterval.unref?.();
1547
+ // Single-mode rule: the watcher runs in `--stdio` mode by default.
1548
+ // Opt-in to watcher-in-dashboard via `NR_AI_WATCHER_MODE=local`.
1549
+ const watcherMode = (process.env['NR_AI_WATCHER_MODE'] ?? 'stdio').toLowerCase();
1550
+ const isStdioWatcher = options.stdio === true;
1551
+ const isLocalWatcher = !isStdioWatcher;
1552
+ const watcherShouldRun = (isStdioWatcher && (watcherMode === 'stdio' || watcherMode === '')) ||
1553
+ (isLocalWatcher && watcherMode === 'local');
1554
+ // The SubagentWatcher is the ONLY thing that feeds per-agent (subagent)
1555
+ // token cost into the CostTracker (via onSubagentTurn → subagentCostUsd).
1556
+ // With it off, a session's persisted/headline cost silently excludes ALL
1557
+ // subagent spend — which is the majority of agentic cost — so the dashboard
1558
+ // shows a per-session total far below the subagent breakdown rendered right
1559
+ // below it. It is therefore default-ON; set NR_AI_ENABLE_SUBAGENT_WATCHER=0
1560
+ // to opt out. In `--stdio` mode it is scoped to the parent session
1561
+ // (parentSessionId filter), so it only ever attributes that session's own
1562
+ // subagents — parent tokens (onTokenEvent, parent transcript) and subagent
1563
+ // tokens (onSubagentTurn, subagent transcripts) are disjoint, so there is no
1564
+ // double count. The WorkflowWatcher stays opt-in (NR_AI_ENABLE_WORKFLOW_WATCHER=1).
1565
+ const subagentWatcherEnabled = process.env['NR_AI_ENABLE_SUBAGENT_WATCHER'] !== '0';
1566
+ const workflowWatcherEnabled = process.env['NR_AI_ENABLE_WORKFLOW_WATCHER'] === '1';
1567
+ // Construct + start the watchers for a given session id. In `--stdio` mode
1568
+ // the watchers filter discovered transcript dirs by `parentSessionId`; in
1569
+ // `--local` mode they run unfiltered (parentSessionId: undefined). Shared by
1570
+ // the initial startup call below and the async re-point call in the
1571
+ // provisional-session path so both produce identical wiring — see
1572
+ // repointWatchersToRealSession below.
1573
+ const startWatchers = (watcherSessionId) => {
1574
+ if (watcherShouldRun && subagentWatcherEnabled) {
1575
+ activeSubagentWatcher = new SubagentWatcher({
1576
+ storagePath: config.storagePath,
1577
+ parentSessionId: isStdioWatcher ? watcherSessionId : undefined,
1578
+ // Runtime cost-self-check: a drift > 5% surfaces as an
1579
+ // `AiObservabilityHealth { event: 'cost_self_check' }` event. We
1580
+ // compare like-with-like from two INDEPENDENT code paths so a
1581
+ // regression in either is caught:
1582
+ // - trackedUsd: subagent cost the live CostTracker accumulated from
1583
+ // the onSubagentTurn feed (the headline/persisted path), and
1584
+ // - groundTruthUsd: an independent re-parse of the same session's
1585
+ // subagent transcripts via SubagentTimelineStore (the trace path).
1586
+ // Both dedup streaming-duplicate lines by message.id, so a healthy
1587
+ // system reads ~0%; any divergence (e.g. one path regressing on dedup
1588
+ // or pricing) shows up as a real, non-zero delta. Only meaningful in
1589
+ // --stdio mode, where the watcher is scoped to this one session.
1590
+ costSelfCheck: () => {
1591
+ const trackedUsd = costTracker.getSubagentMetrics().subagentUsd;
1592
+ let groundTruthUsd = trackedUsd;
1593
+ try {
1594
+ const tl = subagentTimelineInstance.getSubagentsForSession(watcherSessionId);
1595
+ groundTruthUsd = tl.agents.reduce((sum, a) => sum + (a.usd ?? 0), 0);
1596
+ }
1597
+ catch {
1598
+ // On any re-parse error fall back to trackedUsd → 0% delta (no
1599
+ // false alarm); the error is already surfaced via watcher health.
1600
+ groundTruthUsd = trackedUsd;
1601
+ }
1602
+ return { trackedUsd, groundTruthUsd };
1603
+ },
1604
+ });
1605
+ activeSubagentWatcher.start();
1606
+ logger.info('SubagentWatcher started', {
1607
+ mode: watcherMode,
1608
+ parentSessionId: isStdioWatcher ? watcherSessionId : null,
1609
+ });
1610
+ }
1611
+ if (watcherShouldRun && workflowWatcherEnabled) {
1612
+ activeWorkflowWatcher = new WorkflowWatcher({
1613
+ storagePath: config.storagePath,
1614
+ parentSessionId: isStdioWatcher ? watcherSessionId : undefined,
1615
+ getCostForRun: (runId) => costTracker.getCostForWorkflowRun(runId),
1616
+ });
1617
+ activeWorkflowWatcher.setOnRun((run) => {
1618
+ capturedNrIngest?.ingestScriptWorkflowRun(run);
1619
+ });
1620
+ activeWorkflowWatcher.setOnHealth((health) => {
1621
+ capturedNrIngest?.ingestObservabilityHealth(health);
1622
+ });
1623
+ activeWorkflowWatcher.start();
1624
+ logger.info('WorkflowWatcher started', {
1625
+ mode: watcherMode,
1626
+ parentSessionId: isStdioWatcher ? watcherSessionId : null,
1627
+ });
1628
+ }
1629
+ };
1630
+ // Re-point the watchers from a provisional `pending-<ts>` session id to the
1631
+ // resolved real session id. Neither SubagentWatcher nor WorkflowWatcher
1632
+ // exposes a parentSessionId setter (the filter is `private readonly`), so we
1633
+ // stop the provisionally-scoped instance and reconstruct it scoped to the
1634
+ // real id — mirroring the eventProcessor.replaceStore() hot-swap. Only the
1635
+ // watchers that were actually started get rebuilt.
1636
+ const repointWatchersToRealSession = (realSessionId) => {
1637
+ if (activeSubagentWatcher) {
1638
+ activeSubagentWatcher.stop();
1639
+ activeSubagentWatcher = null;
1640
+ }
1641
+ if (activeWorkflowWatcher) {
1642
+ activeWorkflowWatcher.stop();
1643
+ activeWorkflowWatcher = null;
1644
+ }
1645
+ startWatchers(realSessionId);
1646
+ };
1647
+ startWatchers(sessionTraceId);
1362
1648
  if (options.stdio) {
1363
1649
  // Wire audit trail into resource handlers (was undefined at createServer() time).
1364
1650
  // Same instance is shared with the DashboardServer and NrIngestManager so all
@@ -1391,6 +1677,13 @@ async function main() {
1391
1677
  storagePath: config.storagePath,
1392
1678
  signal: sessionResolutionAbort.signal,
1393
1679
  });
1680
+ // Guard against a shutdown that fired while we were awaiting —
1681
+ // the signal is aborted but no exception was thrown (e.g. the
1682
+ // resolver returned successfully just before abort was set).
1683
+ if (sessionResolutionAbort?.signal.aborted) {
1684
+ logger.info('Session ID resolution aborted by shutdown (post-await guard)');
1685
+ return;
1686
+ }
1394
1687
  // Adopt the real session ID without clearing accumulated metrics.
1395
1688
  sessionTraceId = realId;
1396
1689
  sessionTracker.adoptSessionId(realId);
@@ -1452,6 +1745,17 @@ async function main() {
1452
1745
  capturedNrIngest = nrIngest;
1453
1746
  nrIngest.start();
1454
1747
  }
1748
+ // Re-point the subagent/workflow watchers from the provisional
1749
+ // `pending-<ts>` id to the resolved real session id. The watchers
1750
+ // were constructed in the startup block with the provisional id and
1751
+ // filter discovered transcript dirs by it; a `pending-*` id never
1752
+ // matches a real UUID session dir, so without this re-point they
1753
+ // capture nothing for the life of the process. Done after the
1754
+ // NrIngest reassignment above so the rebuilt WorkflowWatcher's
1755
+ // onRun/onHealth closures observe the live `capturedNrIngest`.
1756
+ // Guarded inside repoint: only watchers that were actually started
1757
+ // are stopped + reconstructed; if none ran this is a no-op.
1758
+ repointWatchersToRealSession(realId);
1455
1759
  // Register full tools, replacing the pending handlers.
1456
1760
  const configFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
1457
1761
  const configSummary = {