claude-code-session-manager 0.75.0 → 0.75.2
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-SpzptNyq.js → AgentLibrary-s1jRGOji.js} +1 -1
- package/dist/assets/{DataModel-C4M9xVLC.js → DataModel-DNefcWbk.js} +1 -1
- package/dist/assets/{History-92FYrZKS.js → History-4sMk80yI.js} +1 -1
- package/dist/assets/{Hooks-DlnavO1l.js → Hooks-CNUVwH5P.js} +1 -1
- package/dist/assets/{HostBilko-CKI7jt1z.js → HostBilko-BhkyvIhw.js} +1 -1
- package/dist/assets/{Library-Brhxe7xa.js → Library-BpockanA.js} +1 -1
- package/dist/assets/{ListDetail-DmIweYTN.js → ListDetail-Cre7h3U0.js} +1 -1
- package/dist/assets/{MarkdownEditor-DA4xqWWi.js → MarkdownEditor-x-FzvaXm.js} +1 -1
- package/dist/assets/{McpServers-DWXQGzVc.js → McpServers-CXAcQLMB.js} +1 -1
- package/dist/assets/{Memory-HCx9Arao.js → Memory-CGBv1Rco.js} +1 -1
- package/dist/assets/{Panel-B20C6kN8.js → Panel-czEs21N2.js} +1 -1
- package/dist/assets/{Permissions-C4sPgLpA.js → Permissions-rGkLOpqI.js} +1 -1
- package/dist/assets/{Plugins-CaRenCBg.js → Plugins-BTmB_vgU.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DfrXzTeh.js → ProvenanceBadge-dmCivDq5.js} +1 -1
- package/dist/assets/{SaveBar-BmpUx2Yh.js → SaveBar-D80TNkAx.js} +1 -1
- package/dist/assets/{Scheduler-xUD8pQ6q.js → Scheduler-C6wHSZP7.js} +3 -3
- package/dist/assets/{ScopeSwitcher-DVlKMck5.js → ScopeSwitcher-DsaiYTgl.js} +1 -1
- package/dist/assets/{Settings-C457mxOK.js → Settings-m2fyU8Q5.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-D3dDc5X7.js → SkillReferenceGraph-yS-lgBFU.js} +1 -1
- package/dist/assets/{Skills-BNDlbftC.js → Skills-DtQgCs9-.js} +1 -1
- package/dist/assets/{SystemPrompt-d-ykNRec.js → SystemPrompt-CLuVdoMA.js} +1 -1
- package/dist/assets/{TagLibrary-D3JWkQK-.js → TagLibrary-DQXoI752.js} +1 -1
- package/dist/assets/{TiptapBody-B1Ox6D6E.js → TiptapBody-BGKVXrvq.js} +1 -1
- package/dist/assets/{Toggle-CaYw6Lrj.js → Toggle-RFnEV71m.js} +1 -1
- package/dist/assets/{index-Dbo9R4e0.css → index-CMLnzdZC.css} +1 -1
- package/dist/assets/{index-CEeLxNCz.js → index-Ddrw7J7O.js} +4 -4
- package/dist/assets/{settingsSchema-DUkd45M5.js → settingsSchema-LwuJXKD1.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/scheduler-mcp-server.cjs +16 -1
- package/src/main/__tests__/prdAdminRoutes.test.cjs +100 -0
- package/src/main/__tests__/queueHistory.test.cjs +7 -0
- package/src/main/__tests__/scheduleJobSchema.test.cjs +1 -1
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +1 -1
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +24 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +116 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +113 -7
- package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +134 -0
- package/src/main/__tests__/scheduler-fix-plan-path.test.cjs +119 -0
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +5 -1
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +26 -0
- package/src/main/lib/prdAdminRoutes.cjs +12 -2
- package/src/main/lib/queueHistory.cjs +6 -4
- package/src/main/lib/scheduleJobSchema.cjs +1 -1
- package/src/main/lib/scheduleJobTransitions.cjs +19 -6
- package/src/main/lib/schedulerBatch.cjs +17 -1
- package/src/main/scheduler.cjs +224 -42
- package/src/preload/api.d.ts +1 -1
package/src/main/scheduler.cjs
CHANGED
|
@@ -100,7 +100,7 @@ const queueOps = require('./queueOps.cjs');
|
|
|
100
100
|
// home-dir layout.
|
|
101
101
|
const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
|
|
102
102
|
const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
|
|
103
|
-
const { transitionJob } = require('./lib/scheduleJobTransitions.cjs');
|
|
103
|
+
const { transitionJob, STATUS_HISTORY_CAP } = require('./lib/scheduleJobTransitions.cjs');
|
|
104
104
|
const { buildContextDigest, composeExecutorPrompt } = require('./lib/epicContextDigest.cjs');
|
|
105
105
|
const { JOB_STATUSES } = require('./lib/scheduleJobSchema.cjs');
|
|
106
106
|
const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
@@ -1424,7 +1424,7 @@ async function reconcile(state) {
|
|
|
1424
1424
|
seen.add(job.slug);
|
|
1425
1425
|
next.push({ ...job });
|
|
1426
1426
|
console.warn(`[scheduler] reconcile: keeping ${job.status} job ${job.slug} — PRD source not visible in any candidate dir`);
|
|
1427
|
-
} else if (job.status === 'completed' || job.status === 'failed') {
|
|
1427
|
+
} else if (job.status === 'completed' || job.status === 'failed' || job.status === 'skipped') {
|
|
1428
1428
|
terminalDroppedNeedingHistoryCheck.push(job);
|
|
1429
1429
|
}
|
|
1430
1430
|
continue;
|
|
@@ -2104,7 +2104,7 @@ async function clearPause(source) {
|
|
|
2104
2104
|
* already-'completed' job, and that path is exactly what this guards.
|
|
2105
2105
|
*/
|
|
2106
2106
|
function resetJobFields(job, errorMsg, opts = {}) {
|
|
2107
|
-
if (job.status === 'completed' && opts.force !== true) return false;
|
|
2107
|
+
if ((job.status === 'completed' || job.status === 'skipped') && opts.force !== true) return false;
|
|
2108
2108
|
if (!transitionJob(job, 'pending', { reason: errorMsg ?? 'reset to pending', source: opts.source ?? 'resetJobFields' })) return false;
|
|
2109
2109
|
job.runId = null;
|
|
2110
2110
|
job.startedAt = null;
|
|
@@ -2289,6 +2289,37 @@ async function resolveVerifyPrdPath(job) {
|
|
|
2289
2289
|
return liveDir ? safeSlugPathIn(liveDir, job.slug) : null;
|
|
2290
2290
|
}
|
|
2291
2291
|
|
|
2292
|
+
/**
|
|
2293
|
+
* resolveFixPlanPath(failedJob) → { fixPath, fixSlug, group, livePrdDir }
|
|
2294
|
+
*
|
|
2295
|
+
* Where spawnInvestigation's auto-fix plan for `failedJob` must be written.
|
|
2296
|
+
* The plan belongs in the SAME dir the original PRD lives in right now
|
|
2297
|
+
* (`livePrdDir`, from resolveVerifyPrdPath's live-dir search — usually the
|
|
2298
|
+
* originating Epic's `epics/<epicId>/prds/`), never the retired flat dir:
|
|
2299
|
+
* consolidateFlatPrds (prdMigration.cjs) sweeps only the flat dir and
|
|
2300
|
+
* archives any `.md` there with no live queue row, which a freshly-authored
|
|
2301
|
+
* fix plan never has yet — so writing it flat means the very next
|
|
2302
|
+
* reconcile() pass (src/main/scheduler.cjs's consolidateAllFlatPrds call)
|
|
2303
|
+
* archives it before it can ever be enqueued. `livePrdDir` is null (and the
|
|
2304
|
+
* flat dir, `prdDirForCwd`, is used instead) only when the original PRD
|
|
2305
|
+
* can't be found live anywhere on disk — an archived twin is deliberately
|
|
2306
|
+
* NOT treated as a live location for this purpose.
|
|
2307
|
+
*
|
|
2308
|
+
* Exported as a pure(ish) helper (one fs-free branch, one await) so tests
|
|
2309
|
+
* can prove the round trip — fix plan lands beside its original and
|
|
2310
|
+
* survives a consolidateFlatPrds(cwd) pass — without spawning a real
|
|
2311
|
+
* investigation.
|
|
2312
|
+
*/
|
|
2313
|
+
async function resolveFixPlanPath(failedJob) {
|
|
2314
|
+
const livePrdPath = await resolveVerifyPrdPath(failedJob);
|
|
2315
|
+
const livePrdDir = livePrdPath ? path.dirname(livePrdPath) : null;
|
|
2316
|
+
const baseSlug = failedJob.slug.replace(/^\d+-/, '');
|
|
2317
|
+
const group = failedJob.parallelGroup ?? 99;
|
|
2318
|
+
const fixSlug = `${String(group).padStart(2, '0')}-fix-${baseSlug}`;
|
|
2319
|
+
const fixPath = path.join(livePrdDir ?? prdDirForCwd(failedJob.cwd), `${fixSlug}.md`);
|
|
2320
|
+
return { fixPath, fixSlug, group, livePrdDir };
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2292
2323
|
/**
|
|
2293
2324
|
* notifyOriginatingTab(job) → void
|
|
2294
2325
|
*
|
|
@@ -2980,6 +3011,16 @@ function isFixPlanSlug(slug) {
|
|
|
2980
3011
|
return /^\d+-fix-/.test(slug);
|
|
2981
3012
|
}
|
|
2982
3013
|
|
|
3014
|
+
/**
|
|
3015
|
+
* The fix-plan slug spawnInvestigation authors for a given failed job —
|
|
3016
|
+
* shared by selectAutoFixTargets (queue-membership check) and
|
|
3017
|
+
* reverifyNeedsReview's autofix_plan_unqueued annotation (same check, run
|
|
3018
|
+
* one or more passes later) so both use one slug formula.
|
|
3019
|
+
*/
|
|
3020
|
+
function fixSlugFor(job) {
|
|
3021
|
+
return `${String(job.parallelGroup ?? 99).padStart(2, '0')}-fix-${job.slug.replace(/^\d+-/, '')}`;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
2983
3024
|
/**
|
|
2984
3025
|
* Returns true for statuses that a fix-plan completion should promote
|
|
2985
3026
|
* (clear) on the original job. Both 'failed' and 'needs_review' are
|
|
@@ -3165,9 +3206,18 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
3165
3206
|
const failedLogPath = path.join(runDir, `${failedJob.slug}.log`);
|
|
3166
3207
|
const investigationLogPath = path.join(runDir, `${failedJob.slug}.investigation.log`);
|
|
3167
3208
|
|
|
3209
|
+
// livePrdDir is the SAME dir the original PRD lives in right now (usually
|
|
3210
|
+
// its Epic-scoped `epics/<epicId>/prds/`) — the fix plan must land there
|
|
3211
|
+
// too, since consolidateFlatPrds only ever sweeps the retired flat dir
|
|
3212
|
+
// (prdDirForCwd) and would archive a freshly-authored fix plan on the very
|
|
3213
|
+
// next reconcile() pass before it ever gets a queue row. Only fall back to
|
|
3214
|
+
// the flat dir when the original PRD can't be found on disk at all — an
|
|
3215
|
+
// archived twin (archivedPrdPathForJob) is deliberately NOT used for the
|
|
3216
|
+
// write dir, only for reading originalBody below.
|
|
3168
3217
|
let originalBody = '';
|
|
3218
|
+
const { fixPath, fixSlug, group, livePrdDir } = await resolveFixPlanPath(failedJob);
|
|
3169
3219
|
try {
|
|
3170
|
-
const originalPath = (
|
|
3220
|
+
const originalPath = livePrdDir ? path.join(livePrdDir, `${failedJob.slug}.md`) : archivedPrdPathForJob(failedJob);
|
|
3171
3221
|
originalBody = (await parsePrd(originalPath)).body;
|
|
3172
3222
|
} catch {
|
|
3173
3223
|
originalBody = failedJob.bodyPreview || '(original PRD missing from disk)';
|
|
@@ -3175,11 +3225,6 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
3175
3225
|
|
|
3176
3226
|
const logTail = readTail(failedLogPath, 16 * 1024) || '(failed to read log)';
|
|
3177
3227
|
|
|
3178
|
-
const baseSlug = failedJob.slug.replace(/^\d+-/, '');
|
|
3179
|
-
const group = failedJob.parallelGroup ?? 99;
|
|
3180
|
-
const fixSlug = `${String(group).padStart(2, '0')}-fix-${baseSlug}`;
|
|
3181
|
-
const fixPath = path.join(prdDirForCwd(failedJob.cwd), `${fixSlug}.md`);
|
|
3182
|
-
|
|
3183
3228
|
if (fs.existsSync(fixPath)) {
|
|
3184
3229
|
console.log(`[scheduler] skip investigation: fix plan already exists at ${fixPath}`);
|
|
3185
3230
|
releaseSlot();
|
|
@@ -3281,6 +3326,13 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
3281
3326
|
? `investigation spawn failed: ${error?.message ?? String(error)}`
|
|
3282
3327
|
: `investigation error: ${error.message}`;
|
|
3283
3328
|
sl(`\n[scheduler] ${errMsg}\n`);
|
|
3329
|
+
// Stamp the outcome even on this early exit — otherwise the job is
|
|
3330
|
+
// left with autoFixAttempted: true and no outcome, which used to
|
|
3331
|
+
// read as a permanent dead-end (see selectAutoFixTargets).
|
|
3332
|
+
mutate((s) => {
|
|
3333
|
+
const j = s.jobs.find((x) => x.slug === failedJob.slug);
|
|
3334
|
+
if (j) j.autoFixOutcome = 'error';
|
|
3335
|
+
}).catch(() => {});
|
|
3284
3336
|
return;
|
|
3285
3337
|
}
|
|
3286
3338
|
sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
|
|
@@ -3301,6 +3353,10 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
3301
3353
|
}
|
|
3302
3354
|
if (fs.existsSync(fixPath)) {
|
|
3303
3355
|
console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
|
|
3356
|
+
mutate((s) => {
|
|
3357
|
+
const j = s.jobs.find((x) => x.slug === failedJob.slug);
|
|
3358
|
+
if (j) j.autoFixOutcome = 'plan';
|
|
3359
|
+
}).catch(() => {});
|
|
3304
3360
|
} else {
|
|
3305
3361
|
console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${exitCode})`);
|
|
3306
3362
|
// Record the no-plan outcome so selectAutoFixTargets can offer one
|
|
@@ -3430,13 +3486,18 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
3430
3486
|
// Stale queue entry: the PRD was archived (already shipped) or is gone
|
|
3431
3487
|
// from disk entirely (deleted before dispatch). Both are retire-not-fail
|
|
3432
3488
|
// results from prdArchivedSkipResult with exitCode 0 and no transcript —
|
|
3433
|
-
//
|
|
3434
|
-
//
|
|
3489
|
+
// no verify pass, no commit guard, no RCA feedback item — but only
|
|
3490
|
+
// 'prd-archived' actually means the work shipped (archivedTwinExists
|
|
3491
|
+
// found the file under a prds-archived/ dir). 'prd-missing' means no
|
|
3492
|
+
// executor ever ran at all, so it must NOT land on the same 'completed'
|
|
3493
|
+
// status a genuine successful run produces — that conflation is what let
|
|
3494
|
+
// 20 never-executed sigma PRDs read as shipped (2026-08-30 incident).
|
|
3435
3495
|
if (res.skipped) {
|
|
3496
|
+
const targetStatus = res.skipped === 'prd-missing' ? 'skipped' : 'completed';
|
|
3436
3497
|
await mutate((s) => {
|
|
3437
3498
|
const idx = s.jobs.findIndex((x) => x.slug === job.slug);
|
|
3438
3499
|
if (idx >= 0) {
|
|
3439
|
-
transitionJob(s.jobs[idx],
|
|
3500
|
+
transitionJob(s.jobs[idx], targetStatus, { reason: res.note ?? 'PRD archived or missing — treated as already-shipped', source: 'spawnJob:skip-archived' });
|
|
3440
3501
|
s.jobs[idx].finishedAt = new Date().toISOString();
|
|
3441
3502
|
s.jobs[idx].exitCode = 0;
|
|
3442
3503
|
s.jobs[idx].error = null;
|
|
@@ -3711,10 +3772,10 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
3711
3772
|
process.env.SM_AUTOFIX_DISABLE !== '1' &&
|
|
3712
3773
|
isEligibleForImmediateAutoFix(s.jobs[i2], s.jobs, fixSlugExists)
|
|
3713
3774
|
) {
|
|
3714
|
-
const
|
|
3775
|
+
const isRetryAttempt = s.jobs[i2].autoFixAttempted === true;
|
|
3715
3776
|
s.jobs[i2].autoFixAttempted = true;
|
|
3716
3777
|
if (!s.jobs[i2].runId) s.jobs[i2].runId = runId;
|
|
3717
|
-
if (
|
|
3778
|
+
if (isRetryAttempt) {
|
|
3718
3779
|
s.jobs[i2].autoFixRetries = (s.jobs[i2].autoFixRetries ?? 0) + 1;
|
|
3719
3780
|
delete s.jobs[i2].autoFixOutcome;
|
|
3720
3781
|
}
|
|
@@ -4241,7 +4302,7 @@ async function pollLoop() {
|
|
|
4241
4302
|
// callers/tests that only pass `jobs` are unaffected.
|
|
4242
4303
|
function selectHistoryJobs(jobs, limit, historyEntries = []) {
|
|
4243
4304
|
const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
|
|
4244
|
-
const hot = (Array.isArray(jobs) ? jobs : []).filter((j) => j && (j.status === 'completed' || j.status === 'failed'));
|
|
4305
|
+
const hot = (Array.isArray(jobs) ? jobs : []).filter((j) => j && (j.status === 'completed' || j.status === 'failed' || j.status === 'skipped'));
|
|
4245
4306
|
const seen = new Set(hot.map((j) => `${j.slug}|${j.runId ?? ''}`));
|
|
4246
4307
|
const archived = (Array.isArray(historyEntries) ? historyEntries : []).filter((j) => {
|
|
4247
4308
|
if (!j) return false;
|
|
@@ -4337,6 +4398,35 @@ function isUnresolvableNeedsReview(job, { hasRunDir }) {
|
|
|
4337
4398
|
return !!job && job.status === 'needs_review' && !job.runId && !hasRunDir;
|
|
4338
4399
|
}
|
|
4339
4400
|
|
|
4401
|
+
/**
|
|
4402
|
+
* Pure predicate: an auto-fix attempt whose one bounded retry is already
|
|
4403
|
+
* spent, and whose outcome was NOT 'plan' (a produced plan is judged by
|
|
4404
|
+
* isPlanUnqueued instead — a plan is never retried, only annotated if it
|
|
4405
|
+
* never reaches the queue). Covers all three exhaustible outcomes
|
|
4406
|
+
* ('no-plan', 'error', and unstamped/undefined) — mirrors the retry
|
|
4407
|
+
* eligibility rule in selectAutoFixTargets so a job can never be retry-
|
|
4408
|
+
* eligible there and simultaneously un-annotatable here.
|
|
4409
|
+
*/
|
|
4410
|
+
function isExhaustedAutoFix(job) {
|
|
4411
|
+
return !!job && job.status === 'needs_review'
|
|
4412
|
+
&& job.autoFixAttempted === true
|
|
4413
|
+
&& job.autoFixOutcome !== 'plan'
|
|
4414
|
+
&& (job.autoFixRetries ?? 0) >= 1;
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4417
|
+
/**
|
|
4418
|
+
* Pure predicate: an investigation produced a fix plan (autoFixOutcome ===
|
|
4419
|
+
* 'plan') but its fix-plan slug is not present among `queuedSlugs` — the
|
|
4420
|
+
* plan file failed to become a queue row (write failure, or some other
|
|
4421
|
+
* reason reconcile() never ingested it). `queuedSlugs` is the caller's
|
|
4422
|
+
* current job-slug set, passed in rather than read here so this stays pure.
|
|
4423
|
+
*/
|
|
4424
|
+
function isPlanUnqueued(job, queuedSlugs) {
|
|
4425
|
+
if (!job || job.status !== 'needs_review') return false;
|
|
4426
|
+
if (job.autoFixOutcome !== 'plan') return false;
|
|
4427
|
+
return !queuedSlugs.has(fixSlugFor(job));
|
|
4428
|
+
}
|
|
4429
|
+
|
|
4340
4430
|
/**
|
|
4341
4431
|
* Pure predicate: is this job eligible for the boot re-verify self-heal? Only
|
|
4342
4432
|
* needs_review jobs with a run log (own or backfilled via resolveRunId) AND a
|
|
@@ -4417,9 +4507,13 @@ function isRescanCandidate(job) {
|
|
|
4417
4507
|
* - a runId, own or backfilled via resolveJobRunId (need a run log to investigate)
|
|
4418
4508
|
* - not itself a fix-plan slug (avoids infinite recursion)
|
|
4419
4509
|
* - autoFixAttempted cap: a job with no prior attempt is eligible; a job
|
|
4420
|
-
* whose prior attempt outcome was 'no-plan'
|
|
4421
|
-
* (
|
|
4422
|
-
*
|
|
4510
|
+
* whose prior attempt outcome was 'no-plan', 'error', or UNSTAMPED
|
|
4511
|
+
* (investigation exited without recording any outcome — unknown is not
|
|
4512
|
+
* "succeeded") gets ONE bounded retry (autoFixRetries < 1); a job whose
|
|
4513
|
+
* outcome was 'plan' (a fix plan was produced) is never retried here —
|
|
4514
|
+
* it either lands as a queue row on its own or gets flagged
|
|
4515
|
+
* autofix_plan_unqueued by reverifyNeedsReview's annotation pass; an
|
|
4516
|
+
* exhausted retry is excluded
|
|
4423
4517
|
* - no fix sibling on disk (fixSlugExists) or already in the queue
|
|
4424
4518
|
*/
|
|
4425
4519
|
function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRunId }) {
|
|
@@ -4430,10 +4524,13 @@ function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRu
|
|
|
4430
4524
|
if (!runId) return false;
|
|
4431
4525
|
if (isFixPlanBeyondDepthCap(job.slug, job.investigationDepth)) return false;
|
|
4432
4526
|
if (job.autoFixAttempted) {
|
|
4433
|
-
|
|
4527
|
+
const retryEligible = job.autoFixOutcome === 'no-plan'
|
|
4528
|
+
|| job.autoFixOutcome === 'error'
|
|
4529
|
+
|| job.autoFixOutcome == null;
|
|
4530
|
+
if (!retryEligible) return false;
|
|
4434
4531
|
if ((job.autoFixRetries ?? 0) >= 1) return false;
|
|
4435
4532
|
}
|
|
4436
|
-
const fixSlug =
|
|
4533
|
+
const fixSlug = fixSlugFor(job);
|
|
4437
4534
|
if (fixSlugExists(fixSlug)) return false;
|
|
4438
4535
|
if (slugsInQueue.has(fixSlug)) return false;
|
|
4439
4536
|
return true;
|
|
@@ -4561,20 +4658,29 @@ async function reverifyNeedsReview() {
|
|
|
4561
4658
|
|
|
4562
4659
|
// Surface needs_review jobs that can never self-heal or get an auto-fix
|
|
4563
4660
|
// investigation (no runId, no backfillable run dir) instead of leaving
|
|
4564
|
-
// them silently stranded. Also surface
|
|
4565
|
-
//
|
|
4661
|
+
// them silently stranded. Also surface auto-fix jobs whose one bounded
|
|
4662
|
+
// retry is already exhausted (outcome 'no-plan', 'error', or unstamped —
|
|
4663
|
+
// selectAutoFixTargets treats all three as retry-eligible, so all three
|
|
4664
|
+
// must also be annotatable once the retry is spent), and jobs whose
|
|
4665
|
+
// investigation DID produce a fix plan that never became a queue row
|
|
4666
|
+
// (write failed, or reconcile hasn't picked it up for some other reason).
|
|
4667
|
+
// All three classes are annotated, never looped on — a needs_review job
|
|
4668
|
+
// must not exit this pass both un-retried and un-annotated.
|
|
4566
4669
|
const afterHealForAnnotate = await readQueue();
|
|
4567
4670
|
const unresolvable = afterHealForAnnotate.jobs.filter(
|
|
4568
4671
|
(j) => isUnresolvableNeedsReview(j, { hasRunDir: !!resolveRunId(j) }) && j.verifierVerdict !== 'no_run_artifacts',
|
|
4569
4672
|
);
|
|
4570
|
-
const
|
|
4571
|
-
(j) => j.
|
|
4572
|
-
|
|
4573
|
-
|
|
4673
|
+
const exhaustedAutoFix = afterHealForAnnotate.jobs.filter(
|
|
4674
|
+
(j) => isExhaustedAutoFix(j) && j.verifierVerdict !== 'autofix_no_plan',
|
|
4675
|
+
);
|
|
4676
|
+
const queuedSlugs = new Set(afterHealForAnnotate.jobs.map((j) => j.slug));
|
|
4677
|
+
const planUnqueued = afterHealForAnnotate.jobs.filter(
|
|
4678
|
+
(j) => isPlanUnqueued(j, queuedSlugs) && j.verifierVerdict !== 'autofix_plan_unqueued',
|
|
4574
4679
|
);
|
|
4575
|
-
if (unresolvable.length ||
|
|
4680
|
+
if (unresolvable.length || exhaustedAutoFix.length || planUnqueued.length) {
|
|
4576
4681
|
const unresolvableSet = new Set(unresolvable.map((j) => j.slug));
|
|
4577
|
-
const exhaustedSet = new Set(
|
|
4682
|
+
const exhaustedSet = new Set(exhaustedAutoFix.map((j) => j.slug));
|
|
4683
|
+
const planUnqueuedSet = new Set(planUnqueued.map((j) => j.slug));
|
|
4578
4684
|
await mutate((s) => {
|
|
4579
4685
|
for (const j of s.jobs) {
|
|
4580
4686
|
if (unresolvableSet.has(j.slug)) {
|
|
@@ -4583,14 +4689,22 @@ async function reverifyNeedsReview() {
|
|
|
4583
4689
|
} else if (exhaustedSet.has(j.slug)) {
|
|
4584
4690
|
j.verifierVerdict = 'autofix_no_plan';
|
|
4585
4691
|
j.error = 'auto-fix investigation produced no fix plan after retry — manual review';
|
|
4692
|
+
} else if (planUnqueuedSet.has(j.slug)) {
|
|
4693
|
+
j.verifierVerdict = 'autofix_plan_unqueued';
|
|
4694
|
+
j.error = 'auto-fix investigation produced a fix plan that never reached the queue — manual review';
|
|
4586
4695
|
}
|
|
4587
4696
|
}
|
|
4588
4697
|
});
|
|
4589
4698
|
if (unresolvable.length) {
|
|
4590
4699
|
console.log(`[scheduler] boot reverify: no run artifacts for ${unresolvable.map((j) => j.slug).join(', ')} — flagged for manual review`);
|
|
4591
4700
|
}
|
|
4592
|
-
if (
|
|
4593
|
-
console.log(`[scheduler] boot reverify: auto-fix retry exhausted for ${
|
|
4701
|
+
if (exhaustedAutoFix.length) {
|
|
4702
|
+
console.log(`[scheduler] boot reverify: auto-fix retry exhausted for ${exhaustedAutoFix.map((j) => j.slug).join(', ')} — flagged for manual review`);
|
|
4703
|
+
}
|
|
4704
|
+
if (planUnqueued.length) {
|
|
4705
|
+
for (const j of planUnqueued) {
|
|
4706
|
+
console.log(`[scheduler] boot reverify: auto-fix plan never queued for ${j.slug} — flagged for manual review`);
|
|
4707
|
+
}
|
|
4594
4708
|
}
|
|
4595
4709
|
await broadcast();
|
|
4596
4710
|
}
|
|
@@ -4608,7 +4722,7 @@ async function reverifyNeedsReview() {
|
|
|
4608
4722
|
for (const job of targets) {
|
|
4609
4723
|
const runId = job.runId || resolveRunId(job);
|
|
4610
4724
|
const runDir = path.join(RUNS_DIR, runId);
|
|
4611
|
-
const
|
|
4725
|
+
const isRetryAttempt = job.autoFixAttempted === true;
|
|
4612
4726
|
// Persist the attempt BEFORE spawning — a crash mid-investigation still
|
|
4613
4727
|
// counts it (mirrors orphanRetries). Safe even when the slot is busy: the
|
|
4614
4728
|
// investigation is queued and drained as slots free, so it is genuinely
|
|
@@ -4618,13 +4732,13 @@ async function reverifyNeedsReview() {
|
|
|
4618
4732
|
if (j) {
|
|
4619
4733
|
j.autoFixAttempted = true;
|
|
4620
4734
|
if (!j.runId && runId) j.runId = runId;
|
|
4621
|
-
if (
|
|
4735
|
+
if (isRetryAttempt) {
|
|
4622
4736
|
j.autoFixRetries = (j.autoFixRetries ?? 0) + 1;
|
|
4623
4737
|
delete j.autoFixOutcome;
|
|
4624
4738
|
}
|
|
4625
4739
|
}
|
|
4626
4740
|
});
|
|
4627
|
-
console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (${
|
|
4741
|
+
console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (${isRetryAttempt ? 'retry' : '1/1'})`);
|
|
4628
4742
|
spawnInvestigation(job, runDir).catch((e) => {
|
|
4629
4743
|
console.error('[scheduler] auto-fix spawnInvestigation error', job.slug, e);
|
|
4630
4744
|
});
|
|
@@ -4634,6 +4748,43 @@ async function reverifyNeedsReview() {
|
|
|
4634
4748
|
return { rescanned: candidates.length, healed, leftForReview };
|
|
4635
4749
|
}
|
|
4636
4750
|
|
|
4751
|
+
/**
|
|
4752
|
+
* schedule:clear-queue's core state mutation, split out so it's testable
|
|
4753
|
+
* without ipcMain/electron: every victim (non-running job whose slug is in
|
|
4754
|
+
* `victimSlugs`) must leave a durable history.jsonl record before it
|
|
4755
|
+
* disappears from queue.json — a manual clear used to just filter the row
|
|
4756
|
+
* out with no trace (a job could vanish with zero audit trail; see the
|
|
4757
|
+
* 2026-08-30 sigma `788-pr-sweep-final-gate` incident). Non-terminal victims
|
|
4758
|
+
* (pending/investigating/needs_review/quarantined) are force-transitioned to
|
|
4759
|
+
* 'skipped' — never 'completed', since a manually-cleared job did not ship.
|
|
4760
|
+
* Victims already terminal (completed/failed/skipped) keep their real
|
|
4761
|
+
* status; they just get a statusHistory note + a history row so they don't
|
|
4762
|
+
* silently drop before aging past HISTORY_RETENTION_MS.
|
|
4763
|
+
*
|
|
4764
|
+
* Mutates `state.jobs` in place (drops the victims, same contract as
|
|
4765
|
+
* reconcile()) and returns the job snapshots to append to history.jsonl.
|
|
4766
|
+
*/
|
|
4767
|
+
function applyClearQueueVictims(state, victimSlugs, archiveNoteBySlug, archiveDir) {
|
|
4768
|
+
const historyEntries = [];
|
|
4769
|
+
for (const job of state.jobs) {
|
|
4770
|
+
if (!victimSlugs.has(job.slug)) continue;
|
|
4771
|
+
const reason = archiveNoteBySlug?.get(job.slug) ?? `PRD archived to ${archiveDir}`;
|
|
4772
|
+
if (job.status === 'completed' || job.status === 'failed' || job.status === 'skipped') {
|
|
4773
|
+
// Already terminal — keep the real status, just record the clear.
|
|
4774
|
+
const history = Array.isArray(job.statusHistory) ? job.statusHistory : [];
|
|
4775
|
+
history.push({ from: job.status, to: job.status, reason, source: 'clear-queue', at: new Date().toISOString() });
|
|
4776
|
+
while (history.length > STATUS_HISTORY_CAP) history.shift();
|
|
4777
|
+
job.statusHistory = history;
|
|
4778
|
+
} else {
|
|
4779
|
+
transitionJob(job, 'skipped', { reason, source: 'clear-queue' });
|
|
4780
|
+
}
|
|
4781
|
+
if (!job.finishedAt) job.finishedAt = new Date().toISOString();
|
|
4782
|
+
historyEntries.push({ ...job });
|
|
4783
|
+
}
|
|
4784
|
+
state.jobs = state.jobs.filter((j) => !victimSlugs.has(j.slug));
|
|
4785
|
+
return historyEntries;
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4637
4788
|
function registerScheduleHandlers() {
|
|
4638
4789
|
ensureDirs();
|
|
4639
4790
|
supervisor.registerHandlers();
|
|
@@ -4796,10 +4947,6 @@ function registerScheduleHandlers() {
|
|
|
4796
4947
|
return { ok: true, kind: 'info', message };
|
|
4797
4948
|
});
|
|
4798
4949
|
|
|
4799
|
-
// Archive every non-running PRD and drop its entry from queue.json.
|
|
4800
|
-
// Running entries are kept (would orphan an in-flight job). PRD files are
|
|
4801
|
-
// moved (not deleted) to prds-archived/<ISO>/ so the user can recover them.
|
|
4802
|
-
// Path containment is enforced — only files inside PRDS_DIR are moved.
|
|
4803
4950
|
ipcMain.handle('schedule:clear-queue', async () => {
|
|
4804
4951
|
ensureDirs();
|
|
4805
4952
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
@@ -4811,6 +4958,7 @@ function registerScheduleHandlers() {
|
|
|
4811
4958
|
}
|
|
4812
4959
|
await fsp.mkdir(archiveDir, { recursive: true });
|
|
4813
4960
|
let archived = 0;
|
|
4961
|
+
const archiveNoteBySlug = new Map();
|
|
4814
4962
|
for (const job of victims) {
|
|
4815
4963
|
const srcDir = await findPrdDir(job.slug) ?? prdDirForCwd(job.cwd);
|
|
4816
4964
|
const src = path.resolve(path.join(srcDir, `${job.slug}.md`));
|
|
@@ -4819,17 +4967,27 @@ function registerScheduleHandlers() {
|
|
|
4819
4967
|
try {
|
|
4820
4968
|
await fsp.rename(src, dst);
|
|
4821
4969
|
archived++;
|
|
4970
|
+
archiveNoteBySlug.set(job.slug, `PRD file archived to ${archiveDir}`);
|
|
4822
4971
|
} catch (e) {
|
|
4823
4972
|
// ENOENT: the .md is already gone (reconcile would drop it on next
|
|
4824
4973
|
// read anyway). Either way, fall through and remove from queue.
|
|
4825
4974
|
if (e?.code !== 'ENOENT') {
|
|
4826
4975
|
logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'clear-queue: rename failed', meta: { slug: job.slug, error: e?.message } });
|
|
4827
4976
|
}
|
|
4977
|
+
archiveNoteBySlug.set(job.slug, `PRD file already gone (ENOENT) when archiving to ${archiveDir}`);
|
|
4828
4978
|
}
|
|
4829
4979
|
}
|
|
4830
4980
|
await mutate(async (s) => {
|
|
4831
4981
|
const victimSlugs = new Set(victims.map((j) => j.slug));
|
|
4832
|
-
|
|
4982
|
+
const historyEntries = applyClearQueueVictims(s, victimSlugs, archiveNoteBySlug, archiveDir);
|
|
4983
|
+
// Append BEFORE this mutate's writeQueue persists s.jobs without the
|
|
4984
|
+
// victims (mutate() only writes after this callback resolves) — same
|
|
4985
|
+
// append-before-drop ordering queueHistory.cjs's reconcile() callers
|
|
4986
|
+
// use, so a crash right after this handler returns can never leave a
|
|
4987
|
+
// row gone from queue.json with nothing in history.jsonl yet.
|
|
4988
|
+
if (historyEntries.length > 0) {
|
|
4989
|
+
await queueHistory.appendHistory(historyEntries);
|
|
4990
|
+
}
|
|
4833
4991
|
await reconcile(s);
|
|
4834
4992
|
return null;
|
|
4835
4993
|
});
|
|
@@ -5244,6 +5402,20 @@ async function init() {
|
|
|
5244
5402
|
}
|
|
5245
5403
|
}
|
|
5246
5404
|
|
|
5405
|
+
// Measured against this repo's own live+archived PRD set (868 entries
|
|
5406
|
+
// across every local project — the admin route has no cwd filter by
|
|
5407
|
+
// default): no single field dominates a listPrdsInternal() entry: title is
|
|
5408
|
+
// the largest at ~30% of payload bytes, followed by slug/cwd/sourcePromptId/
|
|
5409
|
+
// epicId at 12-14% each — the 353KB the route was shipping came from
|
|
5410
|
+
// returning the full unbounded row count, not from one oversized field.
|
|
5411
|
+
// This trims the fields an agent doesn't need to answer "what PRDs are
|
|
5412
|
+
// there" (parallelGroup/estimateMinutes/sourcePromptId/epicId/
|
|
5413
|
+
// archivedStatus); `fields=full` restores them.
|
|
5414
|
+
function toCompactPrdEntry(entry) {
|
|
5415
|
+
const { slug, title, cwd, mtimeMs, archived, status } = entry;
|
|
5416
|
+
return { slug, title, cwd, mtimeMs, archived, status };
|
|
5417
|
+
}
|
|
5418
|
+
|
|
5247
5419
|
/**
|
|
5248
5420
|
* listPrdsInternal() → every live + archived PRD across every project,
|
|
5249
5421
|
* with each entry's real job status folded in (`status`: the live queue
|
|
@@ -5503,15 +5675,25 @@ const remote = {
|
|
|
5503
5675
|
|
|
5504
5676
|
// Every live+archived PRD across every project (listPrdsInternal, shared
|
|
5505
5677
|
// with the renderer's schedule:list-prds IPC handler), filtered by the
|
|
5506
|
-
// admin route's cwd/epicId/status query params
|
|
5678
|
+
// admin route's cwd/epicId/status query params and paged by
|
|
5679
|
+
// limit/offset. Sort order is listPrdsInternal's own slug.localeCompare
|
|
5680
|
+
// (numeric) — stable across calls since it's a pure string sort, so
|
|
5681
|
+
// paging by offset can't skip or duplicate an entry between requests as
|
|
5682
|
+
// long as the underlying PRD set doesn't change mid-page.
|
|
5507
5683
|
async listPrds(filter = {}) {
|
|
5508
5684
|
const all = await listPrdsInternal();
|
|
5509
|
-
|
|
5685
|
+
const filtered = all.filter((entry) => {
|
|
5510
5686
|
if (filter.cwd && entry.cwd !== filter.cwd) return false;
|
|
5511
5687
|
if (filter.epicId && entry.epicId !== filter.epicId) return false;
|
|
5512
5688
|
if (filter.status && entry.status !== filter.status) return false;
|
|
5513
5689
|
return true;
|
|
5514
5690
|
});
|
|
5691
|
+
const total = filtered.length;
|
|
5692
|
+
const limit = filter.limit ?? 100;
|
|
5693
|
+
const offset = filter.offset ?? 0;
|
|
5694
|
+
const page = filtered.slice(offset, offset + limit);
|
|
5695
|
+
const prds = filter.fields === 'full' ? page : page.map(toCompactPrdEntry);
|
|
5696
|
+
return { prds, total, limit, offset, hasMore: offset + page.length < total };
|
|
5515
5697
|
},
|
|
5516
5698
|
|
|
5517
5699
|
// Full body + parsed frontmatter for one PRD, live or archived. Mirrors
|
|
@@ -5650,7 +5832,7 @@ const remote = {
|
|
|
5650
5832
|
const state = await readQueue();
|
|
5651
5833
|
const job = state.jobs.find((j) => j.slug === slug);
|
|
5652
5834
|
if (!job) return { ok: false, error: 'not found' };
|
|
5653
|
-
if (job.status === 'completed' || job.status === 'failed' || job.status === 'needs_review') {
|
|
5835
|
+
if (job.status === 'completed' || job.status === 'failed' || job.status === 'needs_review' || job.status === 'skipped') {
|
|
5654
5836
|
return { ok: false, error: `job already terminal (status: "${job.status}") — nothing to cancel` };
|
|
5655
5837
|
}
|
|
5656
5838
|
const wasRunning = job.status === 'running';
|
|
@@ -5712,4 +5894,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
5712
5894
|
});
|
|
5713
5895
|
}
|
|
5714
5896
|
|
|
5715
|
-
module.exports = { findOverrunningJobs, JOB_OVERRUN_FACTOR, JOB_OVERRUN_FLOOR_MS, registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, isGitRepoSync, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, resolveVerifyPrdPath, resolveNotifyPrd, runPrdMigration, consolidateAllFlatPrds, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult, spawnJob, listPrdsInternal, computeStallSummary, findStaleQuarantinedJobs, QUARANTINE_ESCALATE_MS };
|
|
5897
|
+
module.exports = { findOverrunningJobs, JOB_OVERRUN_FACTOR, JOB_OVERRUN_FLOOR_MS, registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor, healTargetForFix, buildInvestigationPrompt, isGitRepoSync, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, resolveVerifyPrdPath, resolveFixPlanPath, resolveNotifyPrd, runPrdMigration, consolidateAllFlatPrds, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult, spawnJob, listPrdsInternal, computeStallSummary, findStaleQuarantinedJobs, QUARANTINE_ESCALATE_MS, applyClearQueueVictims };
|
package/src/preload/api.d.ts
CHANGED
|
@@ -391,7 +391,7 @@ export interface ScheduleConfig {
|
|
|
391
391
|
* so this union is a manually-kept copy; drift from JOB_STATUSES (and from
|
|
392
392
|
* the renderer's own mirrors in StatusBadge.tsx/SchedulePanel.tsx) is
|
|
393
393
|
* caught by src/main/__tests__/scheduleJobStatusDrift.test.cjs. */
|
|
394
|
-
export type ScheduleJobStatus = 'pending' | 'running' | 'investigating' | 'completed' | 'failed' | 'needs_review' | 'quarantined';
|
|
394
|
+
export type ScheduleJobStatus = 'pending' | 'running' | 'investigating' | 'completed' | 'skipped' | 'failed' | 'needs_review' | 'quarantined';
|
|
395
395
|
|
|
396
396
|
export interface ScheduleJobRuntime {
|
|
397
397
|
pid: number;
|