claude-code-session-manager 0.94.0 → 0.96.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 (122) hide show
  1. package/dist/assets/{DataModel-B0LDnnSL.js → DataModel-T0D00BpK.js} +1 -1
  2. package/dist/assets/History-DuAovTr7.js +2 -0
  3. package/dist/assets/{Hooks-CwLnp0Z_.js → Hooks-CgP9jsAN.js} +3 -3
  4. package/dist/assets/{HostBilko-CwEHEKYk.js → HostBilko-DHfz3Mxa.js} +1 -1
  5. package/dist/assets/{Library-BU9np05E.js → Library--poVP-d1.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-B74ogK2f.js → MarkdownEditor-C56-Pc4c.js} +1 -1
  7. package/dist/assets/{McpServers-BLM3ftzP.js → McpServers-D-ykP4Vi.js} +2 -2
  8. package/dist/assets/Memory-BrkJjRL8.js +8 -0
  9. package/dist/assets/{Permissions-3pGLJmxQ.js → Permissions-BMpAaxJu.js} +3 -3
  10. package/dist/assets/Plugins-OuEe5S_T.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-BnjBQiV9.js → ProvenanceBadge-4eNbDYe8.js} +1 -1
  12. package/dist/assets/{SaveBar-Zq2NfcPf.js → SaveBar-BQ8y9Lkv.js} +1 -1
  13. package/dist/assets/Scheduler-DcMT5sT_.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-Cnojv22D.js → ScopeSwitcher-XdxtVKP-.js} +1 -1
  15. package/dist/assets/Settings-FdaPUq2t.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-BRDiE6Hv.js → SkillReferenceGraph-CJzvvc0_.js} +1 -1
  17. package/dist/assets/Skills-Wvm9OE1e.js +3 -0
  18. package/dist/assets/{SystemPrompt-tFr19Od6.js → SystemPrompt--LYw_69j.js} +1 -1
  19. package/dist/assets/TagLibrary-Be6HbMMx.js +1 -0
  20. package/dist/assets/{TiptapBody-DGzSg3BO.js → TiptapBody-DzFlWMgO.js} +1 -1
  21. package/dist/assets/{Toggle-DzoROibf.js → Toggle-BxtAFE1i.js} +1 -1
  22. package/dist/assets/index-XkrLB67D.js +3079 -0
  23. package/dist/assets/{settingsSchema-BYKVe1WM.js → settingsSchema-BIvDZ1Cj.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +10 -2
  26. package/plugins/CLAUDE.md +3 -3
  27. package/plugins/session-manager-dev/skills/develop/SKILL.md +83 -67
  28. package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
  29. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
  30. package/scripts/README.md +4 -0
  31. package/scripts/hooks/guard-destructive-git.cjs +9 -374
  32. package/scripts/hooks/guard-inline-implementation.cjs +9 -128
  33. package/scripts/hooks/guard-prd-writes.cjs +9 -74
  34. package/scripts/hooks/guard-self-schedule.cjs +9 -61
  35. package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
  36. package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
  37. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
  38. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
  39. package/scripts/scheduler-mcp-server.cjs +8 -1
  40. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
  41. package/src/main/__tests__/exchanges.test.cjs +41 -96
  42. package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
  43. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  44. package/src/main/__tests__/health-credentials.test.cjs +81 -0
  45. package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
  46. package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
  47. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
  48. package/src/main/__tests__/planValidator.test.cjs +71 -0
  49. package/src/main/__tests__/prdCreate.test.cjs +3 -1
  50. package/src/main/__tests__/prdSizing.test.cjs +106 -0
  51. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
  52. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
  54. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
  55. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
  56. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
  57. package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
  58. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  59. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  60. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  61. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  62. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  63. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  64. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  65. package/src/main/build-info.json +4 -4
  66. package/src/main/config.cjs +26 -14
  67. package/src/main/crashDiagnostics.cjs +9 -0
  68. package/src/main/exchanges.cjs +26 -21
  69. package/src/main/health.cjs +91 -5
  70. package/src/main/index.cjs +1 -1
  71. package/src/main/ipcSchemas.cjs +5 -1
  72. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  73. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  74. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  75. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  76. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  77. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  78. package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
  79. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  80. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  81. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  82. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  83. package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
  84. package/src/main/lib/agentModelResolve.cjs +1 -0
  85. package/src/main/lib/credentials.cjs +62 -0
  86. package/src/main/lib/cwdClassify.cjs +15 -0
  87. package/src/main/lib/definitionOfDone.cjs +9 -13
  88. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  89. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  90. package/src/main/lib/loadGate.cjs +29 -5
  91. package/src/main/lib/loopDelay.cjs +44 -0
  92. package/src/main/lib/opsOwnership.cjs +23 -0
  93. package/src/main/lib/planValidator.cjs +28 -0
  94. package/src/main/lib/prdCreate.cjs +8 -4
  95. package/src/main/lib/prdSizing.cjs +83 -0
  96. package/src/main/lib/runLogRetention.cjs +2 -8
  97. package/src/main/lib/telemetryBacklog.cjs +9 -3
  98. package/src/main/lib/telemetryClient.cjs +12 -3
  99. package/src/main/lib/telemetrySettings.cjs +8 -2
  100. package/src/main/lib/usageCircuit.cjs +40 -12
  101. package/src/main/lib/validationSentinels.cjs +29 -0
  102. package/src/main/memoryAggregate.cjs +36 -11
  103. package/src/main/otelSettings.cjs +8 -2
  104. package/src/main/scheduler.cjs +264 -34
  105. package/src/main/seedAgentPersonas.cjs +1 -1
  106. package/src/main/sessionsStore.cjs +8 -2
  107. package/src/main/supervisor.cjs +3 -3
  108. package/src/main/templates/PRD_AUTHORING.md +9 -9
  109. package/src/main/usage.cjs +9 -1
  110. package/src/main/voiceSettings.cjs +8 -2
  111. package/src/preload/api.d.ts +20 -5
  112. package/src/preload/index.cjs +5 -5
  113. package/src/seed/agents/dev-lead.md +24 -15
  114. package/src/seed/agents/validator.md +33 -0
  115. package/dist/assets/History-C685Kytt.js +0 -2
  116. package/dist/assets/Memory-DAP1t8bA.js +0 -8
  117. package/dist/assets/Plugins-Q1KoLEzn.js +0 -2
  118. package/dist/assets/Scheduler-D488ebKm.js +0 -16
  119. package/dist/assets/Settings-D5Lhj7ga.js +0 -3
  120. package/dist/assets/Skills-BQDqp-EN.js +0 -3
  121. package/dist/assets/TagLibrary-BjyrnaRE.js +0 -1
  122. package/dist/assets/index-CCl4tz-u.js +0 -3079
@@ -80,6 +80,8 @@ const sessionsStore = require('./sessionsStore.cjs');
80
80
  const { enqueueExternalPrompt } = require('./chatRunner.cjs');
81
81
  const { appendResponseEventIfKnown } = require('./promptSessionEvents.cjs');
82
82
  const { maybeEnqueueValidationPrompt } = require('./lib/epicValidationHook.cjs');
83
+ const { parseValidationSentinels } = require('./lib/validationSentinels.cjs');
84
+ const { hasDownstreamValidator } = require('./lib/planValidator.cjs');
83
85
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
84
86
  const { verifyRun, parseLog, scanSentinel, scanForeignWipPathsClaim } = require('./runVerify.cjs');
85
87
  const { latestTerminalOutcomeForSlug, COMPLETED_EQUIVALENT_VERDICTS } = require('./lib/terminalRunOutcome.cjs');
@@ -274,7 +276,17 @@ const BASH_MAX_TIMEOUT_MS = 900_000; // 15 min — must stay below IDLE_OUTPUT_K
274
276
  // centrally here (not per-PRD) so it applies to every current and future PRD.
275
277
  // The commit step is also backstopped by the post-run commit guard below: a
276
278
  // clean exit that leaves uncommitted changes is downgraded to needs_review.
277
- const FINISH_PROTOCOL = `
279
+ //
280
+ // PRD 1408: a plan that ends in its own trailing `validator` job (PRD
281
+ // 1405/1407) already re-reviews every work-item's diff once, so asking EVERY
282
+ // work-item to also run /code-review + /security-review inline is duplicate
283
+ // review work — and the dominant cost tail on multi-file PRDs. buildFinishProtocol
284
+ // derives a `reviewInRun: false` variant that collapses those two steps into
285
+ // one deferral note and renumbers the rest, sharing every other word
286
+ // byte-for-byte with the default (`reviewInRun: true`, which is exactly
287
+ // FINISH_PROTOCOL) via the head/review-steps/tail pieces below.
288
+ function buildFinishHead(verifyStepNum) {
289
+ return `
278
290
 
279
291
  ---
280
292
  # SCHEDULER FINISH PROTOCOL (mandatory — runs AFTER the work above)
@@ -283,7 +295,7 @@ Once every acceptance-criteria line above is satisfied, finish in this EXACT
283
295
  sequence. Do not stop before the commit lands; committing is part of the job.
284
296
 
285
297
  RUN VERIFICATION IN THE FOREGROUND — this applies to the whole run, not just
286
- step 3 below: every test/typecheck/lint/build command you run, whether while
298
+ step ${verifyStepNum} below: every test/typecheck/lint/build command you run, whether while
287
299
  implementing the AC or during VERIFY, must run SYNCHRONOUSLY and you must wait
288
300
  for it to return. Never start a verification command as a background task
289
301
  (no background Bash) and then call Monitor, TaskOutput, or ScheduleWakeup to
@@ -296,17 +308,26 @@ to fit inside that ceiling; if a gate command still cannot finish inside
296
308
  budget, stop and emit SCHEDULER_VERDICT: FAIL with the reason instead of
297
309
  deferring it.
298
310
 
299
- 1. CODE REVIEW — run \`/code-review --fix\` on your changes and apply the fixes it
311
+ `;
312
+ }
313
+
314
+ const FINISH_REVIEW_STEPS_IN_RUN = `1. CODE REVIEW — run \`/code-review --fix\` on your changes and apply the fixes it
300
315
  surfaces (correctness first). For any finding you judge a false positive, say
301
316
  why in your result; do not silently skip it. If \`/code-review\` is not
302
317
  available in this environment, do an equivalent careful self-review instead.
303
318
  2. SECURITY REVIEW — run \`/security-review\` and address every finding (or
304
319
  justify it). If unavailable, self-review the diff for injection, secrets,
305
320
  path traversal, and unsafe input handling.
306
- 3. VERIFY — run the project's OWN check commands (typecheck / lint / tests — the
321
+ `;
322
+
323
+ const FINISH_REVIEW_STEP_DEFERRED = `1. REVIEW — code and security review for this plan run once, in its trailing validator job. Do NOT run /code-review or /security-review here.
324
+ `;
325
+
326
+ function buildFinishTail({ verifyStepNum, commitStepNum, verdictStepNum }) {
327
+ return `${verifyStepNum}. VERIFY — run the project's OWN check commands (typecheck / lint / tests — the
307
328
  project's CLAUDE.md names them; infer from the repo if not) and make them
308
329
  pass. Do not assume npm; use whatever the target project uses.
309
- 4. COMMIT — the queue can run several jobs against this SAME working tree at
330
+ ${commitStepNum}. COMMIT — the queue can run several jobs against this SAME working tree at
310
331
  once. Do NOT stage the whole working tree in one blanket/wildcard git-add
311
332
  sweep — that captures whatever a concurrent sibling job is mid-writing and
312
333
  mis-attributes its work to this commit, corrupting both jobs' verdicts.
@@ -314,12 +335,12 @@ deferring it.
314
335
  commit: \`git add <path> [<path>...] && git commit -m "<type>(<scope>): <summary>"\`.
315
336
  Your own work must still never be left uncommitted — this only changes
316
337
  which paths get staged, never whether you commit.
317
- 5. VERDICT SENTINEL — as the LAST LINE of your final result text, emit exactly
338
+ ${verdictStepNum}. VERDICT SENTINEL — as the LAST LINE of your final result text, emit exactly
318
339
  one of these lines (no trailing text after it):
319
340
  SCHEDULER_VERDICT: PASS
320
341
  SCHEDULER_VERDICT: FAIL <one-line reason>
321
342
  SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP
322
- Print PASS only when the AC gate is green AND the commit from step 4 landed.
343
+ Print PASS only when the AC gate is green AND the commit from step ${commitStepNum} landed.
323
344
  Print FAIL (and exit 1) if the AC gate was red or the commit could not land.
324
345
  NEVER print PASS on a red AC gate — a lying PASS turns the verifier from a
325
346
  false-failure catcher into a silent-failure shipper. A truthful PASS + a
@@ -348,6 +369,30 @@ for review. Do NOT add work beyond the acceptance criteria — this protocol is
348
369
  only post-AC work. If a review finding can't be fixed within scope, commit what
349
370
  you have, describe the finding in the commit body, and note the follow-up in your
350
371
  final result.`;
372
+ }
373
+
374
+ /**
375
+ * buildFinishProtocol({ reviewInRun }) → string
376
+ *
377
+ * `reviewInRun: true` (default) is byte-for-byte FINISH_PROTOCOL: steps 1-5
378
+ * are CODE REVIEW, SECURITY REVIEW, VERIFY, COMMIT, VERDICT SENTINEL.
379
+ * `reviewInRun: false` collapses the two review steps into one deferral note
380
+ * (step 1) and renumbers VERIFY/COMMIT/VERDICT SENTINEL to 2-4 — used when
381
+ * `hasDownstreamValidator` (lib/planValidator.cjs) finds this job's diff will
382
+ * be re-reviewed once by the plan's own trailing validator job, so asking
383
+ * every work-item to also run /code-review + /security-review inline is
384
+ * redundant review work.
385
+ */
386
+ function buildFinishProtocol({ reviewInRun = true } = {}) {
387
+ if (reviewInRun) {
388
+ return buildFinishHead(3) + FINISH_REVIEW_STEPS_IN_RUN
389
+ + buildFinishTail({ verifyStepNum: 3, commitStepNum: 4, verdictStepNum: 5 });
390
+ }
391
+ return buildFinishHead(2) + FINISH_REVIEW_STEP_DEFERRED
392
+ + buildFinishTail({ verifyStepNum: 2, commitStepNum: 3, verdictStepNum: 4 });
393
+ }
394
+
395
+ const FINISH_PROTOCOL = buildFinishProtocol({ reviewInRun: true });
351
396
 
