claude-code-session-manager 0.78.0 → 0.80.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 (74) hide show
  1. package/dist/assets/{AgentLibrary-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
  2. package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
  3. package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
  4. package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
  5. package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
  6. package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
  7. package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
  9. package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
  10. package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
  11. package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
  12. package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
  13. package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
  15. package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
  16. package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
  18. package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
  20. package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
  21. package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
  22. package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
  23. package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
  24. package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
  25. package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
  26. package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
  30. package/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  33. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  34. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  35. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  36. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  37. package/src/main/__tests__/runLogRetention.test.cjs +59 -0
  38. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  39. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  40. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  41. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  42. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  43. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  44. package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
  45. package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
  46. package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
  47. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
  49. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  50. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  51. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  52. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  53. package/src/main/ipcSchemas.cjs +15 -1
  54. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  55. package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
  56. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  57. package/src/main/lib/depSlugResolve.cjs +72 -0
  58. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  59. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  60. package/src/main/lib/fixPlanSlug.cjs +62 -0
  61. package/src/main/lib/gitWorktree.cjs +97 -12
  62. package/src/main/lib/jobDirtFilter.cjs +54 -0
  63. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  64. package/src/main/lib/prdCreate.cjs +84 -5
  65. package/src/main/lib/prdFrontmatter.cjs +56 -8
  66. package/src/main/lib/queueHistory.cjs +50 -5
  67. package/src/main/lib/rateLimitDetect.cjs +35 -0
  68. package/src/main/lib/reaperHelpers.cjs +31 -6
  69. package/src/main/lib/runLogRetention.cjs +82 -4
  70. package/src/main/lib/scheduleJobTransitions.cjs +12 -2
  71. package/src/main/lib/schedulerBatch.cjs +181 -23
  72. package/src/main/scheduler/prdParser.cjs +7 -0
  73. package/src/main/scheduler.cjs +1165 -70
  74. package/src/preload/api.d.ts +8 -0
@@ -28,6 +28,17 @@ const { appendAuditEvent } = require('./auditLog.cjs');
28
28
  const { resolveProjectContext } = require('./projectRootResolve.cjs');
29
29
  const { fixChainDepthOf, baseSlugOf } = require('./fixChainDepth.cjs');
30
30
  const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
31
+ const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
32
+ const { isFixPlanSlug } = require('./fixPlanSlug.cjs');
33
+
34
+ // A caller-supplied slug that already starts with its own `NN-` (e.g.
35
+ // "254-perf-x") used to silently become the double-prefixed row
36
+ // "254-254-perf-x" once allocateParallelGroup() prepended the REAL
37
+ // allocated NN — PRD_CREATE_SLUG_RE only enforces kebab-case shape, so it
38
+ // never caught this. Refused outright rather than auto-stripped: silently
39
+ // rewriting the caller's slug would hide the same mistake the old behavior
40
+ // hid, just one layer further down.
41
+ const SLUG_HAS_NN_PREFIX_RE = /^(\d+)-/;
31
42
 
32
43
  // Fix-chain depth cap (PRD 1113): a fix-of-a-fix (depth >= 2) is refused at
33
44
  // this shared write path rather than caught later — scheduler.cjs's
@@ -157,7 +168,7 @@ function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
157
168
  * the renderer-facing IPC handler (chat:create-prd, index.cjs) so the
158
169
  * validation/write logic lives in exactly one place (API-reuse standard).
159
170
  * `input` must already be schema-validated by the caller (schemas.schedulerCreatePrd).
160
- * `remote` is scheduler.cjs's remote object (allocateParallelGroup/readPrd/writePrd).
171
+ * `remote` is scheduler.cjs's remote object (allocateParallelGroup/readPrd/writePrd/listPrds).
161
172
  *
162
173
  * Returns `{ ok: true, nn, filename }` on success, or `{ ok: false, status, error }`
163
174
  * on failure — `status` is the HTTP status code the caller should map errors to
@@ -226,6 +237,18 @@ async function createPrd(input, remote) {
226
237
  if (fallback) input = { ...input, sourcePromptId: fallback };
227
238
  }
228
239
 
240
+ if (input.slug) {
241
+ const nnMatch = SLUG_HAS_NN_PREFIX_RE.exec(input.slug);
242
+ if (nnMatch) {
243
+ return {
244
+ ok: false,
245
+ status: 400,
246
+ error: `slug "${input.slug}" already starts with a numeric prefix "${nnMatch[1]}-" — the allocator ` +
247
+ `owns the NN- prefix, not the caller. Pass the bare name instead (e.g. "${input.slug.slice(nnMatch[0].length)}").`,
248
+ };
249
+ }
250
+ }
251
+
229
252
  const slug = input.slug || deriveSlugFromTitle(input.title);
230
253
  if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
231
254
  return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
@@ -243,10 +266,10 @@ async function createPrd(input, remote) {
243
266
  const filenameSlug = `${nn}-${slug}`;
244
267
 
245
268
  // Fix-chain depth guard (PRD 1113): refuse a fix-of-a-fix before it's ever
246
- // written. depth 0/1 pass through unchanged (an ordinary PRD, or a first
247
- // fix-plan, is never blocked); depth >= 2 means the base job has already
248
- // failed to close via at least one prior fix attempt for reasons a repeat
249
- // attempt won't resolve.
269
+ // written. depth >= 2 means the base job has already failed to close via
270
+ // at least one prior fix attempt for reasons a repeat attempt won't
271
+ // resolve — checked first so this distinct, human-escalation error still
272
+ // wins over the more general fix-plan-slug guard below for that shape.
250
273
  const chainDepth = fixChainDepthOf(filenameSlug);
251
274
  if (chainDepth > FIX_CHAIN_DEPTH_CAP) {
252
275
  const base = baseSlugOf(filenameSlug);
@@ -259,6 +282,29 @@ async function createPrd(input, remote) {
259
282
  };
260
283
  }
261
284
 
285
+ // Fix-plan-slug collision guard (PRD 1131): a caller-supplied or
286
+ // title-derived slug that happens to start with "fix-" collides with the
287
+ // scheduler's own NN-fix-... naming convention for fix plans it authors
288
+ // itself (spawnInvestigation) — and that shape carries real special-case
289
+ // semantics (depth-capped auto-investigation eligibility,
290
+ // commitGuardVerdict's zero-edit exemption) that a PRD authored through
291
+ // this API never asked for. Refused outright, not silently renamed, same
292
+ // fail-closed posture as the SLUG_HAS_NN_PREFIX_RE check above (PRD 1126:
293
+ // "126-fix-plan-death-reopens-parent" was wrongly stamped
294
+ // investigationDepth before it ever ran and had to be withdrawn). This
295
+ // subsumes the depth-1 case the chain-depth guard above deliberately let
296
+ // through (MAX_INVESTIGATION_DEPTH=1 means a first fix-plan is legitimate
297
+ // — but only when spawnInvestigation authors it, never through this API).
298
+ if (isFixPlanSlug(filenameSlug)) {
299
+ return {
300
+ ok: false,
301
+ status: 400,
302
+ error: `slug "${filenameSlug}" collides with the reserved NN-fix-... fix-plan naming convention ` +
303
+ '(reserved for PRDs the scheduler\'s own investigation loop authors itself). Reword the title/slug ' +
304
+ 'so it does not start with "fix-" — e.g. "resolve-x" or "repair-x" instead of "fix-x".',
305
+ };
306
+ }
307
+
262
308
  // An explicit `parallelGroup` bypasses allocateParallelGroup()'s
263
309
  // collision-proof reservation, so re-check for an existing file at
264
310
  // this exact destination before writing — remote.writePrd itself has
@@ -284,6 +330,39 @@ async function createPrd(input, remote) {
284
330
  }
285
331
  }
286
332
 
333
+ // Write-time FK check for dependsOn (report on read / throw on write — see
334
+ // prdAgentType.cjs's header): every entry must resolve to a PRD that
335
+ // actually exists in this project, using the SAME resolution rule
336
+ // schedulerBatch.cjs's findBlockingDep applies at run time (exact slug,
337
+ // else bare-name after stripping one leading `NN-`) — a dep that
338
+ // validates here is guaranteed to be a dep findBlockingDep can actually
339
+ // see later. A listPrds() read failure is skipped-with-a-warning, never a
340
+ // write outage: an I/O hiccup on the read side must not block every PRD
341
+ // write in the project.
342
+ if (input.dependsOn && input.dependsOn.length) {
343
+ let listing;
344
+ try {
345
+ listing = await remote.listPrds({ cwd: input.cwd, limit: Number.MAX_SAFE_INTEGER });
346
+ } catch (e) {
347
+ console.warn(`[prdCreate] dependsOn validation skipped (listPrds failed): ${e?.message ?? e}`);
348
+ listing = null;
349
+ }
350
+ if (listing) {
351
+ const candidateSlugs = (listing.prds ?? []).map((p) => p.slug);
352
+ for (const dep of input.dependsOn) {
353
+ if (resolveDepSlug(dep, candidateSlugs).length > 0) continue;
354
+ const near = findNearMatches(dep, candidateSlugs);
355
+ const suggestion = near.length ? ` Closest existing slug(s): ${near.join(', ')}.` : '';
356
+ return {
357
+ ok: false,
358
+ status: 400,
359
+ error: `dependsOn entry "${dep}" does not resolve to any existing PRD in this project.${suggestion} ` +
360
+ 'Pass the bare name (preferred) or the exact NN-prefixed slug of an existing PRD.',
361
+ };
362
+ }
363
+ }
364
+ }
365
+
287
366
  const body = buildPrdBody(input);
288
367
  const writeResult = await remote.writePrd(filenameSlug, body, input.cwd);
289
368
  if (!writeResult?.ok) {
@@ -62,7 +62,7 @@ function splitFrontmatter(raw) {
62
62
  * either surface never reorders or drops a key it didn't touch. Recognized
63
63
  * key set intentionally mirrors the TS module exactly (title, cwd,
64
64
  * estimateMinutes, parallelGroup, sourcePromptId, sourceTabId, tag,
65
- * agentType, createdVia, issuedAt); every other frontmatter key (e.g. dependsOn)
65
+ * agentType, createdVia, issuedAt, dependsOn); every other frontmatter key
66
66
  * round-trips via `extras`, same as the renderer's editor today.
67
67
  *
68
68
  * `createdVia`/`issuedAt` (PRD provenance-lockdown) are recognized here so
@@ -70,10 +70,15 @@ function splitFrontmatter(raw) {
70
70
  * after creation — see prdCreate.cjs's buildPrdBody for the create-time
71
71
  * stamp) round-trips them like any other known key rather than shunting them
72
72
  * into `extras`.
73
+ *
74
+ * `dependsOn` (PRD 1124) is the first ARRAY-valued recognized key — parsed/
75
+ * emitted only in its inline `[a, b]` list form (the sole form prdCreate.cjs
76
+ * ever writes; block-style YAML lists are out of scope). An empty array is
77
+ * never emitted, which is how `scheduler_update_prd` clears a dependency.
73
78
  */
74
79
 
75
- const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'quietMachine']);
76
- const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'quietMachine'];
80
+ const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine']);
81
+ const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine'];
77
82
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
78
83
 
