claude-code-session-manager 0.119.1 → 0.121.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 (76) hide show
  1. package/dist/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-FO2ei2wB.js} +1 -1
  2. package/dist/assets/{Button-DZX5guWb.js → Button-CeIOXUPU.js} +1 -1
  3. package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-D0p__Hil.js} +1 -1
  4. package/dist/assets/{History-D58whvn_.js → History-DVqQ5azN.js} +1 -1
  5. package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-DkTnRHyT.js} +1 -1
  6. package/dist/assets/{Library-YqMi-vsA.js → Library-l7KS6pkE.js} +1 -1
  7. package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-DDw8fuRT.js} +1 -1
  8. package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-UUNIxk_3.js} +1 -1
  9. package/dist/assets/{Memory-BODDUBy4.js → Memory-CNYUoQ8C.js} +1 -1
  10. package/dist/assets/{Panel-CeOfUS9B.js → Panel-Bb54soc2.js} +1 -1
  11. package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-D-dNdV3f.js} +1 -1
  12. package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-DhcIXRhQ.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-DEAkDNjA.js} +1 -1
  14. package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-xCEgvTGU.js} +1 -1
  15. package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-BSEs7ecA.js} +1 -1
  16. package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-BIKJIU-C.js} +5 -5
  17. package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-DcaFyAV-.js} +1 -1
  18. package/dist/assets/Settings-vcdWy_Jx.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-CLrJWTPd.js} +1 -1
  20. package/dist/assets/{Skills-BeCMBQ3l.js → Skills-CYM4rBEC.js} +1 -1
  21. package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-BWNIu-iY.js} +1 -1
  22. package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-CYdwHK6m.js} +1 -1
  23. package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-Kt90lHrD.js} +1 -1
  24. package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-D1OXWYOz.js} +1 -1
  25. package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-BeQR3CKh.js} +1 -1
  26. package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-B0qafrRo.js} +1 -1
  27. package/dist/assets/{index-DdEMkmbi.js → index-Bq9uH08R.js} +201 -201
  28. package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
  29. package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-0ZksSAPB.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-C0nh8yfa.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-DPkoBybS.js} +1 -1
  32. package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-BDJMUihy.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +1 -1
  35. package/src/main/build-info.json +4 -4
  36. package/src/main/chatRunner.cjs +54 -14
  37. package/src/main/crashDiagnostics.cjs +18 -13
  38. package/src/main/health.cjs +129 -4
  39. package/src/main/index.cjs +57 -18
  40. package/src/main/ipcSchemas.cjs +18 -4
  41. package/src/main/lib/activeSessions.cjs +51 -34
  42. package/src/main/lib/claude/agentModelResolve.cjs +5 -3
  43. package/src/main/lib/claude/personaToolScope.cjs +68 -0
  44. package/src/main/lib/epic/archivedTranscript.cjs +91 -0
  45. package/src/main/lib/epic/archivedTranscriptMigration.cjs +84 -0
  46. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  47. package/src/main/lib/epic/epicSettle.cjs +21 -5
  48. package/src/main/lib/epic/epicValidationHook.cjs +7 -0
  49. package/src/main/lib/git/depHealth.cjs +161 -0
  50. package/src/main/lib/git/worktreeDeps.cjs +158 -5
  51. package/src/main/lib/machineProfile.cjs +2 -0
  52. package/src/main/lib/mcpToolCatalog.cjs +3 -1
  53. package/src/main/lib/opsRetentionSettings.cjs +183 -0
  54. package/src/main/lib/persona-macro/macroLibrary.cjs +75 -0
  55. package/src/main/lib/prd/prdCreate.cjs +25 -0
  56. package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
  57. package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
  58. package/src/main/lib/process/procGroupRss.cjs +61 -0
  59. package/src/main/lib/remoteTranscriptTail.cjs +69 -2
  60. package/src/main/lib/scheduler/queueHistory.cjs +68 -9
  61. package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
  62. package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
  63. package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
  64. package/src/main/lib/telemetry/auditLog.cjs +51 -6
  65. package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
  66. package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
  67. package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
  68. package/src/main/scheduler/prdParser.cjs +50 -0
  69. package/src/main/scheduler/prdPaths.cjs +61 -16
  70. package/src/main/scheduler/reviewLadder.cjs +24 -1
  71. package/src/main/scheduler.cjs +214 -37
  72. package/src/main/templates/PRD_AUTHORING.md +2 -2
  73. package/src/main/webRemote.cjs +82 -91
  74. package/src/preload/api.d.ts +41 -3
  75. package/src/preload/index.cjs +9 -0
  76. package/dist/assets/Settings-B4LuoRnd.js +0 -4
@@ -57,6 +57,7 @@ const billing = require('./usage.cjs');
57
57
  const { cleanChildEnv, pathWithUserBins } = require('./lib/process/cleanEnv.cjs');
58
58
  const { ensureTimeoutShim, withTimeoutShimOnPath } = require('./lib/process/timeoutShim.cjs');
59
59
  const supervisor = require('./supervisor.cjs');
60
+ const { processGroupRssMb } = require('./lib/process/procGroupRss.cjs');
60
61
  const { resolveClaudeBin, claudeSpawnTarget, probeClaudeVersion } = require('./lib/claude/claudeBin.cjs');
61
62
  const { headlessPermissionArgs, ensureCliCapsProbed } = require('./lib/claude/claudeCliCaps.cjs');