352
397
  // Unquote a single git porcelain v1 path token. Defined once in
353
398
  // gitWorktree.cjs (which this file already requires — the reverse would be
@@ -955,7 +1000,11 @@ const DEFAULT_CONFIG = {
955
1000
  // 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
956
1001
  // 'manual' = only fire on explicit Run now click.
957
1002
  firePolicy: 'when-available',
958
- // For 'when-available'. Fire only when five_hour utilization < this percent.
1003
+ // For 'when-available'. Fire only when BINDING-window utilization < this
1004
+ // percent. Binding = the most-consumed unscoped window (five_hour OR weekly):
1005
+ // dispatching into an exhausted weekly window would just produce 429s, so the
1006
+ // gate holds on it — a hold that can last days, which is why it is surfaced
1007
+ // loudly (utilizationHold snapshot field, heartbeat window name, health).
959
1008
  utilizationThreshold: 90,
960
1009
  schemaVersion: 1,
961
1010
  supervisor: {
@@ -1509,9 +1558,19 @@ function loadSchedulerState() {
1509
1558
  if (typeof s.failureStreakWarnedAt === 'number') failureStreakWarnedAt = s.failureStreakWarnedAt;
1510
1559
  failureStreakWarnedAt = restoreFailureStreakWarnedAt(failureStreakWarned, failureStreakWarnedAt, Date.now());
1511
1560
  if (typeof s.lastEscalationAt === 'number') lastEscalationAtMs = s.lastEscalationAt;
1561
+ if (typeof s.lastPollOk === 'boolean') lastPollOk = s.lastPollOk;
1562
+ if (typeof s.lastFailureKind === 'string' || s.lastFailureKind === null) lastFailureKind = s.lastFailureKind;
1563
+ if (typeof s.backoffNextAt === 'number' || s.backoffNextAt === null) backoffNextAt = s.backoffNextAt;
1564
+ if (typeof s.cachedUtilization === 'number' || s.cachedUtilization === null) cachedUtilization = s.cachedUtilization;
1565
+ if (typeof s.cachedBindingWindowName === 'string' || s.cachedBindingWindowName === null) cachedBindingWindowName = s.cachedBindingWindowName;
1512
1566
  } catch { /* first boot or corrupt — start fresh */ }
1513
1567
  }
1514
1568
 
1569
+ /** Test-only seam: the module-level fields loadSchedulerState() restores that have no other exported reader. */
1570
+ function getSchedulerStateSnapshot() {
1571
+ return { lastPollOk, lastFailureKind, backoffNextAt, cachedUtilization, cachedBindingWindowName };
1572
+ }
1573
+
1515
1574
  function persistSchedulerState() {
1516
1575
  // Sync write: called from many sync hot paths (clearPause, pollLoop catch
1517
1576
  // block) and the sidecar is tiny (<1 KB). Converting to async here would
@@ -1540,6 +1599,14 @@ function persistSchedulerState() {
1540
1599
  // THESE persisted values, since it never holds the in-memory circuit.
1541
1600
  usageCircuitState: billing.usageCircuit.state(),
1542
1601
  usageCircuitOpenedAt: billing.usageCircuit.openedAt(),
1602
+ // Footer/KPI display fields — previously memory-only, so a restart showed
1603
+ // misleading placeholders (poll-failed, no rate-limit banner, "—" utilization)
1604
+ // until the next post-boot billing poll overwrote them.
1605
+ lastPollOk,
1606
+ lastFailureKind,
1607
+ backoffNextAt,
1608
+ cachedUtilization,
1609
+ cachedBindingWindowName,
1543
1610
  });
1544
1611
  } catch (e) {
1545
1612
  console.warn('[scheduler] failed to persist scheduler state', e?.message);
@@ -1804,6 +1871,9 @@ function heartbeatTick(deps = {}) {
1804
1871
  quarantinedCwds: (s.unreadableCwds ?? []).map((u) => u.cwd),
1805
1872
  nextReset: cachedNextReset,
1806
1873
  utilization: cachedUtilization,
1874
+ // Which window `utilization` (and nextReset) refer to — a reader of
1875
+ // this log can't otherwise tell a 5h hold from a weekly one.
1876
+ utilizationWindow: cachedBindingWindowName,
1807
1877
  consecutiveFailures,
1808
1878
  // State/consecutiveFailures/degraded-budget snapshot of the shared
1809
1879
  // usage-meter breaker, so a human reading only the heartbeat log can
@@ -3226,6 +3296,10 @@ async function reconcile(state) {
3226
3296
 
3227
3297
  let cachedNextReset = null; // bare ISO string or null
3228
3298
  let cachedUtilization = null; // binding-window utilization %, 0–100, or null if unknown
3299
+ let cachedBindingWindowName = null; // name/kind of the window cachedUtilization reads (e.g. 'five_hour', 'weekly_all')
3300
+ // Non-null while maybeLaunchWhenAvailable is holding the queue on the
3301
+ // utilization gate: { window, percent, threshold, resetsAt }. Snapshot-level.
3302
+ let utilizationHold = null;
3229
3303
  // ms timestamp of the last FRESH reset observation (see recordObservedReset)
3230
3304
  // — distinct from Date.now(), so persistSchedulerState never re-stamps a
3231
3305
  // stale cachedNextReset as "just observed" on every poll cycle.
@@ -3281,6 +3355,7 @@ function computeDegradedBudget() {
3281
3355
  function applyDegradedBudget() {
3282
3356
  const budget = computeDegradedBudget();
3283
3357
  cachedUtilization = budget.utilization;
3358
+ cachedBindingWindowName = bindingWindow(lastGoodUsagePayload).name;
3284
3359
  degradedConcurrencyCapValue = budget.concurrencyCap;
3285
3360
  return budget;
3286
3361
  }
@@ -3290,8 +3365,15 @@ async function refreshNextReset() {
3290
3365
  const r = await billing.fetchUsage();
3291
3366
  if (r.kind !== 'ok') throw new Error(`usage fetch failed (${r.kind}): ${r.message ?? ''}`);
3292
3367
  const window = bindingWindow(r.data?.usage);
3368
+ if (!Number.isFinite(window.utilization)) {
3369
+ // Successful fetch with no readable percent = meter/contract problem, not a reading.
3370
+ billing.usageCircuit.recordFailure('no_utilization');
3371
+ throw new Error('usage fetch ok but payload yielded no finite utilization');
3372
+ }
3293
3373
  recordObservedReset(window.resets_at ?? null);
3294
- cachedUtilization = Number.isFinite(window.utilization) ? window.utilization : cachedUtilization;
3374
+ cachedUtilization = window.utilization;
3375
+ cachedBindingWindowName = window.name;
3376
+ lastGoodUsagePayload = r.data?.usage ?? lastGoodUsagePayload;
3295
3377
  return cachedNextReset;
3296
3378
  }
3297
3379
 
@@ -3606,6 +3688,8 @@ function buildScheduleStatePayload(state) {
3606
3688
  launchBlocks: state.launchBlocks ?? {},
3607
3689
  launchMitigations: state.launchMitigations ?? {},
3608
3690
  utilization: cachedUtilization,
3691
+ utilizationWindow: cachedBindingWindowName,
3692
+ utilizationHold,
3609
3693
  pollHealth: {
3610
3694
  lastPollAt,
3611
3695
  lastPollOk,
@@ -4259,6 +4343,11 @@ async function notifyOriginatingTab(job, {
4259
4343
  appendTranscriptTurn = promptSessionTranscript.appendTurn,
4260
4344
  readResultFromLog = extractResultTextFromLog,
4261
4345
  enqueueValidation = maybeEnqueueValidationPrompt,
4346
+ // PRD 1408: same queue rows spawnJob/tickQueue already use — read fresh
4347
+ // here (not threaded through the terminal-transition snapshot) since this
4348
+ // fires well after dispatch, when the plan may have gained/dropped a
4349
+ // trailing validator row since this job was spawned.
4350
+ loadJobs = async () => (await readQueue()).jobs || [],
4262
4351
  } = {}) {
4263
4352
  try {
4264
4353
  const prd = await resolveNotifyPrd(job, parsePrdRaw);
@@ -4290,10 +4379,25 @@ async function notifyOriginatingTab(job, {
4290
4379
  // fallback). Best-effort: a missing cwd/epic id, an unreadable run log,
4291
4380
  // or an IPC error here must never block the notification below.
4292
4381
  const epicIdForTranscript = prd?.sourcePromptId || prd?.sourceTabId || job.epicId || null;
4293
- if (epicIdForTranscript && job.cwd) {
4382
+ // Read the job's raw result text once, up front, so both the transcript
4383
+ // append below and the validator-sentinel parsing further down (PRD
4384
+ // 1407) reuse the same read instead of hitting the run log twice. Only
4385
+ // read when at least one consumer needs it — an ordinary job with no
4386
+ // resolvable transcript target and no validator sentinels to parse must
4387
+ // not pay for a log-tail read it will throw away.
4388
+ const needsResultText = (epicIdForTranscript && job.cwd)
4389
+ || (job.agentType === 'validator' && job.status === 'completed');
4390
+ let resultText = null;
4391
+ if (needsResultText) {
4294
4392
  try {
4295
4393
  const logPath = job.runId ? path.join(schedulerPaths.runsDir(), job.runId, `${job.slug}.log`) : null;
4296
- const resultText = readResultFromLog(logPath);
4394
+ resultText = readResultFromLog(logPath);
4395
+ } catch (e) {
4396
+ console.error('[scheduler] notifyOriginatingTab readResultFromLog error', job?.slug, e);
4397
+ }
4398
+ }
4399
+ if (epicIdForTranscript && job.cwd) {
4400
+ try {
4297
4401
  await appendTranscriptTurn(job.cwd, epicIdForTranscript, {
4298
4402
  role: 'assistant',
4299
4403
  text: resultText || message,
@@ -4304,7 +4408,43 @@ async function notifyOriginatingTab(job, {
4304
4408
  }
4305
4409
  }
4306
4410
 
4411
+ // PRD 1407: a plan-level validator job ends its run with one
4412
+ // `VALIDATION: <slug> VERIFIED`/`REFUTED` line per PRD it checked.
4413
+ // Parsed only for a completed validator job; every ordinary work-item
4414
+ // job gets an empty array here and behaves exactly as before.
4415
+ const verdicts = (job.agentType === 'validator' && job.status === 'completed')
4416
+ ? parseValidationSentinels(resultText)
4417
+ : [];
4418
+
4419
+ if (!epicId && verdicts.length > 0) {
4420
+ // No Epic to attach these to (unresolved sourcePromptId/epicId, same
4421
+ // rare case the check-in message below falls back on) — unlike the
4422
+ // check-in message, a verdict has no chat-notification fallback, so
4423
+ // without this log the parsed evidence vanishes with no trace at all.
4424
+ console.error(
4425
+ `[scheduler] notifyOriginatingTab: ${verdicts.length} validator verdict(s) parsed for ${job.slug} but no epicId to attach them to — dropped`,
4426
+ );
4427
+ }
4428
+
4307
4429
  if (epicId) {
4430
+ // PRD 1407: append one response event per parsed verdict, BEFORE the
4431
+ // validator job's own check-in append below — each verdict stamps
4432
+ // `validation: 'verified' | 'refuted'` onto the authoring Epic's
4433
+ // event chain, which epicDerive.ts/epicTimeline.ts already render as
4434
+ // the Epic traffic light and which epicValidationHook.cjs's loop
4435
+ // guard already ignores (only 'unvalidated' can trigger a prompt).
4436
+ for (const v of verdicts) {
4437
+ try {
4438
+ await appendResponseEvent(
4439
+ job.cwd || null,
4440
+ epicId,
4441
+ `Validation ${v.verdict.toUpperCase()}: ${v.slug}${v.reason ? ` — ${v.reason}` : ''}`,
4442
+ { prdSlug: v.slug, outcome: 'completed', validation: v.verdict },
4443
+ );
4444
+ } catch (e) {
4445
+ console.error('[scheduler] notifyOriginatingTab verdict appendResponseEvent error', job?.slug, v.slug, e);
4446
+ }
4447
+ }
4308
4448
  // PRD 986: the check-in event is born validation:'unvalidated' — never
4309
4449
  // 'verified' — regardless of the job's self-reported outcome. The
4310
4450
  // check-in is a request to validate, not an assertion of done.
@@ -4327,20 +4467,40 @@ async function notifyOriginatingTab(job, {
4327
4467
  // happened above). All gating (kill-switch SM_EPIC_VALIDATION_DISABLE,
4328
4468
  // active-Epic check, once-per-(epicId, prdSlug), loop guard, slot-pool
4329
4469
  // routing) lives in lib/epicValidationHook.cjs.
4330
- try {
4331
- enqueueValidation(
4332
- {
4333
- cwd: job.cwd || null,
4334
- epicId,
4335
- prdSlug: job.slug,
4336
- prdPath: prd?.path || archivedPrdPathForJob(job) || null,
4337
- outcome: job.status,
4338
- eventValidation: 'unvalidated',
4339
- },
4340
- { sendPrompt },
4341
- );
4342
- } catch (e) {
4343
- console.error('[scheduler] notifyOriginatingTab enqueueValidation error', job?.slug, e);
4470
+ //
4471
+ // PRD 1407: a validator job's own check-in never enqueues a
4472
+ // validation prompt for itself — it IS the plan's validation pass,
4473
+ // not a work-item awaiting one.
4474
+ //
4475
+ // PRD 1408: nor does an ordinary work-item whose plan already ends in
4476
+ // a trailing validator job depending on it — that validator's own
4477
+ // check-in (above) is the single validation pass for the whole plan,
4478
+ // so a per-PRD prompt here would be a second, redundant ask into the
4479
+ // same (Opus/Fable-tier) authoring session.
4480
+ if (job.agentType !== 'validator') {
4481
+ let skipForDownstreamValidator = false;
4482
+ try {
4483
+ skipForDownstreamValidator = hasDownstreamValidator(job, await loadJobs());
4484
+ } catch (e) {
4485
+ console.error('[scheduler] notifyOriginatingTab loadJobs error', job?.slug, e);
4486
+ }
4487
+ if (!skipForDownstreamValidator) {
4488
+ try {
4489
+ enqueueValidation(
4490
+ {
4491
+ cwd: job.cwd || null,
4492
+ epicId,
4493
+ prdSlug: job.slug,
4494
+ prdPath: prd?.path || archivedPrdPathForJob(job) || null,
4495
+ outcome: job.status,
4496
+ eventValidation: 'unvalidated',
4497
+ },
4498
+ { sendPrompt },
4499
+ );
4500
+ } catch (e) {
4501
+ console.error('[scheduler] notifyOriginatingTab enqueueValidation error', job?.slug, e);
4502
+ }
4503
+ }
4344
4504
  }
4345
4505
  return;
4346
4506
  }
@@ -5316,7 +5476,7 @@ function pickRunDir() {
5316
5476
  * Watchdogs are declared as an array; the result-tailer's exit-code mapping
5317
5477
  * (success+killedBySignal → 0) is scheduler-specific and lives in onExit.
5318
5478
  */
5319
- async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget = null, foreignWip = null, launchEnv = null, onPhase = null) {
5479
+ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget = null, foreignWip = null, launchEnv = null, onPhase = null, reviewInRun = true) {
5320
5480
  // First statement — stamps the 'exec-entered' dispatch-phase breadcrumb
5321
5481
  // before openLog below, so a hang inside openLog/spawn itself still shows
5322
5482
  // execution reached this function (see spawnJob's dispatchPhase comment).
@@ -5502,7 +5662,7 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
5502
5662
  // Always route through composeExecutorPrompt (even with an empty digest)
5503
5663
  // so the finish protocol is appended in the prompt's tail exactly once,
5504
5664
  // after any digest fence rather than concatenated ahead of it.
5505
- prompt = composeExecutorPrompt({ prdBody: prompt, digestText, finishProtocol: FINISH_PROTOCOL });
5665
+ prompt = composeExecutorPrompt({ prdBody: prompt, digestText, finishProtocol: buildFinishProtocol({ reviewInRun }) });
5506
5666
 
5507
5667
  // Foreign-WIP manifest (starry-night-ships PRD 148 postmortem): the
5508
5668
  // scheduler already knows, at spawn time, which dirty paths this job did
@@ -6723,7 +6883,15 @@ function guardedTick(fn, label) {
6723
6883
  }
6724
6884
  }
6725
6885
 
6726
- async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
6886
+ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, siblingJobs = null) {
6887
+ // PRD 1408: the queue rows in scope at dispatch (tickQueue's own
6888
+ // state.jobs, threaded straight down — never re-read here or inside
6889
+ // executeJob) decide whether this job's own finish protocol still needs to
6890
+ // run /code-review + /security-review inline, or can defer to a downstream
6891
+ // `validator` job that will re-review this diff once the plan finishes.
6892
+ // A caller with no rows in scope (spawnResumeRecovery) reads them itself,
6893
+ // once, before delegating to spawnJob — still never inside executeJob.
6894
+ const reviewInRun = !hasDownstreamValidator(job, siblingJobs || []);
6727
6895
  // Session-Manager owns the machine-wide `claude -p` pool (sessionSlots.cjs)
6728
6896
  // — the scheduler REQUESTS capacity, it doesn't own a private cap. A miss
6729
6897
  // leaves the job pending; the next tick retries when a slot frees up.
@@ -7095,7 +7263,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
7095
7263
  s.jobs[idx].dispatchPhaseAt = new Date().toISOString();
7096
7264
  }
7097
7265
  });
7098
- });
7266
+ }, reviewInRun);
7099
7267
  } finally {
7100
7268
  if (worktree.ok) {
7101
7269
  ({ worktreeLeftoverDirty, salvagePatch, worktreeIntegrationFailure, worktreeIntegrationDetail, mergeAutoResolved, mergeAutoResolvedPaths } =
@@ -7485,10 +7653,17 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
7485
7653
  let mechanicalRecoveryTarget = null;
7486
7654
  let terminalNotifySnapshot = null;
7487
7655
  const newlyCompletedPrds = [];
7656
+ // PRD 1408: captured inside the mutate() below (the one in-memory read of
7657
+ // `s.jobs` this finalize pass already does) so notifyOriginatingTab's own
7658
+ // hasDownstreamValidator check can reuse it instead of a second, redundant
7659
+ // readQueue() — that call federates queue.json shards across every known
7660
+ // project cwd on the machine, not just this job's own project.
7661
+ let jobsSnapshotForNotify = null;
7488
7662
  // Base HEAD for the mechanical-recovery futility check — read here, never inside mutate() (no I/O).
7489
7663
  const finalizeHeadSha = worktreeIntegrationDetail && worktreeIntegrationDetail.failureKind === 'content_conflict'
7490
7664
  ? await gitHead(guardCwd) : null;
7491
7665
  await mutate((s) => {
7666
+ jobsSnapshotForNotify = s.jobs.slice();
7492
7667
  const i2 = s.jobs.findIndex((x) => x.slug === job.slug);
7493
7668
  // A job already moved off 'running' by someone else (namely
7494
7669
  // remote.cancelJob, PRD 1024 — it SIGTERMs the process then finalizes
@@ -7902,7 +8077,9 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
7902
8077
  });
7903
8078
 
7904
8079
  if (terminalNotifySnapshot) {
7905
- notifyOriginatingTab(terminalNotifySnapshot).catch((e) => {
8080
+ notifyOriginatingTab(terminalNotifySnapshot, {
8081
+ loadJobs: async () => jobsSnapshotForNotify || [],
8082
+ }).catch((e) => {
7906
8083
  console.error('[scheduler] notifyOriginatingTab error', job.slug, e);
7907
8084
  });
7908
8085
  }
@@ -8130,7 +8307,11 @@ async function reclaimTerminalJobOrphansThrottled(state) {
8130
8307
  */
8131
8308
  async function spawnResumeRecovery(job, resumeTarget) {
8132
8309
  const { runId, dir: runDir } = pickRunDir();
8133
- await spawnJob(job, runId, runDir, job.cwd || DEFAULT_PROJECT_CWD, resumeTarget);
8310
+ // No queue rows in scope here (unlike tickQueue's own dispatch loop) — read
8311
+ // once, before delegating to spawnJob, so hasDownstreamValidator still
8312
+ // never runs inside executeJob itself.
8313
+ const siblingJobs = (await readQueue().catch(() => ({ jobs: [] }))).jobs || [];
8314
+ await spawnJob(job, runId, runDir, job.cwd || DEFAULT_PROJECT_CWD, resumeTarget, siblingJobs);
8134
8315
  }
8135
8316
 
8136
8317
  // Serialized ticker: prevents two concurrent tickQueue() calls from racing
@@ -8390,7 +8571,10 @@ async function tickBody(gen, { bypassLoadGate }) {
8390
8571
  for (const job of gatedBatch) {
8391
8572
  if (cancelToken.cancelled || stale()) break;
8392
8573
  // spawnJob is fire-and-forget; it calls tickQueue() on completion.
8393
- spawnJob(job, runId, runDir, state.config.defaultCwd).catch((e) => reportSchedulerError('spawnJob dispatch rejected', job.slug, e));
8574
+ // state.jobs is the queue's rows in scope at THIS dispatch (PRD 1408) —
8575
+ // threaded straight through so spawnJob's hasDownstreamValidator check
8576
+ // never re-reads the queue itself.
8577
+ spawnJob(job, runId, runDir, state.config.defaultCwd, null, state.jobs).catch((e) => reportSchedulerError('spawnJob dispatch rejected', job.slug, e));
8394
8578
  }
8395
8579
  return recordTick({ fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup }, { holds });
8396
8580
  }
@@ -8449,12 +8633,20 @@ async function runDueJobs({ bypassLoadGate = false } = {}) {
8449
8633
  // ---------- when-available launch logic ----------
8450
8634
 
8451
8635
  async function maybeLaunchWhenAvailable(state) {
8636
+ utilizationHold = null;
8452
8637
  if (state.config.firePolicy !== 'when-available') return;
8453
8638
  if (state.paused) return;
8454
8639
  const pending = state.jobs.filter((j) => j.status === 'pending' && !runningSet.has(j.slug));
8455
8640
  if (pending.length === 0) return;
8456
8641
  if (cachedUtilization === null || cachedUtilization === undefined) return;
8457
8642
  if (cachedUtilization >= state.config.utilizationThreshold) {
8643
+ utilizationHold = {
8644
+ window: cachedBindingWindowName,
8645
+ percent: cachedUtilization,
8646
+ threshold: state.config.utilizationThreshold,
8647
+ resetsAt: cachedNextReset,
8648
+ };
8649
+ console.log(`[scheduler] when-available: utilization-held — ${cachedBindingWindowName ?? 'unknown'} window at ${cachedUtilization}% ≥ ${state.config.utilizationThreshold}%, resets ${cachedNextReset ?? 'unknown'}, ${pending.length} pending — holding, not ticking`);
8458
8650
  await broadcast();
8459
8651
  return;
8460
8652
  }
@@ -8825,7 +9017,24 @@ async function runQueueStarvationWatchdog(state, {
8825
9017
  // A forced tick is machine-wide by construction (the picker considers
8826
9018
  // every project's rows) — one call here services every starved cwd found
8827
9019
  // this pass, not one call per cwd.
8828
- await tickQueue({ bypassLoadGate: false }).catch((e) => console.error('[scheduler] starvation tick error', e));
9020
+ //
9021
+ // bypassLoadGate: TRUE. The watchdog only reaches here on a 'starved'
9022
+ // verdict: pending + dispatchable rows, ZERO running, idle >= 10 min. That
9023
+ // is exactly the state where the CPU load gate (scheduler.cjs's INNERMOST
9024
+ // launch predicate) is a false positive — a box whose loadavg is high while
9025
+ // *this* scheduler runs nothing is being loaded by something else, and the
9026
+ // load gate would then hold the last-resort tick behind the very saturation
9027
+ // it can't influence, freezing the queue for hours (observed 2026-09-23:
9028
+ // 3 dispatchable, 0 running, lastTickReason 'load-deferred' unbroken from
9029
+ // 07:00→13:06). The gate is advisory headroom ("leave one core free"), not
9030
+ // a resource guard; starting ONE batch cannot OOM the host. The slot pool
9031
+ // and memory gate — which DO guard real exhaustion — are unaffected by this
9032
+ // flag and still bound the batch. The watchdog is the automated equivalent
9033
+ // of a human Run-now, the other caller that legitimately bypasses load
9034
+ // (§2/§3, scheduler-operations.md). Latched to one forced tick per
9035
+ // QUEUE_STARVATION_MS per cwd, so this cannot thrash. Routed via
9036
+ // module.exports so tests can assert the bypass without driving real load.
9037
+ await module.exports.tickQueue({ bypassLoadGate: true }).catch((e) => console.error('[scheduler] starvation tick error', e));
8829
9038
  return primary;
8830
9039
  }
8831
9040
 
@@ -9520,10 +9729,26 @@ async function pollLoop() {
9520
9729
 
9521
9730
  const r = await billing.fetchUsage();
9522
9731
 
9732
+ if (r.kind === 'ok' && !Number.isFinite(bindingWindow(r.data?.usage).utilization)) {
9733
+ // Successful fetch but no finite percent: a meter/contract problem, not a
9734
+ // reading. Report it truthfully instead of leaving a stale number that
9735
+ // looks live; carry the conservative degraded budget forward.
9736
+ billing.usageCircuit.recordFailure('no_utilization');
9737
+ applyDegradedBudget();
9738
+ lastPollAt = Date.now();
9739
+ lastPollOk = false;
9740
+ persistSchedulerState();
9741
+ const cur = await readQueue();
9742
+ await maybeLaunchWhenAvailable(cur);
9743
+ await broadcast();
9744
+ return;
9745
+ }
9746
+
9523
9747
  if (r.kind === 'ok') {
9524
9748
  const window = bindingWindow(r.data?.usage);
9525
9749
  recordObservedReset(window.resets_at ?? null);
9526
- cachedUtilization = Number.isFinite(window.utilization) ? window.utilization : cachedUtilization;
9750
+ cachedUtilization = window.utilization;
9751
+ cachedBindingWindowName = window.name;
9527
9752
  lastGoodUsagePayload = r.data?.usage ?? lastGoodUsagePayload;
9528
9753
  degradedConcurrencyCapValue = null;
9529
9754
  consecutiveFailures = 0;
@@ -12645,6 +12870,9 @@ module.exports = {
12645
12870
  shouldEscalateFailureStreak,
12646
12871
  restoreFailureStreakWarnedAt,
12647
12872
  persistedStreakMinutes,
12873
+ loadSchedulerState,
12874
+ persistSchedulerState,
12875
+ getSchedulerStateSnapshot,
12648
12876
  computeDegradedBudget,
12649
12877
  healRefusalReason,
12650
12878
  writeQueue,
@@ -12656,6 +12884,7 @@ module.exports = {
12656
12884
  parsePorcelain,
12657
12885
  parsePorcelainEntries,
12658
12886
  FINISH_PROTOCOL,
12887
+ buildFinishProtocol,
12659
12888
  IDLE_OUTPUT_KILL_MS,
12660
12889
  BASH_DEFAULT_TIMEOUT_MS,
12661
12890
  BASH_MAX_TIMEOUT_MS,
@@ -12743,6 +12972,7 @@ module.exports = {
12743
12972
  FOREIGN_WIP_END_DELIMITER,
12744
12973
  TRANSIENT_RETRY_CAP,
12745
12974
  buildScheduleStatePayload,
12975
+ refreshNextReset,
12746
12976
  partitionBootOrphans,
12747
12977
  applyOrphanOutcome,
12748
12978
  registerAdminRoutes,
@@ -39,7 +39,7 @@ const path = require('node:path');
39
39
  const os = require('node:os');
40
40
  const { writeJsonSync } = require('./config.cjs');
41
41
 
42
- const PERSONAS = ['architect', 'dev-lead', 'project-home-builder'];
42
+ const PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator'];
43
43
  // What `{ done: true }` (the pre-seeded-set marker format) meant: only these two personas existed
44
44
  // in PERSONAS at the time. Fixed, not derived from the current PERSONAS array above — otherwise
45
45
  // every future persona added to PERSONAS would retroactively count as already delivered to a
@@ -2,7 +2,7 @@
2
2
  * SessionsStore — persists the renderer's tab list to disk so the app can
3
3
  * restore tabs + resume claude sessions after an electron restart.
4
4
  *
5
- * Storage: ~/.config/session-manager/tabs.json
5
+ * Storage: ~/.claude/session-manager/tabs.json
6
6
  * Shape: { tabs: PersistedTab[], activeTabId: string | null, savedAt: number }
7
7
  *
8
8
  * Only serializable, durable fields are persisted: id, sessionId, cwd,
@@ -17,8 +17,14 @@ const os = require('node:os');
17
17
  const { ipcMain } = require('electron');
18
18
  const config = require('./config.cjs');
19
19
 
20
+ let migrated = false;
20
21
  function storePath() {
21
- return path.join(os.homedir(), '.config', 'session-manager', 'tabs.json');
22
+ const p = path.join(os.homedir(), '.claude', 'session-manager', 'tabs.json');
23
+ if (!migrated) {
24
+ migrated = true;
25
+ config.migrateLegacyHomeFile(path.join(os.homedir(), '.config', 'session-manager', 'tabs.json'), p);
26
+ }
27
+ return p;
22
28
  }
23
29
 
24
30
  async function load() {
@@ -18,11 +18,11 @@ const { spawn, execFileSync } = require('node:child_process');
18
18
  const { ipcMain } = require('electron');
19
19
  const { resolveClaudeBin, claudeSpawnTarget } = require('./lib/claudeBin.cjs');
20
20
  const { withProcRole } = require('./lib/cleanEnv.cjs');
21
+ const schedulerPaths = require('./lib/schedulerPaths.cjs');
21
22
 
22
23
  const HOME = os.homedir();
23
24
  const SUPERVISOR_LOG_PATH = path.join(HOME, '.claude', 'session-manager', 'supervisor.log');
24
25
  const SUPERVISOR_LOG_MAX_BYTES = 1024 * 1024;
25
- const RUNS_DIR = path.join(HOME, '.claude', 'session-manager', 'scheduled-plans', 'runs');
26
26
 
27
27
  // In-flight probe slugs — prevents duplicate probes across ticks.
28
28
  const inFlightProbes = new Set();
@@ -371,7 +371,7 @@ async function probeJob(job, lastActivityAge) {
371
371
  const startedAt = job.startedAt || new Date().toISOString();
372
372
  const ageMinutes = Math.floor((Date.now() - Date.parse(startedAt)) / 60_000);
373
373
  const runId = job.runtime?.runId;
374
- const logPath = runId ? path.join(RUNS_DIR, runId, `${slug}.log`) : null;
374
+ const logPath = runId ? path.join(schedulerPaths.runsDir(), runId, `${slug}.log`) : null;
375
375
 
376
376
  const pstreeOutput = getPstree(jobPid);
377
377
  const childBashCmdlines = getChildBashCmdlines(jobPid);
@@ -426,7 +426,7 @@ async function supervisorTick() {
426
426
  for (const job of runningJobs) {
427
427
  if (inFlightProbes.has(job.slug)) continue;
428
428
  const runId = job.runtime?.runId;
429
- const logPath = runId ? path.join(RUNS_DIR, runId, `${job.slug}.log`) : null;
429
+ const logPath = runId ? path.join(schedulerPaths.runsDir(), runId, `${job.slug}.log`) : null;
430
430
  if (!logPath) continue;
431
431
  const lastActivity = getLastActivityTs(logPath);
432
432
  const ageMs = Date.now() - lastActivity;