79
84
  function indentOf(line) {
@@ -99,6 +104,28 @@ function parseScalar(raw) {
99
104
  return v;
100
105
  }
101
106
 
107
+ /**
108
+ * Parse the inline `[a, b]` list form used by `dependsOn` — the sole form
109
+ * prdCreate.cjs's buildPrdBody ever writes. Returns null (not recognized as
110
+ * a list) when `raw` isn't bracketed, so a hand-edited non-list value is
111
+ * left unset rather than mis-parsed.
112
+ */
113
+ function parseInlineList(raw) {
114
+ const v = raw.trim();
115
+ if (!v.startsWith('[') || !v.endsWith(']')) return null;
116
+ const inner = v.slice(1, -1).trim();
117
+ if (inner === '') return [];
118
+ return inner
119
+ .split(',')
120
+ .map((s) => s.trim().replace(/^(['"])(.*)\1$/, '$2'))
121
+ .filter((s) => s.length > 0);
122
+ }
123
+
124
+ function arraysEqual(a, b) {
125
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
126
+ return a.length === b.length && a.every((v, i) => v === b[i]);
127
+ }
128
+
102
129
  function applyKey(fm, key, after) {
103
130
  const v = parseScalar(after);
104
131
  if (v === null) return;
@@ -138,6 +165,13 @@ function applyKey(fm, key, after) {
138
165
  case 'issuedAt':
139
166
  fm.issuedAt = String(v);
140
167
  return;
168
+ case 'dependsOn': {
169
+ // Only the inline `[a, b]` list form is recognized (see parseInlineList's
170
+ // header) — a malformed/hand-edited non-list value is left unset.
171
+ const list = parseInlineList(after);
172
+ if (list) fm.dependsOn = list;
173
+ return;
174
+ }
141
175
  case 'quietMachine':
142
176
  // Opt-in exclusive-lease flag (PRD 1107) — only a literal `true`
143
177
  // frontmatter value opts in; anything else (including an explicit
@@ -180,10 +214,17 @@ function parsePrdFile(text) {
180
214
 
181
215
  if (RECOGNIZED_KEYS.has(key)) {
182
216
  applyKey(fm, key, after);
183
- const parsed = parseScalar(after);
184
- if (parsed !== null && (typeof parsed === 'string' || typeof parsed === 'number')) {
185
- if (!fm._raw) fm._raw = {};
186
- fm._raw[key] = { line, parsed };
217
+ if (key === 'dependsOn') {
218
+ if (fm.dependsOn) {
219
+ if (!fm._raw) fm._raw = {};
220
+ fm._raw[key] = { line, parsed: fm.dependsOn };
221
+ }
222
+ } else {
223
+ const parsed = parseScalar(after);
224
+ if (parsed !== null && (typeof parsed === 'string' || typeof parsed === 'number')) {
225
+ if (!fm._raw) fm._raw = {};
226
+ fm._raw[key] = { line, parsed };
227
+ }
187
228
  }
188
229
  } else {
189
230
  extras[key] = { lines: band };
@@ -218,8 +259,11 @@ function serializePrdFile(fm, body) {
218
259
  for (const key of EMIT_ORDER) {
219
260
  const v = fm[key];
220
261
  if (v === undefined || v === null || v === '') continue;
262
+ // An empty dependsOn array is how scheduler_update_prd CLEARS the
263
+ // dependency — never emitted, same as an absent key.
264
+ if (Array.isArray(v) && v.length === 0) continue;
221
265
  const cached = fm._raw?.[key];
222
- if (cached && cached.parsed === v) {
266
+ if (cached && (Array.isArray(v) ? arraysEqual(cached.parsed, v) : cached.parsed === v)) {
223
267
  out.push(cached.line);
224
268
  continue;
225
269
  }
@@ -227,6 +271,10 @@ function serializePrdFile(fm, body) {
227
271
  out.push(`${key}: ${v}`);
228
272
  continue;
229
273
  }
274
+ if (Array.isArray(v)) {
275
+ out.push(`${key}: [${v.join(', ')}]`);
276
+ continue;
277
+ }
230
278
  out.push(`${key}: ${serializeScalar(v)}`);
231
279
  }
232
280
  if (fm.extras) {
@@ -15,6 +15,7 @@ const path = require('path');
15
15
  const fsp = require('fs').promises;
16
16
  const { HISTORY_RETENTION_MS } = require('./schedulerConfig.cjs');
17
17
  const { projectHistoryPath, stateCwds } = require('./queueStore.cjs');
18
+ const { resolveIsFixPlan } = require('./fixPlanSlug.cjs');
18
19
 
19
20
  // Legacy global sidecar — READ-ONLY since 2026-07-31 (federated per-project
20
21
  // history under <cwd>/session-manager-operations/scheduler/state/history.jsonl,
@@ -52,10 +53,6 @@ function historyPathFor(entry) {
52
53
  return HISTORY_PATH;
53
54
  }
54
55
 
55
- function isFixPlanSlug(slug) {
56
- return /^\d+-fix-/.test(slug);
57
- }
58
-
59
56
  function jobKey(job) {
60
57
  return `${job?.slug ?? ''}|${job?.runId ?? ''}`;
61
58
  }
@@ -79,9 +76,13 @@ function partitionJobs(jobs, nowMs, opts = {}) {
79
76
 
80
77
  // Slugs of jobs that a still-pending/running fix-plan is going to try to
81
78
  // heal (see healTargetForFix in scheduler.cjs — same regex, kept in sync).
79
+ // Honors the job's own persisted `isFixPlan` provenance stamp (PRD 1131)
80
+ // over the slug shape, same as every other fix-plan consumer in
81
+ // scheduler.cjs — a slug that merely starts with "fix-" but was classified
82
+ // isFixPlan:false must not get treated as a real fix-plan here either.
82
83
  const protectedSlugs = new Set();
83
84
  for (const j of list) {
84
- if (!j || !isFixPlanSlug(j.slug)) continue;
85
+ if (!j || !resolveIsFixPlan(j.slug, j.isFixPlan)) continue;
85
86
  if (j.status !== 'pending' && j.status !== 'running') continue;
86
87
  protectedSlugs.add(j.slug.replace(/^(\d+)-fix-/, '$1-'));
87
88
  }
@@ -269,10 +270,54 @@ async function historyTerminalBySlug() {
269
270
  return map;
270
271
  }
271
272
 
273
+ /**
274
+ * completedSlugsForCwd(cwd) → Set<string> of every slug whose most recent
275
+ * appended record in THIS project's own history shard
276
+ * (`<cwd>/session-manager-operations/scheduler/state/history.jsonl`, via
277
+ * projectHistoryPath) has status 'completed'. Scoped to one project's shard
278
+ * — unlike historyTerminalBySlug's federated, machine-wide map — because
279
+ * this feeds PRD 1122's dependsOn history lookup, where a same-named PRD in
280
+ * an unrelated project must never count as satisfying a dep. Missing file
281
+ * (ENOENT) returns an empty Set, not an error — a project with no history
282
+ * yet is not a read failure.
283
+ */
284
+ async function completedSlugsForCwd(cwd) {
285
+ let text = '';
286
+ try {
287
+ text = await fsp.readFile(projectHistoryPath(cwd), 'utf8');
288
+ } catch (e) {
289
+ if (e.code === 'ENOENT') return new Set();
290
+ throw e;
291
+ }
292
+ // Last-line-wins per slug (file is append-only chronological) — same
293
+ // pattern as historyTerminalBySlug above. A slug can legitimately appear
294
+ // more than once (scheduler_reset_job resets a completed row back to
295
+ // pending, it runs again, and eventually re-retires to history) — unioning
296
+ // every 'completed' line ever seen, without letting a LATER 'failed' line
297
+ // for the same slug revoke it, would keep a since-refailed dep permanently
298
+ // satisfied for its dependents.
299
+ const statusBySlug = new Map();
300
+ for (const line of text.split('\n')) {
301
+ if (!line.trim()) continue;
302
+ try {
303
+ const j = JSON.parse(line);
304
+ if (j?.slug) statusBySlug.set(j.slug, j.status);
305
+ } catch {
306
+ // corrupt/partial line — ignore
307
+ }
308
+ }
309
+ const slugs = new Set();
310
+ for (const [slug, status] of statusBySlug) {
311
+ if (status === 'completed') slugs.add(slug);
312
+ }
313
+ return slugs;
314
+ }
315
+
272
316
  module.exports = {
273
317
  HISTORY_PATH,
274
318
  partitionJobs,
275
319
  appendHistory,
276
320
  readHistory,
277
321
  historyTerminalBySlug,
322
+ completedSlugsForCwd,
278
323
  };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * rateLimitDetect.cjs — the single source of truth for "did this claude -p
5
+ * log tail show a rate-limit death". Shared by spawnJob() (a still-running
6
+ * process that just exited) and reapDeadRunningJobs()/classifyRunOutcome()
7
+ * (a process the reaper found already dead) — see PRD 1117. Before this,
8
+ * only spawnJob checked this; the reaper had no rate-limit branch at all,
9
+ * so a rate-limited exit that the reaper won the race to finalize got
10
+ * stamped terminal 'failed' instead of retryable 'pending'. There must
11
+ * never be a second, independently-drifting regex set at either call site.
12
+ */
13
+
14
+ const { readTail } = require('./fileTail.cjs');
15
+
16
+ /** Scan the tail of a job's log for the canonical rate-limit signal. We look
17
+ * at the last 16 KB — final result event always lands at the end. Covers
18
+ * both unified-window rate-limit types (five_hour, seven_day), the raw
19
+ * 429 status, and the two human-readable limit-message phrasings the CLI
20
+ * emits ("You've hit your limit" / "You've reached your <model> limit"). */
21
+ function detectRateLimitInLog(logPath) {
22
+ try {
23
+ const text = readTail(logPath, 16384);
24
+ if (!text) return false;
25
+ return /"rateLimitType":"five_hour"/.test(text)
26
+ || /"rateLimitType":"seven_day"/.test(text)
27
+ || /"api_error_status":429/.test(text)
28
+ || /You'?ve hit your limit/.test(text)
29
+ || /You'?ve reached your .* limit/.test(text);
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ module.exports = { detectRateLimitInLog };
@@ -9,6 +9,7 @@
9
9
 
10
10
  const fs = require('node:fs');
11
11
  const { readTail } = require('./fileTail.cjs');
12
+ const { detectRateLimitInLog } = require('./rateLimitDetect.cjs');
12
13
 
13
14
  /**
14
15
  * Return true if pid is alive AND its cmdline looks like a claude process.
@@ -38,11 +39,16 @@ function claudePidAlive(pid) {
38
39
  * of its log file and scanning for the LAST `{"type":"result"}` JSONL event.
39
40
  *
40
41
  * Returns:
41
- * 'success' — last result event has subtype=success and is_error !== true
42
- * 'failed' — last result event exists but indicates an error
43
- * 'no_result' — no result event found in the tail (process may have been killed
44
- * before emitting one, or the log is absent/empty)
45
- * 'unknown' — unexpected error reading/parsing (outer catch)
42
+ * 'success' — last result event has subtype=success and is_error !== true
43
+ * 'rate_limited' — the log tail shows the same rate-limit signal spawnJob's own
44
+ * live-process check uses (detectRateLimitInLog, the shared
45
+ * single source of truth) — a NEW, distinct outcome from
46
+ * 'failed' (PRD 1117): a rate-limited death is retryable, not
47
+ * a genuine gate failure, and must never collapse into 'failed'
48
+ * 'failed' — last result event exists but indicates a genuine error
49
+ * 'no_result' — no result event found in the tail (process may have been killed
50
+ * before emitting one, or the log is absent/empty)
51
+ * 'unknown' — unexpected error reading/parsing (outer catch)
46
52
  */
47
53
  function classifyRunOutcome(logPath) {
48
54
  try {
@@ -56,8 +62,27 @@ function classifyRunOutcome(logPath) {
56
62
  if (obj && obj.type === 'result') lastResult = obj;
57
63
  } catch { /* partial line at tail boundary or non-JSON scheduler log line */ }
58
64
  }
65
+ // ORDER IS LOAD-BEARING. The rate-limit check must come AFTER the success
66
+ // determination, never before it. The CLI emits an informational
67
+ // `rate_limit_event` with status:"allowed_warning" on essentially every
68
+ // run once utilization is non-zero, and detectRateLimitInLog matches its
69
+ // "rateLimitType" field — so checking first classified genuinely
70
+ // SUCCESSFUL runs as rate_limited. Measured on 2026-09-05 against the
71
+ // eight most recent runs whose own meta.json recorded exitCode:0 and
72
+ // rateLimited:false, four came back 'rate_limited' (e.g.
73
+ // 200-campaign-toolkit-weak-points-and-stuns: 55 turns, is_error:false,
74
+ // terminalReasonFromHarness:'completed', landed commit 7fd05f7 — matched
75
+ // purely on an allowed_warning five_hour event). In reapDeadRunningJobs
76
+ // that resets a finished job to 'pending' to re-run shipped work AND
77
+ // engages setPaused('rate_limit') with no rate limit in effect — strictly
78
+ // worse than the terminal-'failed' bug the rate_limit branch was added to
79
+ // fix. See the guard test in this file's __tests__ sibling.
80
+ if (lastResult && lastResult.subtype === 'success' && lastResult.is_error !== true) return 'success';
81
+ // Still checked ahead of 'no_result': a run killed mid-flight by a rate
82
+ // limit may never emit a result event at all, and that is a rate-limited
83
+ // death, not silence.
84
+ if (detectRateLimitInLog(logPath)) return 'rate_limited';
59
85
  if (!lastResult) return 'no_result';
60
- if (lastResult.subtype === 'success' && lastResult.is_error !== true) return 'success';
61
86
  return 'failed';
62
87
  } catch {
63
88
  return 'unknown';
@@ -296,11 +296,84 @@ function resolveLiveKeysForApply(runsDir, opts, enabled) {
296
296
  }
297
297
  }
298
298
 
299
+ /**
300
+ * Build the set of runIds currently claimed by a job in status 'running' —
301
+ * used by the orphan pass below to skip a directory whose spawn may be
302
+ * mid-flight right now (scheduler.cjs's pickRunDir mints the runId/dir pair
303
+ * before the child process is spawned; the directory itself is only
304
+ * mkdir'd, and files only start landing in it, once execution actually
305
+ * commits — see executeJob). Deliberately keyed by runId alone, not
306
+ * `${slug}|${runId}` like liveKeysFromJobs: a shared batch dir (tickQueue
307
+ * hands ONE runId to several jobs at once) is in flight if ANY job dispatched
308
+ * into it is still running, regardless of which slug.
309
+ */
310
+ function runningRunIdsFromJobs(jobs) {
311
+ const ids = new Set();
312
+ for (const job of jobs || []) {
313
+ if (job && job.status === 'running' && job.runId) ids.add(job.runId);
314
+ }
315
+ return ids;
316
+ }
317
+
318
+ /**
319
+ * Resolve the running-runId protection set for applyRetention's orphan pass,
320
+ * mirroring resolveLiveKeysForApply's precedence: explicit opts win, then
321
+ * opts.jobs, then (only when deletion is actually about to happen) a direct
322
+ * read of the real scheduler queue.
323
+ */
324
+ function resolveRunningRunIdsForApply(opts, enabled) {
325
+ if (opts && opts.runningRunIds) return opts.runningRunIds;
326
+ if (opts && opts.jobs) return runningRunIdsFromJobs(opts.jobs);
327
+ if (!enabled) return new Set();
328
+ try {
329
+ const queueStore = require('./queueStore.cjs');
330
+ const state = queueStore.readMergedSync();
331
+ return runningRunIdsFromJobs(state.jobs || []);
332
+ } catch {
333
+ return new Set();
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Remove every EMPTY directory directly under runsDir, at any age, except
339
+ * one whose runId is in runningRunIds. These are orphans left behind by a
340
+ * dispatch that minted a runId/dir pair (pickRunDir) but aborted before
341
+ * spawnJob's executeJob ever wrote into it (slot-acquire miss, worktree-cap
342
+ * deferral, launch-gate block, ...) — scanRunEntries never sees them (no
343
+ * meta.json), so the age/count policy above can't reach them either.
344
+ * Returns the count of directories removed.
345
+ */
346
+ function sweepOrphanDirs(runsDir, runningRunIds, errors) {
347
+ let dirNames;
348
+ try {
349
+ dirNames = fs.readdirSync(runsDir, { withFileTypes: true })
350
+ .filter((d) => d.isDirectory())
351
+ .map((d) => d.name);
352
+ } catch {
353
+ return 0;
354
+ }
355
+
356
+ let removed = 0;
357
+ for (const runId of dirNames) {
358
+ if (runningRunIds.has(runId)) continue;
359
+ const dir = path.join(runsDir, runId);
360
+ try {
361
+ if (fs.readdirSync(dir).length !== 0) continue;
362
+ fs.rmdirSync(dir);
363
+ removed += 1;
364
+ } catch (e) {
365
+ if (e && e.code !== 'ENOENT') errors.push({ path: dir, error: e.message });
366
+ }
367
+ }
368
+ return removed;
369
+ }
370
+
299
371
  /**
300
372
  * Compute the report, and — ONLY when isRetentionEnabled(settings) — delete
301
- * the eligible files and rmdir any directory left fully empty. With no
302
- * opt-in (the default), this is exactly computeReport(): read-only,
303
- * `deleted: false`, nothing removed.
373
+ * the eligible files, rmdir any directory left fully empty, and sweep any
374
+ * orphan empty run directory (see sweepOrphanDirs). With no opt-in (the
375
+ * default), this is exactly computeReport(): read-only, `deleted: false`,
376
+ * nothing removed.
304
377
  */
305
378
  function applyRetention(runsDir, settings, opts) {
306
379
  const cfg = (settings && settings.schedulerRunLogRetention) || null;
@@ -342,7 +415,10 @@ function applyRetention(runsDir, settings, opts) {
342
415
  }
343
416
  }
344
417
 
345
- return { deleted: true, removedFiles, freedBytes, errors, report };
418
+ const runningRunIds = resolveRunningRunIdsForApply(opts, enabled);
419
+ const orphanDirsRemoved = sweepOrphanDirs(runsDir, runningRunIds, errors);
420
+
421
+ return { deleted: true, removedFiles, freedBytes, orphanDirsRemoved, errors, report };
346
422
  }
347
423
 
348
424
  /**
@@ -370,6 +446,8 @@ module.exports = {
370
446
  LIVE_STATUSES,
371
447
  isLiveJob,
372
448
  liveKeysFromJobs,
449
+ runningRunIdsFromJobs,
450
+ sweepOrphanDirs,
373
451
  scanRunEntries,
374
452
  computeEligibility,
375
453
  computeReport,
@@ -61,7 +61,17 @@ const STATUS_HISTORY_CAP = 20;
61
61
  * declared paths is surfaced to a human as needs_review, never silently
62
62
  * auto-completed)
63
63
  * - needs_review->investigating, needs_review->pending, needs_review->completed
64
- * (heal on reverify, or auto-promote) — same shape as `failed`
64
+ * (heal on reverify, or auto-promote) — same shape as `failed`. Also
65
+ * reached by source 'scheduler:mechanicalRecovery' (PRD 1130): a job
66
+ * parked with a mechanically-resolvable verdict (starting with exactly
67
+ * 'worktree_integration_failed') gets one bounded, model-free re-attempt
68
+ * of its worktree branch integration; success lands here exactly like any
69
+ * other completion.
70
+ * - needs_review->running (resume-first recovery, PRD 1111: a job parked
71
+ * needs_review with verdict 'uncommitted_changes' gets one bounded
72
+ * `claude -p --resume` dispatch through spawnJob before any fix-plan
73
+ * investigation is authored — spawnJob's own dispatch mutate transitions
74
+ * straight to 'running', same as any pending job)
65
75
  * - completed->pending (force-only reset, gated separately by
66
76
  * resetJobFields' own guard — this table only says the edge is
67
77
  * structurally legal, not that every caller may take it unconditionally)
@@ -85,7 +95,7 @@ const LEGAL_TRANSITIONS = {
85
95
  running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
86
96
  investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
87
97
  failed: ['investigating', 'pending', 'completed', 'needs_review'],
88
- needs_review: ['investigating', 'pending', 'completed', 'skipped'],
98
+ needs_review: ['investigating', 'pending', 'completed', 'skipped', 'running'],
89
99
  completed: ['pending'],
90
100
  skipped: ['pending'],
91
101
  quarantined: ['pending', 'skipped'],