62
63
  // Fire-and-forget priming — by the time the first job dispatches, the cached
@@ -81,6 +82,10 @@ const { stripAppOwnedChurn } = require('./lib/scheduler/jobDirtFilter.cjs');
81
82
  const { GATE_AUTHORITY_VERDICTS, decideGateAuthority } = require('./lib/scheduler/gateAuthority.cjs');
82
83
  const { decideBaselineAuthority } = require('./lib/scheduler/baselineGate.cjs');
83
84
  const { linkDependencyDirs } = require('./lib/git/worktreeDeps.cjs');
85
+ const { checkMainDependencyHealth, repairMainDependencies } = require('./lib/git/depHealth.cjs');
86
+ // cwd -> last main-checkout dependency repair attempt (ms); caps repair to once per cooldown.
87
+ const mainDepsRepairAt = new Map();
88
+ const MAIN_DEPS_REPAIR_COOLDOWN_MS = 10 * 60_000;
84
89
  const { resolveBindingRateLimitReset } = require('./lib/claude/rateLimitWindow.cjs');
85
90
  const { isResetFresh, bindingWindow, degradedBudget, meterFailureBudget } = require('./lib/claude/usageCircuit.cjs');
86
91
  const { computeQueueHealth } = require('./lib/scheduler/queueHealth.cjs');
@@ -146,6 +151,7 @@ const queueOps = require('./queueOps.cjs');
146
151
  const { listEpicPrdDirs, listArchivedPrdDirs, deriveProjectCwdFromPrdPath } = require('./lib/prd/prdLocations.cjs');
147
152
  const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epic/epicMint.cjs');
148
153
  const agentModelResolve = require('./lib/claude/agentModelResolve.cjs');
154
+ const { buildPersonaToolScope, personaToolScopeArgs } = require('./lib/claude/personaToolScope.cjs');
149
155
  const { resolveEpicEffort, effortArgs } = require('./lib/claude/agentEffortResolve.cjs');
150
156
  const { transitionJob, STATUS_HISTORY_CAP, LEGAL_TRANSITIONS } = require('./lib/scheduler/scheduleJobTransitions.cjs');
151
157
  const { buildContextDigest, composeExecutorPrompt } = require('./lib/epic/epicContextDigest.cjs');
@@ -411,7 +417,9 @@ const HEARTBEAT_MAX_BYTES = 1024 * 1024;
411
417
  // DEFAULT_PROJECT_CWD imported from lib/scheduler/schedulerBatch.cjs (single source of truth).
412
418
 
413
419
  // Each headless claude -p job can shell out to tsc/vite/pytest and grow well
414
- // past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
420
+ // past 1 GB at peak; reserve 2.5 GB of PEAK headroom per running+pending slot.
421
+ // A running job's already-resident memory is credited back (consumedMb in
422
+ // memoryLimitedBatchSize) because MemAvailable is already net of it. Raised from 1.5 GB
415
423
  // after the 2026-06-16 OOM: 3 concurrent cross-project jobs + their build
416
424
  // subprocesses pushed a 24 GB host ~10 GB into swap and the OOM killer took
417
425
  // Electron — every pty got SIGHUP (code=0 signal=1).
