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.
- package/dist/assets/{AgentLibrary-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
- package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
- package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
- package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
- package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
- package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
- package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
- package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
- package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
- package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
- package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
- package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
- package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
- package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
- package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
- package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
- package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
- package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
- package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
- package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
- package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
- package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
- package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
- package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/runLogRetention.test.cjs +59 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
- package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
- package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +97 -12
- package/src/main/lib/jobDirtFilter.cjs +54 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitDetect.cjs +35 -0
- package/src/main/lib/reaperHelpers.cjs +31 -6
- package/src/main/lib/runLogRetention.cjs +82 -4
- package/src/main/lib/scheduleJobTransitions.cjs +12 -2
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1165 -70
- 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
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 || !
|
|
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'
|
|
42
|
-
* '
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
302
|
-
*
|
|
303
|
-
* `deleted: false`,
|
|
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
|
-
|
|
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'],
|