claude-code-session-manager 0.75.1 → 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-CGplUKxY.js → AgentLibrary-s1jRGOji.js} +1 -1
- package/dist/assets/{DataModel-DQ15KxVb.js → DataModel-DNefcWbk.js} +1 -1
- package/dist/assets/{History-v2TSC_6A.js → History-4sMk80yI.js} +1 -1
- package/dist/assets/{Hooks-Co7s6BNB.js → Hooks-CNUVwH5P.js} +1 -1
- package/dist/assets/{HostBilko-DJj8FWPK.js → HostBilko-BhkyvIhw.js} +1 -1
- package/dist/assets/{Library-DQaC9VQ1.js → Library-BpockanA.js} +1 -1
- package/dist/assets/{ListDetail-BwFRfAZm.js → ListDetail-Cre7h3U0.js} +1 -1
- package/dist/assets/{MarkdownEditor-C155gNpc.js → MarkdownEditor-x-FzvaXm.js} +1 -1
- package/dist/assets/{McpServers-BmLgE_gS.js → McpServers-CXAcQLMB.js} +1 -1
- package/dist/assets/{Memory-ISwNcS5j.js → Memory-CGBv1Rco.js} +1 -1
- package/dist/assets/{Panel-1adjEpj-.js → Panel-czEs21N2.js} +1 -1
- package/dist/assets/{Permissions-FrCz5cGa.js → Permissions-rGkLOpqI.js} +1 -1
- package/dist/assets/{Plugins-zUssKp_b.js → Plugins-BTmB_vgU.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CAXqA_HX.js → ProvenanceBadge-dmCivDq5.js} +1 -1
- package/dist/assets/{SaveBar-D9cqms3Y.js → SaveBar-D80TNkAx.js} +1 -1
- package/dist/assets/{Scheduler-dNNc9S11.js → Scheduler-C6wHSZP7.js} +3 -3
- package/dist/assets/{ScopeSwitcher-1wBTne7S.js → ScopeSwitcher-DsaiYTgl.js} +1 -1
- package/dist/assets/{Settings-BT_expIg.js → Settings-m2fyU8Q5.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CVccq4Cu.js → SkillReferenceGraph-yS-lgBFU.js} +1 -1
- package/dist/assets/{Skills-xFpRY0ld.js → Skills-DtQgCs9-.js} +1 -1
- package/dist/assets/{SystemPrompt-CEk_71th.js → SystemPrompt-CLuVdoMA.js} +1 -1
- package/dist/assets/{TagLibrary-C2zhihQG.js → TagLibrary-DQXoI752.js} +1 -1
- package/dist/assets/{TiptapBody-CQKcHKaD.js → TiptapBody-BGKVXrvq.js} +1 -1
- package/dist/assets/{Toggle-CQ-IBb8m.js → Toggle-RFnEV71m.js} +1 -1
- package/dist/assets/{index-Dbo9R4e0.css → index-CMLnzdZC.css} +1 -1
- package/dist/assets/{index-BgbV3sKX.js → index-Ddrw7J7O.js} +4 -4
- package/dist/assets/{settingsSchema-D6ccSKqm.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-clear-queue-history.test.cjs +134 -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 +89 -16
- package/src/preload/api.d.ts +1 -1
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -369,6 +369,16 @@ const adminListPrdsQuery = z.object({
|
|
|
369
369
|
cwd: z.string().min(1).max(4096).optional(),
|
|
370
370
|
epicId: z.string().min(1).max(200).optional(),
|
|
371
371
|
status: z.string().min(1).max(40).optional(),
|
|
372
|
+
// Page size — default 100, hard max 500 so an agent can't accidentally
|
|
373
|
+
// re-request the full unbounded list (PRD: the 353KB/120s-timeout
|
|
374
|
+
// incident this pagination exists to fix). Query strings arrive as text,
|
|
375
|
+
// so these coerce before the int/range checks run.
|
|
376
|
+
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
|
|
377
|
+
offset: z.coerce.number().int().min(0).optional().default(0),
|
|
378
|
+
// 'compact' (default) omits the secondary detail fields (parallelGroup,
|
|
379
|
+
// estimateMinutes, sourcePromptId, epicId, archivedStatus) that aren't
|
|
380
|
+
// needed to answer "what PRDs are there"; 'full' restores them.
|
|
381
|
+
fields: z.enum(['compact', 'full']).optional().default('compact'),
|
|
372
382
|
});
|
|
373
383
|
|
|
374
384
|
const adminGetPrdQuery = z.object({
|
|
@@ -93,6 +93,18 @@ test('a FAILED bare-named dep holds the dependent and reports an explicit reason
|
|
|
93
93
|
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test('a SKIPPED (never-ran) bare-named dep holds the dependent and reports an explicit reason', () => {
|
|
97
|
+
const jobs = [
|
|
98
|
+
job('873-leftnav-two-face-framework', 'skipped'),
|
|
99
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
100
|
+
];
|
|
101
|
+
const { batch, reason } = pick(jobs);
|
|
102
|
+
assert.deepEqual(batch, []);
|
|
103
|
+
assert.match(reason, /depends-gate/);
|
|
104
|
+
assert.match(reason, /never-ran dependencies/);
|
|
105
|
+
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
106
|
+
});
|
|
107
|
+
|
|
96
108
|
// ---------------------------------------------------------------------------
|
|
97
109
|
// Parallelism regression coverage.
|
|
98
110
|
//
|
|
@@ -209,6 +221,20 @@ test('hold record carries a failed dep status alongside the depends-gate reason'
|
|
|
209
221
|
]);
|
|
210
222
|
});
|
|
211
223
|
|
|
224
|
+
test('hold record carries a skipped dep status alongside the depends-gate reason', () => {
|
|
225
|
+
const jobs = [
|
|
226
|
+
job('985-foundation', 'skipped'),
|
|
227
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
228
|
+
];
|
|
229
|
+
const { batch, reason, holds } = pick(jobs, new Set(), 5);
|
|
230
|
+
assert.deepEqual(batch, []);
|
|
231
|
+
assert.match(reason, /depends-gate/);
|
|
232
|
+
assert.match(reason, /never-ran dependencies/);
|
|
233
|
+
assert.deepEqual(holds, [
|
|
234
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'skipped' },
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
212
238
|
test('no holds when nothing is dependency-blocked', () => {
|
|
213
239
|
const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
|
|
214
240
|
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
@@ -45,6 +45,16 @@ function parseJsonBody(raw) {
|
|
|
45
45
|
* scheduler.cjs's own registerAdminRoute(s) pattern.
|
|
46
46
|
*/
|
|
47
47
|
function registerAdminRoute(adminHttp, remote) {
|
|
48
|
+
// GET /admin/scheduler/prds — paginated PRD listing (PRD: 353KB/120s
|
|
49
|
+
// fix). Sort order is stable and explicit: slug ascending via
|
|
50
|
+
// String.localeCompare(..., { numeric: true }) (listPrdsInternal in
|
|
51
|
+
// scheduler.cjs), so offset paging can't skip or duplicate an entry
|
|
52
|
+
// between calls as long as the underlying PRD set is unchanged.
|
|
53
|
+
// `limit`/`offset` default to 100/0 (hard max limit 500); `total` is the
|
|
54
|
+
// filtered count BEFORE the page slice, and `hasMore` is derived from
|
|
55
|
+
// `total`, not from whether the page came back full. `fields=full` opts
|
|
56
|
+
// into the secondary detail fields (parallelGroup, estimateMinutes,
|
|
57
|
+
// sourcePromptId, epicId, archivedStatus) omitted by default.
|
|
48
58
|
adminHttp.registerRoute('GET', '/admin/scheduler/prds', async (req, res, query) => {
|
|
49
59
|
let input;
|
|
50
60
|
try {
|
|
@@ -53,8 +63,8 @@ function registerAdminRoute(adminHttp, remote) {
|
|
|
53
63
|
sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
|
|
54
64
|
return;
|
|
55
65
|
}
|
|
56
|
-
const prds = await remote.listPrds(input);
|
|
57
|
-
sendJson(res, 200, { ok: true, prds });
|
|
66
|
+
const { prds, total, limit, offset, hasMore } = await remote.listPrds(input);
|
|
67
|
+
sendJson(res, 200, { ok: true, prds, total, limit, offset, hasMore });
|
|
58
68
|
});
|
|
59
69
|
|
|
60
70
|
adminHttp.registerRoute('GET', '/admin/scheduler/prd', async (req, res, query) => {
|
|
@@ -62,9 +62,11 @@ function jobKey(job) {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* O(n) over `jobs`. Splits terminal jobs into `hot` (stays in queue.json)
|
|
65
|
-
* and `toArchive` (moves to history.jsonl). A completed/failed job
|
|
66
|
-
* only when ALL of:
|
|
67
|
-
* - status is 'completed' or '
|
|
65
|
+
* and `toArchive` (moves to history.jsonl). A completed/failed/skipped job
|
|
66
|
+
* archives only when ALL of:
|
|
67
|
+
* - status is 'completed', 'failed', or 'skipped' (skipped: a job whose
|
|
68
|
+
* PRD source vanished before dispatch — never ran, but still terminal,
|
|
69
|
+
* and must age out of the hot queue the same as any other finished row)
|
|
68
70
|
* - finishedAt parses and is older than opts.retentionMs (default
|
|
69
71
|
* HISTORY_RETENTION_MS)
|
|
70
72
|
* - it is not the healTargetForFix() parent of a pending/running fix-plan
|
|
@@ -90,7 +92,7 @@ function partitionJobs(jobs, nowMs, opts = {}) {
|
|
|
90
92
|
if (!j) continue;
|
|
91
93
|
const finishedMs = j.finishedAt ? Date.parse(j.finishedAt) : NaN;
|
|
92
94
|
const archivable =
|
|
93
|
-
(j.status === 'completed' || j.status === 'failed') &&
|
|
95
|
+
(j.status === 'completed' || j.status === 'failed' || j.status === 'skipped') &&
|
|
94
96
|
Number.isFinite(finishedMs) &&
|
|
95
97
|
(nowMs - finishedMs) > retentionMs &&
|
|
96
98
|
!protectedSlugs.has(j.slug);
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
const { z } = require('zod');
|
|
26
26
|
|
|
27
|
-
const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined'];
|
|
27
|
+
const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'skipped', 'failed', 'needs_review', 'quarantined'];
|
|
28
28
|
|
|
29
29
|
const ScheduleJobStatusSchema = z.enum(JOB_STATUSES);
|
|
30
30
|
|
|
@@ -44,7 +44,10 @@ const STATUS_HISTORY_CAP = 20;
|
|
|
44
44
|
* manual archive of an already-shipped PRD), pending->failed (admin
|
|
45
45
|
* cancelJob on a not-yet-started job)
|
|
46
46
|
* - running->completed|failed|needs_review (normal run outcomes, reaper),
|
|
47
|
-
* running->
|
|
47
|
+
* running->skipped (spawnJob:skip-archived's 'prd-missing' case — no
|
|
48
|
+
* executor ever ran; kept distinct from 'completed' so unrun work can't
|
|
49
|
+
* read as shipped), running->pending (halt/rate-limit reset,
|
|
50
|
+
* transient-failure retry)
|
|
48
51
|
* - investigating->failed|needs_review (restore prior status once the
|
|
49
52
|
* investigation probe exits), investigating->completed (defensive: the
|
|
50
53
|
* restored prior status could in principle be 'completed' if a caller
|
|
@@ -58,20 +61,30 @@ const STATUS_HISTORY_CAP = 20;
|
|
|
58
61
|
* - completed->pending (force-only reset, gated separately by
|
|
59
62
|
* resetJobFields' own guard — this table only says the edge is
|
|
60
63
|
* structurally legal, not that every caller may take it unconditionally)
|
|
64
|
+
* - skipped->pending (force-only reset, same resetJobFields guard as
|
|
65
|
+
* completed->pending — 'skipped' is otherwise terminal: no further
|
|
66
|
+
* transitions out)
|
|
61
67
|
* - quarantined->pending (reconcile()'s adopt path, PRD-authoring lockdown:
|
|
62
68
|
* a PRD discovered with no `createdVia` provenance stamp is queued
|
|
63
69
|
* 'quarantined' instead of 'pending'; the ONLY way out is the PRD being
|
|
64
70
|
* stamped via the update-prd API — reconcile() detects the stamp on its
|
|
65
71
|
* next pass and promotes the row)
|
|
72
|
+
* - pending|investigating|needs_review|quarantined -> skipped
|
|
73
|
+
* (schedule:clear-queue: a manual queue clear force-terminates any
|
|
74
|
+
* non-running victim row so it can leave a durable history.jsonl record
|
|
75
|
+
* instead of vanishing — same 'never ran, don't call it completed'
|
|
76
|
+
* rationale as running->skipped, just reachable from every pre-dispatch
|
|
77
|
+
* or unresolved status a queued row can be sitting in)
|
|
66
78
|
*/
|
|
67
79
|
const LEGAL_TRANSITIONS = {
|
|
68
|
-
pending: ['running', 'completed', 'failed'],
|
|
69
|
-
running: ['completed', 'failed', 'needs_review', 'pending'],
|
|
70
|
-
investigating: ['failed', 'needs_review', 'completed', 'pending'],
|
|
80
|
+
pending: ['running', 'completed', 'failed', 'skipped'],
|
|
81
|
+
running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
|
|
82
|
+
investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
|
|
71
83
|
failed: ['investigating', 'pending', 'completed'],
|
|
72
|
-
needs_review: ['investigating', 'pending', 'completed'],
|
|
84
|
+
needs_review: ['investigating', 'pending', 'completed', 'skipped'],
|
|
73
85
|
completed: ['pending'],
|
|
74
|
-
|
|
86
|
+
skipped: ['pending'],
|
|
87
|
+
quarantined: ['pending', 'skipped'],
|
|
75
88
|
};
|
|
76
89
|
|
|
77
90
|
let refusedTransitionCount = 0;
|
|
@@ -97,6 +97,12 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
97
97
|
|
|
98
98
|
const pending = [];
|
|
99
99
|
const heldByFailedDep = [];
|
|
100
|
+
// A dep that never ran (its PRD source vanished before dispatch — see
|
|
101
|
+
// scheduleJobSchema.cjs's 'skipped' status) is just as blocking as a
|
|
102
|
+
// failed one and deserves the same explicit reason, not a silent fall into
|
|
103
|
+
// the generic `holds` bucket — resetting won't help here (the source file
|
|
104
|
+
// is gone), so the guidance differs from the failed-dep case.
|
|
105
|
+
const heldBySkippedDep = [];
|
|
100
106
|
// Per-job hold records, surfaced to the UI so a `pending` row can say WHICH
|
|
101
107
|
// dep is holding it instead of leaving the reason in console.log.
|
|
102
108
|
const holds = [];
|
|
@@ -105,7 +111,9 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
105
111
|
if (!dep) { pending.push(j); continue; }
|
|
106
112
|
const depRows = rowsForDep(dep);
|
|
107
113
|
const failed = depRows.some((d) => d.status === 'failed');
|
|
114
|
+
const skipped = depRows.some((d) => d.status === 'skipped');
|
|
108
115
|
if (failed) heldByFailedDep.push({ job: j, dep });
|
|
116
|
+
else if (skipped) heldBySkippedDep.push({ job: j, dep });
|
|
109
117
|
holds.push({
|
|
110
118
|
slug: j.slug,
|
|
111
119
|
dep,
|
|
@@ -114,9 +122,17 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
114
122
|
// running/pending/needs_review dep — simply not eligible this tick.
|
|
115
123
|
}
|
|
116
124
|
if (pending.length === 0) {
|
|
125
|
+
const reasons = [];
|
|
117
126
|
if (heldByFailedDep.length > 0) {
|
|
118
127
|
const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
|
|
119
|
-
|
|
128
|
+
reasons.push(`holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`);
|
|
129
|
+
}
|
|
130
|
+
if (heldBySkippedDep.length > 0) {
|
|
131
|
+
const detail = heldBySkippedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
|
|
132
|
+
reasons.push(`holding ${heldBySkippedDep.length} job(s) behind never-ran dependencies [${detail}]. The dep's PRD source is gone — author a fresh PRD for that work to unblock.`);
|
|
133
|
+
}
|
|
134
|
+
if (reasons.length > 0) {
|
|
135
|
+
const reason = `[scheduler] depends-gate [${projectCwd}]: ${reasons.join(' ')}`;
|
|
120
136
|
console.log(reason);
|
|
121
137
|
return { batch: [], reason, holds };
|
|
122
138
|
}
|
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;
|
|
@@ -3486,13 +3486,18 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
3486
3486
|
// Stale queue entry: the PRD was archived (already shipped) or is gone
|
|
3487
3487
|
// from disk entirely (deleted before dispatch). Both are retire-not-fail
|
|
3488
3488
|
// results from prdArchivedSkipResult with exitCode 0 and no transcript —
|
|
3489
|
-
//
|
|
3490
|
-
//
|
|
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).
|
|
3491
3495
|
if (res.skipped) {
|
|
3496
|
+
const targetStatus = res.skipped === 'prd-missing' ? 'skipped' : 'completed';
|
|
3492
3497
|
await mutate((s) => {
|
|
3493
3498
|
const idx = s.jobs.findIndex((x) => x.slug === job.slug);
|
|
3494
3499
|
if (idx >= 0) {
|
|
3495
|
-
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' });
|
|
3496
3501
|
s.jobs[idx].finishedAt = new Date().toISOString();
|
|
3497
3502
|
s.jobs[idx].exitCode = 0;
|
|
3498
3503
|
s.jobs[idx].error = null;
|
|
@@ -4297,7 +4302,7 @@ async function pollLoop() {
|
|
|
4297
4302
|
// callers/tests that only pass `jobs` are unaffected.
|
|
4298
4303
|
function selectHistoryJobs(jobs, limit, historyEntries = []) {
|
|
4299
4304
|
const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
|
|
4300
|
-
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'));
|
|
4301
4306
|
const seen = new Set(hot.map((j) => `${j.slug}|${j.runId ?? ''}`));
|
|
4302
4307
|
const archived = (Array.isArray(historyEntries) ? historyEntries : []).filter((j) => {
|
|
4303
4308
|
if (!j) return false;
|
|
@@ -4743,6 +4748,43 @@ async function reverifyNeedsReview() {
|
|
|
4743
4748
|
return { rescanned: candidates.length, healed, leftForReview };
|
|
4744
4749
|
}
|
|
4745
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
|
+
|
|
4746
4788
|
function registerScheduleHandlers() {
|
|
4747
4789
|
ensureDirs();
|
|
4748
4790
|
supervisor.registerHandlers();
|
|
@@ -4905,10 +4947,6 @@ function registerScheduleHandlers() {
|
|
|
4905
4947
|
return { ok: true, kind: 'info', message };
|
|
4906
4948
|
});
|
|
4907
4949
|
|
|
4908
|
-
// Archive every non-running PRD and drop its entry from queue.json.
|
|
4909
|
-
// Running entries are kept (would orphan an in-flight job). PRD files are
|
|
4910
|
-
// moved (not deleted) to prds-archived/<ISO>/ so the user can recover them.
|
|
4911
|
-
// Path containment is enforced — only files inside PRDS_DIR are moved.
|
|
4912
4950
|
ipcMain.handle('schedule:clear-queue', async () => {
|
|
4913
4951
|
ensureDirs();
|
|
4914
4952
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
@@ -4920,6 +4958,7 @@ function registerScheduleHandlers() {
|
|
|
4920
4958
|
}
|
|
4921
4959
|
await fsp.mkdir(archiveDir, { recursive: true });
|
|
4922
4960
|
let archived = 0;
|
|
4961
|
+
const archiveNoteBySlug = new Map();
|
|
4923
4962
|
for (const job of victims) {
|
|
4924
4963
|
const srcDir = await findPrdDir(job.slug) ?? prdDirForCwd(job.cwd);
|
|
4925
4964
|
const src = path.resolve(path.join(srcDir, `${job.slug}.md`));
|
|
@@ -4928,17 +4967,27 @@ function registerScheduleHandlers() {
|
|
|
4928
4967
|
try {
|
|
4929
4968
|
await fsp.rename(src, dst);
|
|
4930
4969
|
archived++;
|
|
4970
|
+
archiveNoteBySlug.set(job.slug, `PRD file archived to ${archiveDir}`);
|
|
4931
4971
|
} catch (e) {
|
|
4932
4972
|
// ENOENT: the .md is already gone (reconcile would drop it on next
|
|
4933
4973
|
// read anyway). Either way, fall through and remove from queue.
|
|
4934
4974
|
if (e?.code !== 'ENOENT') {
|
|
4935
4975
|
logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'clear-queue: rename failed', meta: { slug: job.slug, error: e?.message } });
|
|
4936
4976
|
}
|
|
4977
|
+
archiveNoteBySlug.set(job.slug, `PRD file already gone (ENOENT) when archiving to ${archiveDir}`);
|
|
4937
4978
|
}
|
|
4938
4979
|
}
|
|
4939
4980
|
await mutate(async (s) => {
|
|
4940
4981
|
const victimSlugs = new Set(victims.map((j) => j.slug));
|
|
4941
|
-
|
|
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
|
+
}
|
|
4942
4991
|
await reconcile(s);
|
|
4943
4992
|
return null;
|
|
4944
4993
|
});
|
|
@@ -5353,6 +5402,20 @@ async function init() {
|
|
|
5353
5402
|
}
|
|
5354
5403
|
}
|
|
5355
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
|
+
|
|
5356
5419
|
/**
|
|
5357
5420
|
* listPrdsInternal() → every live + archived PRD across every project,
|
|
5358
5421
|
* with each entry's real job status folded in (`status`: the live queue
|
|
@@ -5612,15 +5675,25 @@ const remote = {
|
|
|
5612
5675
|
|
|
5613
5676
|
// Every live+archived PRD across every project (listPrdsInternal, shared
|
|
5614
5677
|
// with the renderer's schedule:list-prds IPC handler), filtered by the
|
|
5615
|
-
// 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.
|
|
5616
5683
|
async listPrds(filter = {}) {
|
|
5617
5684
|
const all = await listPrdsInternal();
|
|
5618
|
-
|
|
5685
|
+
const filtered = all.filter((entry) => {
|
|
5619
5686
|
if (filter.cwd && entry.cwd !== filter.cwd) return false;
|
|
5620
5687
|
if (filter.epicId && entry.epicId !== filter.epicId) return false;
|
|
5621
5688
|
if (filter.status && entry.status !== filter.status) return false;
|
|
5622
5689
|
return true;
|
|
5623
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 };
|
|
5624
5697
|
},
|
|
5625
5698
|
|
|
5626
5699
|
// Full body + parsed frontmatter for one PRD, live or archived. Mirrors
|
|
@@ -5759,7 +5832,7 @@ const remote = {
|
|
|
5759
5832
|
const state = await readQueue();
|
|
5760
5833
|
const job = state.jobs.find((j) => j.slug === slug);
|
|
5761
5834
|
if (!job) return { ok: false, error: 'not found' };
|
|
5762
|
-
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') {
|
|
5763
5836
|
return { ok: false, error: `job already terminal (status: "${job.status}") — nothing to cancel` };
|
|
5764
5837
|
}
|
|
5765
5838
|
const wasRunning = job.status === 'running';
|
|
@@ -5821,4 +5894,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
5821
5894
|
});
|
|
5822
5895
|
}
|
|
5823
5896
|
|
|
5824
|
-
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 };
|
|
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;
|