@@ -478,10 +486,13 @@ function getAvailableMemMb() {
478
486
  * drop available memory below MIN_FREE_MB_PER_JOB per active slot.
479
487
  * Exported for unit tests.
480
488
  */
481
- function memoryLimitedBatchSize(availableMb, minPerJob, runningCount, batchLen) {
489
+ function memoryLimitedBatchSize(availableMb, minPerJob, runningCount, batchLen, consumedMb = 0) {
482
490
  if (availableMb === Infinity) return batchLen;
491
+ // Credit is bounded by the running jobs' own reservations: never negative,
492
+ // never more permissive than a 0-running machine.
493
+ const credit = Math.min(Math.max(Number(consumedMb) || 0, 0), minPerJob * runningCount);
483
494
  let allowed = batchLen;
484
- while (allowed > 0 && availableMb < minPerJob * (runningCount + allowed)) {
495
+ while (allowed > 0 && availableMb < minPerJob * (runningCount + allowed) - credit) {
485
496
  allowed--;
486
497
  }
487
498
  return allowed;
@@ -522,6 +533,26 @@ function biasJobOomScore(pid) {
522
533
  // source and a search fallback. Live PRDs resolve per-job to
523
534
  // `<job.cwd>/session-manager-operations/scheduler/prds/` via prdLocations.cjs.
524
535
 
536
+ /**
537
+ * Last-line guard before any 'prd-missing' retire: bust the project-cwd cache
538
+ * and re-check every job-scoped dir plus findPrdDirForJob for
539
+ * `<dir>/<slug>.md`. Returns the first existing absolute path, or null. A
540
+ * stale allProjectCwds scan must never retire a PRD whose file exists.
541
+ */
542
+ async function recheckPrdSourceForJob(job) {
543
+ const slug = job && job.slug;
544
+ if (typeof slug !== 'string' || !slug || path.basename(slug) !== slug) return null;
545
+ try { require('./lib/activeSessions.cjs').bustProjectCwdCache(); } catch { /* best-effort */ }
546
+ const dirs = [...(await prdPaths.jobScopedPrdDirs(job))];
547
+ const found = await prdPaths.findPrdDirForJob(job);
548
+ if (found) dirs.push(found);
549
+ for (const dir of dirs) {
550
+ const candidate = path.join(dir, `${slug}.md`);
551
+ try { await fsp.access(candidate); return candidate; } catch { /* not here */ }
552
+ }
553
+ return null;
554
+ }
555
+
525
556
  /**
526
557
  * Build the non-failure result + run meta for a queued job whose PRD source
527
558
  * is gone. Shared by both PRD-read failure exits in executeJob so the
@@ -2665,7 +2696,12 @@ async function clearPause(source) {
2665
2696
  */
2666
2697
  function resetJobFields(job, errorMsg, opts = {}) {
2667
2698
  if ((job.status === 'completed' || job.status === 'skipped') && opts.force !== true) return false;
2699
+ // Durable cancel (2026-10-19 git-viewer incident): a human-cancelled row is
2700
+ // revived only by an explicit human reset, never by an automated pass.
2701
+ if (reviewLadder.isCancelledJob(job) && opts.source !== 'remote:resetJob' && opts.source !== 'ipc:schedule:reset-job') return false;
2668
2702
  if (!transitionJob(job, 'pending', { reason: errorMsg ?? 'reset to pending', source: opts.source ?? 'resetJobFields' })) return false;
2703
+ delete job.cancelledAt;
2704
+ delete job.cancelledRunId;
2669
2705
  job.runId = null;
2670
2706
  job.startedAt = null;
2671
2707
  job.finishedAt = null;
@@ -3093,6 +3129,13 @@ async function notifyOriginatingTab(job, {
3093
3129
  + (failures.length ? `. Pre-existing failures: ${shown}${more}` : '')
3094
3130
  + '. Check Scheduler for details.';
3095
3131
  }
3132
+ if (job.status === 'skipped' && Array.isArray(job.blockedDependents) && job.blockedDependents.length > 0) {
3133
+ const deps = job.blockedDependents;
3134
+ const shown = deps.slice(0, 10).join(', ');
3135
+ const more = deps.length > 10 ? ` (+${deps.length - 10} more)` : '';
3136
+ message = `PRD ${job.slug} was skipped: its PRD source could not be found on disk. Blocked dependents: ${shown}${more}.`
3137
+ + ' If the PRD file exists, run scheduler_reset_job with force:true; if it was intentionally removed, archive it with scheduler_archive_prd.';
3138
+ }
3096
3139
 
3097
3140
  // The EPIC this check-in belongs to. Deliberately does NOT consider
3098
3141
  // `sourceTabId` — that is a Terminal tab id, not an Epic id, and
@@ -4142,16 +4185,21 @@ const HEADLESS_DISALLOWED_TOOLS = Object.freeze([
4142
4185
  * persona body, resolved by agentModelResolve.cjs's resolvePrdPersonaForSpawn),
4143
4186
  * is passed as `--append-system-prompt` so the executor IS that persona at
4144
4187
  * launch rather than being asked in prose to adopt one.
4188
+ * `toolScope` (buildPersonaToolScope of the persona's `tools:` list; null =
4189
+ * unscoped) adds `--agents/--agent` so the persona's tool list is enforced —
4190
+ * `--allowedTools` is a no-op under --dangerously-skip-permissions. It is
4191
+ * inserted BEFORE that flag, never between `--disallowedTools` and its value.
4145
4192
  * `--disallowedTools` takes a variadic list, so another flag must always
4146
4193
  * follow it to end that list — `--output-format` does, right after. Never
4147
4194
  * put `--disallowedTools` last or right before the prompt.
4148
4195
  */
4149
- function buildClaudeSpawnArgs({ prompt, model, effort, sessionId, resume, systemPrompt }) {
4196
+ function buildClaudeSpawnArgs({ prompt, model, effort, sessionId, resume, systemPrompt, toolScope }) {
4150
4197
  return [
4151
4198
  '-p', prompt,
4152
4199
  '--model', model,
4153
4200
  ...effortArgs(effort),
4154
4201
  ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : []),
4202
+ ...personaToolScopeArgs(toolScope),
4155
4203
  '--dangerously-skip-permissions',
4156
4204
  ...headlessPermissionArgs(),
4157
4205
  '--disallowedTools', HEADLESS_DISALLOWED_TOOLS.join(','),
@@ -4289,6 +4337,26 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4289
4337
  // then an ancestor-folder Epic walk above job.cwd) first, so the common
4290
4338
  // case — a live Epic-scoped PRD — is a first-try hit instead of probing
4291
4339
  // the retired flat dir and only then falling back.
4340
+ // Shared by both 'prd-missing' exits: resolves to null (truly missing), the
4341
+ // string 'resolved' (file found + parsed; prompt/prdPath/parsedPrdMeta set),
4342
+ // or a ready-to-return { exitCode: -1, error } result.
4343
+ const recheckBeforeMissingSkip = async () => {
4344
+ const found = await recheckPrdSourceForJob(job);
4345
+ if (!found) return null;
4346
+ try {
4347
+ const parsed = await parsePrd(found);
4348
+ prompt = parsed.body;
4349
+ prdPath = found;
4350
+ parsedPrdMeta = parsed;
4351
+ } catch (err) {
4352
+ const error = `PRD exists at ${found} but could not be read: ${err?.message}`;
4353
+ safeLog(`[scheduler] ${error}\n`);
4354
+ closeFd();
4355
+ return { exitCode: -1, durationMs: 0, error, sessionId };
4356
+ }
4357
+ safeLog(`[scheduler] PRD resolved on re-check at ${found}\n`);
4358
+ return 'resolved';
4359
+ };
4292
4360
  const resolvedDir = await prdPaths.findPrdDirForJob(job);
4293
4361
  prdPath = resolvedDir ? path.join(resolvedDir, `${job.slug}.md`) : prdPaths.prdPathForJob(job);
4294
4362
  try {
@@ -4324,11 +4392,14 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4324
4392
  return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
4325
4393
  }
4326
4394
  if (e2 && e2.code === 'ENOENT') {
4327
- return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
4395
+ const rc = await recheckBeforeMissingSkip();
4396
+ if (rc && rc !== 'resolved') return rc;
4397
+ if (!rc) return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
4398
+ } else {
4399
+ safeLog(`[scheduler] failed to read PRD: ${e2?.message}\n`);
4400
+ closeFd();
4401
+ return { exitCode: -1, durationMs: 0, error: e2?.message };
4328
4402
  }
4329
- safeLog(`[scheduler] failed to read PRD: ${e2?.message}\n`);
4330
- closeFd();
4331
- return { exitCode: -1, durationMs: 0, error: e2?.message };
4332
4403
  }
4333
4404
  } else {
4334
4405
  // No candidate dir has the slug at all. Case A: it was archived —
@@ -4341,11 +4412,14 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4341
4412
  return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
4342
4413
  }
4343
4414
  if (e && e.code === 'ENOENT') {
4344
- return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
4415
+ const rc = await recheckBeforeMissingSkip();
4416
+ if (rc && rc !== 'resolved') return rc;
4417
+ if (!rc) return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
4418
+ } else {
4419
+ safeLog(`[scheduler] failed to read PRD: ${e?.message}\n`);
4420
+ closeFd();
4421
+ return { exitCode: -1, durationMs: 0, error: e?.message };
4345
4422
  }
4346
- safeLog(`[scheduler] failed to read PRD: ${e?.message}\n`);
4347
- closeFd();
4348
- return { exitCode: -1, durationMs: 0, error: e?.message };
4349
4423
  }
4350
4424
  }
4351
4425
  } // end resumeTarget ? preamble : normal-PRD-read
@@ -4437,7 +4511,8 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4437
4511
  // and is logged once by resolvePrdPersonaForSpawn itself.
4438
4512
  const personaResolution = await agentModelResolve.resolvePrdPersonaForSpawn({ cwd, agentType: job.agentType });
4439
4513
  const personaEffort = resolveEpicEffort({ cwd, agentType: job.agentType }).effort;
4440
- safeLog(`[scheduler] agentType=${job.agentType || '(none)'} persona=${personaResolution.personaPath || '(fallback — no persona applied)'} model=${personaResolution.model}${personaEffort ? ` effort=${personaEffort}` : ''}\n`);
4514
+ const personaToolScope = buildPersonaToolScope(personaResolution.tools, { disallowed: HEADLESS_DISALLOWED_TOOLS });
4515
+ safeLog(`[scheduler] agentType=${job.agentType || '(none)'} persona=${personaResolution.personaPath || '(fallback — no persona applied)'} model=${personaResolution.model}${personaEffort ? ` effort=${personaEffort}` : ''} ${personaToolScope ? `tools=${personaToolScope.tools.length} scoped` : 'tools=unscoped'}\n`);
4441
4516
 
4442
4517
  // Authoritative guarantee that buildClaudeSpawnArgs (a pure sync helper,
4443
4518
  // called below via withChildAndLog) sees a resolved --permission-prompts
@@ -4646,6 +4721,7 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4646
4721
  sessionId,
4647
4722
  resume: !!resumeTarget,
4648
4723
  systemPrompt: personaResolution.systemPrompt,
4724
+ toolScope: personaToolScope,
4649
4725
  });
4650
4726
  const jobSpawn = claudeSpawnTarget('job', job.slug, claudeBin, jobArgs);
4651
4727
 
@@ -4836,7 +4912,7 @@ function isPromotableOriginal(status) {
4836
4912
  */
4837
4913
  function healTargetForFix(fixSlug, jobs) {
4838
4914
  const originalSlug = fixSlug.replace(/^(\d+)-fix-/, '$1-');
4839
- return jobs.find((x) => x.slug === originalSlug && isPromotableOriginal(x.status)) || null;
4915
+ return jobs.find((x) => x.slug === originalSlug && isPromotableOriginal(x.status) && !reviewLadder.isCancelledJob(x)) || null;
4840
4916
  }
4841
4917
 
4842
4918
  /**
@@ -5179,8 +5255,8 @@ async function spawnInvestigation(failedJob, runDir, { deadChild = null } = {})
5179
5255
  await mutate((s) => {
5180
5256
  const j = s.jobs.find((x) => x.slug === failedJob.slug);
5181
5257
  if (!j) return;
5182
- const statusEligible = j.status === 'needs_review' || j.status === 'failed';
5183
- const transitioned = transitionJob(j, 'investigating', { reason: 'spawning investigation probe', source: 'spawnInvestigation:start' });
5258
+ const statusEligible = (j.status === 'needs_review' || j.status === 'failed') && !reviewLadder.isCancelledJob(j);
5259
+ const transitioned = statusEligible && transitionJob(j, 'investigating', { reason: 'spawning investigation probe', source: 'spawnInvestigation:start' });
5184
5260
  liveCheck = { ok: statusEligible && transitioned, status: j.status };
5185
5261
  });
5186
5262
  if (!liveCheck.ok) {
@@ -5530,10 +5606,11 @@ function reportSchedulerError(message, slug, e) {
5530
5606
  * worktreeIntegrationFailure: string|null, worktreeIntegrationDetail: object|null,
5531
5607
  * mergeAutoResolved: string|null, mergeAutoResolvedPaths: string[]|null}>}
5532
5608
  */
5533
- async function finalizeJobWorktree({ job, runDir, worktree, guardCwd, carriedPaths, deps = {} }) {
5609
+ async function finalizeJobWorktree({ job, runId = null, runDir, worktree, guardCwd, carriedPaths, deps = {} }) {
5534
5610
  const jw = deps.jobWorktree || jobWorktree;
5535
5611
  const uncommitted = deps.uncommittedChanges || gitTreeGuard.uncommittedChanges;
5536
5612
  const report = deps.reportSchedulerError || reportSchedulerError;
5613
+ const runCancelled = deps.isRunCancelled || isRunCancelled;
5537
5614
  let worktreeLeftoverDirty = [];
5538
5615
  let salvagePatch = null;
5539
5616
  let worktreeIntegrationFailure = null;
@@ -5556,6 +5633,12 @@ async function finalizeJobWorktree({ job, runDir, worktree, guardCwd, carriedPat
5556
5633
  console.log(`[scheduler] ${job.slug}: salvaged ${salvage.bytes} byte(s) of uncommitted worktree diff to ${salvagePath}`);
5557
5634
  }
5558
5635
  }
5636
+ if (await runCancelled(job.slug, runId)) {
5637
+ // Cancelled mid-run: keep the commit on the job branch only.
5638
+ appendAuditEvent('job_cancel_integration_skipped', { slug: job.slug, runId, branch: worktree.branch });
5639
+ await jw.cleanupJobWorktree({ cwd: guardCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
5640
+ return { worktreeLeftoverDirty, salvagePatch, worktreeIntegrationFailure, worktreeIntegrationDetail, mergeAutoResolved, mergeAutoResolvedPaths, integratedSha: null, cancelled: true };
5641
+ }
5559
5642
  const integration = await jw.integrateJobBranch({ cwd: guardCwd, branch: worktree.branch, slug: job.slug, carriedPaths, baseBranch: worktree.baseBranch });
5560
5643
  if (integration.ok && integration.reason === 'carried-wip-only') {
5561
5644
  console.log(`[scheduler] ${job.slug}: worktree branch ${worktree.branch} touched only carried base WIP paths — skipping merge (carried-wip-only)`);
@@ -5587,7 +5670,25 @@ async function finalizeJobWorktree({ job, runDir, worktree, guardCwd, carriedPat
5587
5670
  await jw.cleanupJobWorktree({ cwd: guardCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
5588
5671
  } catch { /* already reported above */ }
5589
5672
  }
5590
- return { worktreeLeftoverDirty, salvagePatch, worktreeIntegrationFailure, worktreeIntegrationDetail, mergeAutoResolved, mergeAutoResolvedPaths, integratedSha };
5673
+ return { worktreeLeftoverDirty, salvagePatch, worktreeIntegrationFailure, worktreeIntegrationDetail, mergeAutoResolved, mergeAutoResolvedPaths, integratedSha, cancelled: false };
5674
+ }
5675
+
5676
+ /**
5677
+ * True when the queue row for `slug` carries a durable cancel stamp for this
5678
+ * exact run (`cancelledAt` + matching `cancelledRunId`). Incident 2026-10-19
5679
+ * (git-viewer): a cancelled running PRD's commit still landed on main because
5680
+ * finalizeJobWorktree integrated unconditionally. A read error returns false so
5681
+ * a flaky read never strands every normal run's work. Known residual: a cancel
5682
+ * arriving after integrateJobBranch already completed cannot un-land the commit.
5683
+ */
5684
+ async function isRunCancelled(slug, runId) {
5685
+ try {
5686
+ const state = await readQueue();
5687
+ const row = state.jobs.find((j) => j.slug === slug);
5688
+ return !!(row && row.cancelledAt && (row.cancelledRunId ?? null) === (runId ?? null));
5689
+ } catch {
5690
+ return false;
5691
+ }
5591
5692
  }
5592
5693
 
5593
5694
  /**
@@ -6016,7 +6117,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6016
6117
  } finally {
6017
6118
  if (worktree.ok) {
6018
6119
  ({ worktreeLeftoverDirty, salvagePatch, worktreeIntegrationFailure, worktreeIntegrationDetail, mergeAutoResolved, mergeAutoResolvedPaths, integratedSha } =
6019
- await finalizeJobWorktree({ job, runDir, worktree, guardCwd, carriedPaths }));
6120
+ await finalizeJobWorktree({ job, runId, runDir, worktree, guardCwd, carriedPaths }));
6020
6121
  } else {
6021
6122
  // In-place run (non-git cwd, cap reached, env-disabled, or a carry-over
6022
6123
  // failure) — there is no throwaway checkout to diff, so salvage only
@@ -6139,6 +6240,8 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6139
6240
  // 20 never-executed sigma PRDs read as shipped (2026-08-30 incident).
6140
6241
  if (res.skipped) {
6141
6242
  const targetStatus = res.skipped === 'prd-missing' ? 'skipped' : 'completed';
6243
+ /** @type {string[]} */
6244
+ let blockedDependents = [];
6142
6245
  await mutate((s) => {
6143
6246
  const idx = s.jobs.findIndex((x) => x.slug === job.slug);
6144
6247
  if (idx >= 0) {
@@ -6147,9 +6250,25 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6147
6250
  s.jobs[idx].exitCode = 0;
6148
6251
  s.jobs[idx].error = null;
6149
6252
  delete s.jobs[idx].runtime;
6253
+ if (res.skipped === 'prd-missing') {
6254
+ // Dependents wait on this slug forever once it is skipped; record
6255
+ // them so the Epic can be told what is blocked.
6256
+ blockedDependents = s.jobs
6257
+ .filter((x) => x.slug !== job.slug
6258
+ && x.cwd === job.cwd
6259
+ && Array.isArray(x.dependsOn) && x.dependsOn.includes(job.slug)
6260
+ && (x.status == null || x.status === 'pending' || x.status === 'needs_review'))
6261
+ .map((x) => x.slug);
6262
+ s.jobs[idx].blockedDependents = blockedDependents;
6263
+ }
6150
6264
  }
6151
6265
  });
6152
6266
  await broadcast({ flush: true });
6267
+ if (res.skipped === 'prd-missing' && blockedDependents.length > 0) {
6268
+ notifyOriginatingTab({ ...job, status: 'skipped', blockedDependents }).catch((e) => {
6269
+ console.error('[scheduler] notifyOriginatingTab error', job.slug, e);
6270
+ });
6271
+ }
6153
6272
  return;
6154
6273
  }
6155
6274
 
@@ -7342,22 +7461,34 @@ async function tickBody(gen, { bypassLoadGate }) {
7342
7461
  const jobBudgetMb = availableForJobs(availableMb, RESERVED_HOST_MB);
7343
7462
  // The slot pool already bounded `batch` (it was passed as freeSlots to
7344
7463
  // pickNextBatch above), so only the memory gate can narrow it further.
7464
+ // Credit each running job's already-resident RSS (capped at its reservation)
7465
+ // — MemAvailable is already net of it. Unknown pid / 0 MB read = no credit.
7466
+ let consumedMb = 0;
7467
+ if (availableMb !== Infinity) {
7468
+ for (const j of state.jobs) {
7469
+ if (!runningSet.has(j.slug)) continue;
7470
+ const pid = j.runtime?.pid ?? j.pid;
7471
+ if (!Number.isInteger(pid) || pid <= 0) continue;
7472
+ consumedMb += Math.min(processGroupRssMb(pid), MIN_FREE_MB_PER_JOB);
7473
+ }
7474
+ consumedMb = Math.min(consumedMb, MIN_FREE_MB_PER_JOB * runningSet.size);
7475
+ }
7345
7476
  const allowed = memoryLimitedBatchSize(
7346
- jobBudgetMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length,
7477
+ jobBudgetMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length, consumedMb,
7347
7478
  );
7348
7479
  if (allowed === 0) {
7349
- const threshold = RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
7350
- console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job × ${runningSet.size + 1}) — deferring ${batch.length} job(s)`);
7351
- lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
7480
+ const threshold = RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + 1) - consumedMb;
7481
+ console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job × ${runningSet.size + 1} - consumed=${consumedMb} MB) — deferring ${batch.length} job(s)`);
7482
+ lastMemGate = { availableMb, threshold, consumedMb, deferred: true, at: new Date().toISOString() };
7352
7483
  return recordTick(
7353
- { fired: false, reason: 'memory-deferred', deferredCount: batch.length, availableMb, threshold },
7354
- { detail: `memory gate: ${availableMb} MB free, need ${threshold} MB`, holds },
7484
+ { fired: false, reason: 'memory-deferred', deferredCount: batch.length, availableMb, threshold, consumedMb },
7485
+ { detail: `memory gate: ${availableMb} MB free, need ${threshold} MB (consumed=${consumedMb} MB credited)`, holds },
7355
7486
  );
7356
7487
  }
7357
7488
  const gatedBatch = batch.slice(0, allowed);
7358
7489
  if (gatedBatch.length < batch.length) {
7359
- console.log(`[scheduler] memory gate: available=${availableMb} MB — clamped batch ${batch.length} → ${gatedBatch.length} (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job)`);
7360
- lastMemGate = { availableMb, threshold: RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + gatedBatch.length), deferred: false, clamped: true, at: new Date().toISOString() };
7490
+ console.log(`[scheduler] memory gate: available=${availableMb} MB — clamped batch ${batch.length} → ${gatedBatch.length} (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job, consumed=${consumedMb} MB)`);
7491
+ lastMemGate = { availableMb, threshold: RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + gatedBatch.length) - consumedMb, consumedMb, deferred: false, clamped: true, at: new Date().toISOString() };
7361
7492
  } else {
7362
7493
  // Ungated full batch: clear stale gate snapshot so status doesn't show
7363
7494
  // a stale deferral from a previous tick.
@@ -8499,7 +8630,7 @@ function selectHistoryJobs(jobs, limit, historyEntries = []) {
8499
8630
  * guard-parked row was never "exhausted auto-fix" and must never claim to be.
8500
8631
  */
8501
8632
  function applyNeedsReviewAutoResolve(j, jobsInProject = []) {
8502
- if (!j || j.status !== 'needs_review' || !reviewLadder.isEligibleForNeedsReviewAutoResolve(j, jobsInProject)) return null;
8633
+ if (!j || j.status !== 'needs_review' || reviewLadder.isCancelledJob(j) || !reviewLadder.isEligibleForNeedsReviewAutoResolve(j, jobsInProject)) return null;
8503
8634
  if ((j.exhaustedResolveAttempts ?? 0) > reviewLadder.NEEDS_REVIEW_RESOLVE_CAP) return null;
8504
8635
  const originIsGuardParked = !reviewLadder.isExhaustedAutoFix(j) && reviewLadder.isGuardParkedWithoutAutoFix(j);
8505
8636
 
@@ -8744,12 +8875,31 @@ async function runGateShadow(job) {
8744
8875
  const dirtBefore = await gateTreeDirt(job.cwd);
8745
8876
  const gate = resolveGate(prdText);
8746
8877
  let outcome;
8878
+ let mainDeps = null;
8879
+ let repaired = false;
8747
8880
  if (gate.source === 'none') outcome = { status: 'unavailable', reason: 'gate-opt-out', results: [] };
8748
8881
  else if (!gate.sequence.length) outcome = { status: 'unavailable', reason: 'no-parseable-gate', results: [] };
8749
8882
  else {
8750
- const r = await runGateSequence(gate.sequence, { cwd: job.cwd });
8751
- if (r.status === 'busy') return null;
8752
- outcome = r;
8883
+ let health = checkMainDependencyHealth(job.cwd);
8884
+ if (!health.ok) {
8885
+ const last = mainDepsRepairAt.get(job.cwd) ?? 0;
8886
+ if (Date.now() - last >= MAIN_DEPS_REPAIR_COOLDOWN_MS) {
8887
+ mainDepsRepairAt.set(job.cwd, Date.now());
8888
+ const rep = await repairMainDependencies(job.cwd);
8889
+ repaired = rep.ok;
8890
+ console.warn(`[scheduler] main deps broken in ${job.cwd}: ${health.reason} — repair ${rep.ok ? 'ok' : 'failed'}`);
8891
+ health = checkMainDependencyHealth(job.cwd);
8892
+ }
8893
+ mainDeps = { ok: health.ok, reason: health.reason ?? null, missing: health.missing ?? [], repaired };
8894
+ }
8895
+ if (!health.ok) {
8896
+ const names = health.missing && health.missing.length ? `: ${health.missing.join(', ')}` : '';
8897
+ outcome = { status: 'unavailable', reason: `main-deps-broken: ${health.reason}${names}`, results: [] };
8898
+ } else {
8899
+ const r = await runGateSequence(gate.sequence, { cwd: job.cwd });
8900
+ if (r.status === 'busy') return null;
8901
+ outcome = r;
8902
+ }
8753
8903
  }
8754
8904
  const headAfter = await gitTreeGuard.gitHead(job.cwd);
8755
8905
  const dirtAfter = await gateTreeDirt(job.cwd);
@@ -8761,7 +8911,7 @@ async function runGateShadow(job) {
8761
8911
  // about done-ness, so it must stay eligible for retry once the PRD gains a
8762
8912
  // real gate. `busy` never reaches here (returns early above).
8763
8913
  const definitive = headStable && cleanOk && (outcome.status === 'green' || outcome.status === 'red');
8764
- const gateShadow = { ...outcome, head: headBefore, source: gate.source, ranAt: new Date().toISOString(), definitive };
8914
+ const gateShadow = { ...outcome, head: headBefore, source: gate.source, ranAt: new Date().toISOString(), definitive, ...(mainDeps ? { mainDeps } : {}) };
8765
8915
 
8766
8916
  // Gate authority: a green re-run may complete the row outright, but only
8767
8917
  // for transcript-noise verdicts with a landed commit — every other park
@@ -9930,7 +10080,7 @@ function rescheduleIntervalTick() {
9930
10080
  mutate(async (ms) => {
9931
10081
  for (const target of autoResetTargets) {
9932
10082
  const j = ms.jobs.find((x) => x.slug === target.slug);
9933
- if (!j || j.status !== 'failed' || (j.failedAutoResetAttempts ?? 0) >= reviewLadder.FAILED_AUTORESET_CAP) continue; // race guard
10083
+ if (!j || j.status !== 'failed' || reviewLadder.isCancelledJob(j) || (j.failedAutoResetAttempts ?? 0) >= reviewLadder.FAILED_AUTORESET_CAP) continue; // race guard
9934
10084
  const attempt = (j.failedAutoResetAttempts ?? 0) + 1;
9935
10085
  j.failedAutoResetAttempts = attempt;
9936
10086
  const reason = `auto-reset after ${Math.round(reviewLadder.FAILED_AUTORESET_MS / 60_000)}m failed (attempt ${attempt}/${reviewLadder.FAILED_AUTORESET_CAP})`;
@@ -10823,9 +10973,6 @@ const remote = {
10823
10973
  }
10824
10974
  const wasRunning = job.status === 'running';
10825
10975
  const pid = job.runtime?.pid;
10826
- if (wasRunning && pid) {
10827
- killOrphanClaudePid(pid);
10828
- }
10829
10976
  // Evidence-before-failure guard, scoped to an actually-running job being
10830
10977
  // killed here (a 'pending' cancel has no live process, so nothing new
10831
10978
  // could have landed since its last stamp — and 'needs_review' is not
@@ -10845,19 +10992,49 @@ const remote = {
10845
10992
  const cancelReason = confirmedLandedCommit
10846
10993
  ? `cancelled via admin API, but landedCommit ${confirmedLandedCommit} resolves — verify before treating as done`
10847
10994
  : 'cancelled via admin API';
10995
+ const cancelledAt = new Date().toISOString();
10848
10996
  await mutate((s) => {
10849
10997
  const idx = s.jobs.findIndex((j) => j.slug === slug);
10850
10998
  if (idx < 0) return;
10851
10999
  const j = s.jobs[idx];
10852
11000
  transitionJob(j, targetStatus, { reason: cancelReason, source: 'remote:cancelJob' });
10853
11001
  j.error = cancelReason;
11002
+ j.cancelledAt = cancelledAt;
11003
+ if (wasRunning) j.cancelledRunId = job.runId ?? null;
10854
11004
  j.finishedAt = new Date().toISOString();
10855
11005
  j.exitCode = j.exitCode ?? null;
10856
11006
  if (confirmedLandedCommit) j.verifierVerdict = 'cancelled_with_landed_commit';
10857
11007
  delete j.runtime;
10858
11008
  });
11009
+ // Stamped before SIGTERM so finalizeJobWorktree (run on executor exit) sees it.
11010
+ if (wasRunning && pid) killOrphanClaudePid(pid);
10859
11011
  await broadcast({ flush: true });
10860
- return { ok: true, slug, status: targetStatus, wasRunning, cwd: job.cwd ?? null };
11012
+ const result = { ok: true, slug, status: targetStatus, wasRunning, cwd: job.cwd ?? null, cancelledAt };
11013
+ if (wasRunning) {
11014
+ // Whatever the executor committed before SIGTERM stays on its job
11015
+ // branch (never integrated); tell the caller where. Known residual: a
11016
+ // cancel arriving after integration already finished reports
11017
+ // landed:false with jobBranch:null (branch deleted after the merge).
11018
+ let jobBranch = null;
11019
+ let jobBranchSha = null;
11020
+ try {
11021
+ const branch = jobWorktree.branchNameFor(slug);
11022
+ const stdout = await execGitAt(
11023
+ resolveProjectRoot(job.cwd || DEFAULT_PROJECT_CWD),
11024
+ ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`],
11025
+ { timeout: 10_000 },
11026
+ );
11027
+ const sha = String(stdout || '').trim();
11028
+ if (sha) { jobBranch = branch; jobBranchSha = sha; }
11029
+ } catch { /* branch absent or git errored → nulls */ }
11030
+ Object.assign(result, {
11031
+ landed: confirmedLandedCommit != null,
11032
+ landedCommit: confirmedLandedCommit ?? null,
11033
+ jobBranch,
11034
+ jobBranchSha,
11035
+ });
11036
+ }
11037
+ return result;
10861
11038
  },
10862
11039
 
10863
11040
  // Exposes the module-level allocateParallelGroup (PRD 548) to callers that
@@ -1,4 +1,4 @@
1
- <!-- PRD_AUTHORING.md v6 -->
1
+ <!-- PRD_AUTHORING.md v7 -->
2
2
  # PRD Authoring Guide — Scheduler Safety Rules
3
3
 
4
4
  Rule: every PRD you queue follows the rules below.
@@ -125,7 +125,7 @@ A short Epic-context digest is prepended automatically for orientation only —
125
125
 
126
126
  The executor can't ask you anything: no `ScheduleWakeup`, Cron, Monitor, `AskUserQuestion`, plan mode, or worktree tools; background tasks are off (a `timeout` command is always on PATH — the app installs a shim on macOS). It must never stop to ask a question — it makes the safest reasonable call and reports it, so write the PRD so that call is obvious.
127
127
 
128
- Worktree reach: every PRD runs in an isolated git worktree holding tracked files plus symlinked `node_modules`/`.venv`/`venv` only. A step that needs gitignored runtime state (databases, `downloads/`, browser profiles, logs) or a live side effect must address the main checkout by absolute path (e.g. `$HOME/Projects/<repo>/...`), be artifact-only (a deliverable artifact + `artifactPaths`, no tracked edits), and `dependsOn` the PRD that lands its tracked edits. Host crontab installs stay human-only (see §15).
128
+ Worktree reach: every PRD runs in an isolated git worktree holding tracked files plus `.venv`/`venv` (and `node_modules` for non-pnpm projects) symlinked from the main checkout; pnpm projects (pnpm-lock.yaml) get a worktree-local `node_modules` installed offline from the store. Why: a pnpm install through the symlink purged the main checkout's node_modules (Bilko-Game-Academy 2026-10-18). A step that needs gitignored runtime state (databases, `downloads/`, browser profiles, logs) or a live side effect must address the main checkout by absolute path (e.g. `$HOME/Projects/<repo>/...`), be artifact-only (a deliverable artifact + `artifactPaths`, no tracked edits), and `dependsOn` the PRD that lands its tracked edits. Host crontab installs stay human-only (see §15).
129
129
  Why: burrow PRD 1516 halted because its relative `.venv` python against `downloads/fb-tracker.db`, `scripts/install-cron.sh` and `pipeline-cron.sh --now` resolved inside the worktree (no runtime state; install-cron would bake a temporary worktree path into crontab), while sibling 1494 succeeded the same day using an absolute main-checkout path and artifact-only output. `scheduler_create_prd` warns on relative gitignored paths.
130
130
 
131
131
  ---