claude-code-session-manager 0.37.0 → 0.37.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/bin/cli.cjs +12 -1
- package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
- package/dist/assets/index-CD_LuJZF.css +32 -0
- package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
- package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
- package/scripts/lib/activeSessions.cjs +117 -0
- package/scripts/lib/watchdogHelpers.cjs +829 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
- package/src/main/__tests__/docEdit.test.cjs +164 -2
- package/src/main/__tests__/prdCreate.test.cjs +265 -25
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
- package/src/main/browserAgentServer.cjs +11 -10
- package/src/main/chatRunner.cjs +21 -2
- package/src/main/docEdit.cjs +125 -5
- package/src/main/health.cjs +15 -0
- package/src/main/index.cjs +12 -6
- package/src/main/ipcSchemas.cjs +14 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
- package/src/main/lib/classifyTranscriptLine.cjs +89 -0
- package/src/main/lib/localAdminHttp.cjs +157 -0
- package/src/main/lib/personaImportHealth.cjs +161 -0
- package/src/main/lib/prdCreate.cjs +107 -17
- package/src/main/lib/rcaFeedbackHook.cjs +2 -2
- package/src/main/lib/singleInstanceGuard.cjs +20 -0
- package/src/main/scheduler.cjs +198 -54
- package/src/main/templates/PRD_AUTHORING.md +4 -4
- package/src/main/transcripts.cjs +1 -85
- package/src/preload/api.d.ts +12 -1
- package/src/preload/index.cjs +6 -0
- package/dist/assets/index-CTTjT08J.css +0 -32
- package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
- package/src/main/__tests__/adminServer.test.cjs +0 -380
- package/src/main/adminServer.cjs +0 -242
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* prdCreate.cjs — PRD-body builder
|
|
3
|
-
* gh-issue-6
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* prdCreate.cjs — PRD-body builder + create-prd admin HTTP route (PRD 549,
|
|
3
|
+
* gh-issue-6; route consolidated here from the former standalone admin HTTP
|
|
4
|
+
* server module by PRD 689). buildPrdBody/deriveSlugFromTitle/readStandards are pure
|
|
5
|
+
* functions: no filesystem writes, no NN allocation, no HTTP. registerAdminRoute
|
|
6
|
+
* owns orchestration (auth is the injected transport's job, cwd validation
|
|
7
|
+
* via config.cjs's validatePath, NN allocation via the injected remote,
|
|
8
|
+
* writing via remote.writePrd -> config.cjs's writeTextAtomic).
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Neither this route nor the /develop skill embeds standards.md's contents
|
|
11
|
+
* anymore — both just point the headless executor at STANDARDS_PATH with an
|
|
12
|
+
* instruction to read it before starting. There's nothing to go stale: the
|
|
13
|
+
* executor always reads the live file at run time, same one-concept-one-
|
|
14
|
+
* implementation reasoning that keeps the two PRD-creation paths in sync
|
|
15
|
+
* (see SKILL.md).
|
|
13
16
|
*/
|
|
14
17
|
'use strict';
|
|
15
18
|
|
|
16
19
|
const fsp = require('node:fs/promises');
|
|
17
20
|
const path = require('node:path');
|
|
18
|
-
const { PRD_CREATE_SLUG_RE } = require('../ipcSchemas.cjs');
|
|
21
|
+
const { schemas, PRD_CREATE_SLUG_RE } = require('../ipcSchemas.cjs');
|
|
19
22
|
const { kebabCase } = require('./kebabCase.cjs');
|
|
23
|
+
const config = require('../config.cjs');
|
|
24
|
+
const { expandHome } = require('./expandHome.cjs');
|
|
25
|
+
const { readBody, sendJson } = require('./localAdminHttp.cjs');
|
|
20
26
|
|
|
21
27
|
const STANDARDS_PATH = path.join(
|
|
22
28
|
__dirname, '..', '..', '..',
|
|
@@ -33,12 +39,13 @@ function deriveSlugFromTitle(title) {
|
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
* Build the full PRD markdown body (frontmatter + required sections +
|
|
37
|
-
*
|
|
38
|
-
* SKILL.md documents: frontmatter, then Goal / Acceptance
|
|
39
|
-
* Implementation notes / Out of scope / Engineering standards,
|
|
42
|
+
* Build the full PRD markdown body (frontmatter + required sections + a
|
|
43
|
+
* pointer at the engineering standards file), matching the structure
|
|
44
|
+
* `/develop`'s SKILL.md documents: frontmatter, then Goal / Acceptance
|
|
45
|
+
* criteria / Implementation notes / Out of scope / Engineering standards,
|
|
46
|
+
* in order.
|
|
40
47
|
*/
|
|
41
|
-
function buildPrdBody(input
|
|
48
|
+
function buildPrdBody(input) {
|
|
42
49
|
const {
|
|
43
50
|
title, cwd, estimateMinutes, goal, acceptanceCriteria,
|
|
44
51
|
implementationNotes, outOfScope,
|
|
@@ -53,21 +60,104 @@ function buildPrdBody(input, standardsText) {
|
|
|
53
60
|
const oosSource = outOfScope && outOfScope.length ? outOfScope : ['(none)'];
|
|
54
61
|
const oosLines = oosSource.map((line) => `- ${line}`).join('\n');
|
|
55
62
|
|
|
63
|
+
const standardsPointer = [
|
|
64
|
+
`Before writing any code, read \`${STANDARDS_PATH}\` — it has the Performance, Debugging,`,
|
|
65
|
+
'API-reuse, TDD, and Execution-discipline rules that apply to this PRD. Every rule in it is',
|
|
66
|
+
'mandatory, especially Execution discipline (bounded commands, verify before done, the',
|
|
67
|
+
'finish-protocol sentinel).',
|
|
68
|
+
].join('\n');
|
|
69
|
+
|
|
56
70
|
const bodyLines = [
|
|
57
71
|
'# Goal', '', goal, '',
|
|
58
72
|
'# Acceptance criteria', '', acLines, '',
|
|
59
73
|
'# Implementation notes', '', implementationNotes, '',
|
|
60
74
|
'# Out of scope', '', oosLines, '',
|
|
61
|
-
'## Engineering standards', '',
|
|
75
|
+
'## Engineering standards', '', standardsPointer, '',
|
|
62
76
|
];
|
|
63
77
|
|
|
64
78
|
return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Registers the create-prd admin HTTP route (PRD 689 — moved verbatim out of
|
|
83
|
+
* the former standalone admin HTTP server module's handleRequest, no behavior
|
|
84
|
+
* change) against an injected localAdminHttp.cjs transport. `remote` is
|
|
85
|
+
* scheduler.cjs's remote object, passed explicitly (not required directly) so
|
|
86
|
+
* this stays testable without booting Electron, matching that former
|
|
87
|
+
* module's original dependency-injection pattern.
|
|
88
|
+
*/
|
|
89
|
+
function registerAdminRoute(adminHttp, remote) {
|
|
90
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/create-prd', async (req, res) => {
|
|
91
|
+
const raw = await readBody(req);
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
95
|
+
} catch {
|
|
96
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let input;
|
|
101
|
+
try {
|
|
102
|
+
input = schemas.schedulerCreatePrd.parse(parsed);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
sendJson(res, 400, { ok: false, error: 'invalid PRD payload', details: e?.issues ?? e?.message });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// cwd is untrusted: this is the first *creating* mutation on a
|
|
109
|
+
// token-authed API whose product is "a command that will later run
|
|
110
|
+
// with --dangerously-skip-permissions in a chosen cwd". Route it
|
|
111
|
+
// through config.cjs's validatePath (allowedRoots = home dir) —
|
|
112
|
+
// same boundary every other fs-touching IPC handler uses — never a
|
|
113
|
+
// bespoke check here.
|
|
114
|
+
try {
|
|
115
|
+
config.validatePath(expandHome(input.cwd));
|
|
116
|
+
} catch (e) {
|
|
117
|
+
sendJson(res, 400, { ok: false, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const slug = input.slug || deriveSlugFromTitle(input.title);
|
|
122
|
+
if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
|
|
123
|
+
sendJson(res, 400, { ok: false, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// NN allocation is delegated to allocateParallelGroup() (PRD 548) via
|
|
128
|
+
// the injected remote — never re-derived here — unless the caller
|
|
129
|
+
// opted into an existing group explicitly.
|
|
130
|
+
const nn = input.parallelGroup ?? await remote.allocateParallelGroup();
|
|
131
|
+
const filenameSlug = `${nn}-${slug}`;
|
|
132
|
+
|
|
133
|
+
// An explicit `parallelGroup` bypasses allocateParallelGroup()'s
|
|
134
|
+
// collision-proof reservation, so re-check for an existing file at
|
|
135
|
+
// this exact destination before writing — remote.writePrd itself has
|
|
136
|
+
// no existence guard (by design, it doubles as the edit-in-place
|
|
137
|
+
// path for Scheduler UI PRD edits), so "create" must not silently
|
|
138
|
+
// clobber an existing job's PRD.
|
|
139
|
+
const existing = await remote.readPrd(filenameSlug);
|
|
140
|
+
if (existing?.ok) {
|
|
141
|
+
sendJson(res, 409, { ok: false, error: `PRD already exists: ${filenameSlug}.md` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const body = buildPrdBody(input);
|
|
146
|
+
const writeResult = await remote.writePrd(filenameSlug, body);
|
|
147
|
+
if (!writeResult?.ok) {
|
|
148
|
+
sendJson(res, 500, { ok: false, error: writeResult?.error ?? 'write failed' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
sendJson(res, 200, { nn, filename: `${filenameSlug}.md`, status: 'queued' });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
67
156
|
module.exports = {
|
|
68
157
|
PRD_CREATE_SLUG_RE,
|
|
69
158
|
STANDARDS_PATH,
|
|
70
159
|
readStandards,
|
|
71
160
|
deriveSlugFromTitle,
|
|
72
161
|
buildPrdBody,
|
|
162
|
+
registerAdminRoute,
|
|
73
163
|
};
|
|
@@ -38,8 +38,8 @@ const PRDS_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'schedule
|
|
|
38
38
|
const SM_REPO_ROOT = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
39
39
|
const SM_REPO_FEEDBACK_DIR = path.join(SM_REPO_ROOT, 'session-manager-operations', 'feedback');
|
|
40
40
|
|
|
41
|
-
// NEVER write here: ~/.claude/session-manager/feedback/ is the
|
|
42
|
-
//
|
|
41
|
+
// NEVER write here: ~/.claude/session-manager/feedback/ is the scheduler's
|
|
42
|
+
// auto-PRD intake (src/main/scheduler.cjs's periodic feedback sweep) — it
|
|
43
43
|
// converts anything dropped there into a scheduled PRD automatically. Filing
|
|
44
44
|
// an RCA there would create an RCA → auto-PRD → run → needs_review → RCA
|
|
45
45
|
// cycle with no human in the loop. Both real destinations below are project
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* singleInstanceGuard.cjs — the losing side of Electron's single-instance
|
|
3
|
+
* lock must actually terminate the process, not just request a quit.
|
|
4
|
+
*
|
|
5
|
+
* app.quit() only requests a graceful shutdown via the normal
|
|
6
|
+
* window-all-closed -> quit -> exit chain; a losing instance calls it before
|
|
7
|
+
* app.whenReady() ever fires (no window, no IPC), so there's nothing for
|
|
8
|
+
* that chain to hook into and the process can sit as a zombie indefinitely.
|
|
9
|
+
* app.exit() terminates immediately without waiting on that chain; the
|
|
10
|
+
* setTimeout is a defense-in-depth backstop in case app.exit() itself is
|
|
11
|
+
* ever delayed by an Electron internal on a future version.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
function terminateLosingInstance(app, { fallbackDelayMs = 2000 } = {}) {
|
|
16
|
+
setTimeout(() => process.exit(0), fallbackDelayMs).unref?.();
|
|
17
|
+
app.exit(0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { terminateLosingInstance };
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -63,6 +63,7 @@ const prdParser = require('./scheduler/prdParser.cjs');
|
|
|
63
63
|
const { verifyRun } = require('./runVerify.cjs');
|
|
64
64
|
const logs = require('./logs.cjs');
|
|
65
65
|
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
66
|
+
const { readBody, sendJson } = require('./lib/localAdminHttp.cjs');
|
|
66
67
|
const {
|
|
67
68
|
POLL_INTERVAL_MS,
|
|
68
69
|
USAGE_REFRESH_INTERVAL_MS,
|
|
@@ -74,6 +75,12 @@ const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
|
|
|
74
75
|
const { fileRcaFeedback, extractRcaBlock } = require('./lib/rcaFeedbackHook.cjs');
|
|
75
76
|
const queueHistory = require('./lib/queueHistory.cjs');
|
|
76
77
|
const queueOps = require('./queueOps.cjs');
|
|
78
|
+
// Feedback-auto-PRD sweep — formerly only run by the external scheduler-watchdog
|
|
79
|
+
// while the app was down (PRD 686 moved it in-app so it also runs while alive).
|
|
80
|
+
// Plain Node module, no Electron dependency; queuePath/prdsDir defaults already
|
|
81
|
+
// match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
|
|
82
|
+
// home-dir layout.
|
|
83
|
+
const { sweep: sweepFeedback } = require('../../scripts/lib/watchdogHelpers.cjs');
|
|
77
84
|
|
|
78
85
|
const MAX_INVESTIGATION_DURATION_MS = 30 * 60_000;
|
|
79
86
|
|
|
@@ -203,10 +210,10 @@ function gitHead(cwd) {
|
|
|
203
210
|
|
|
204
211
|
// Returns true if ≥1 commit landed on any ref (branch, remote-tracking branch,
|
|
205
212
|
// or tag) in cwd between startedAt and finishedAt (with 60s slack) — not just
|
|
206
|
-
// the currently checked-out branch. Used by the self-heal pass
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// stays as-is).
|
|
213
|
+
// the currently checked-out branch. Used both by the self-heal pass and by the
|
|
214
|
+
// live commit-guard's fallback (see computeCommittedDuringRun) to derive
|
|
215
|
+
// committedDuringRun from the recorded run window. Never throws;
|
|
216
|
+
// git-unavailable → false (no override, job stays as-is).
|
|
210
217
|
function committedInWindow(cwd, startedAt, finishedAt) {
|
|
211
218
|
return new Promise((resolve) => {
|
|
212
219
|
if (!cwd || !startedAt) { resolve(false); return; }
|
|
@@ -222,6 +229,16 @@ function committedInWindow(cwd, startedAt, finishedAt) {
|
|
|
222
229
|
});
|
|
223
230
|
}
|
|
224
231
|
|
|
232
|
+
// Live commit-guard: cheap HEAD-diff fast path, falling back to the
|
|
233
|
+
// git-log --all scan when HEAD didn't move on the starting branch. A PRD that
|
|
234
|
+
// checks out other branches, commits real work on each, then checks its
|
|
235
|
+
// starting branch back out before exit leaves HEAD unchanged even though
|
|
236
|
+
// commits landed — the fallback catches that case. Never throws.
|
|
237
|
+
async function computeCommittedDuringRun(cwd, headBefore, headAfter, startedAt, untilIso) {
|
|
238
|
+
if (headBefore && headAfter && headBefore !== headAfter) return true;
|
|
239
|
+
return committedInWindow(cwd, startedAt, untilIso);
|
|
240
|
+
}
|
|
241
|
+
|
|
225
242
|
const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
|
|
226
243
|
const PRDS_DIR = path.join(ROOT, 'prds');
|
|
227
244
|
const RUNS_DIR = path.join(ROOT, 'runs');
|
|
@@ -817,6 +834,17 @@ let resumeTimer = null;
|
|
|
817
834
|
let pollLoopTimer = null;
|
|
818
835
|
let rescheduleInterval = null;
|
|
819
836
|
let heartbeatInterval = null;
|
|
837
|
+
// Feedback sweep piggybacks on the 60s heartbeat tick but runs far less often —
|
|
838
|
+
// every Nth tick — since it's a readdir-per-active-project scan, not free.
|
|
839
|
+
// 5 ticks = 5 minutes; well inside sweep()/activeProjectCwds()'s 90-minute
|
|
840
|
+
// active-session window, so no active project can be missed between sweeps.
|
|
841
|
+
const FEEDBACK_SWEEP_TICK_INTERVAL = 5;
|
|
842
|
+
let feedbackSweepTickCount = 0;
|
|
843
|
+
/** Pure gate for the tick counter above — exported so the wiring is testable
|
|
844
|
+
* without waiting on real setInterval timers. */
|
|
845
|
+
function feedbackSweepDue(tickCount, interval = FEEDBACK_SWEEP_TICK_INTERVAL) {
|
|
846
|
+
return tickCount >= interval;
|
|
847
|
+
}
|
|
820
848
|
// In-memory set of slugs currently spawned in this process. Prevents
|
|
821
849
|
// double-spawn when runDueJobs() is called while jobs are in flight.
|
|
822
850
|
const runningSet = new Set();
|
|
@@ -1059,6 +1087,79 @@ function resetJobFields(job, errorMsg) {
|
|
|
1059
1087
|
delete job.verifierVerdict;
|
|
1060
1088
|
}
|
|
1061
1089
|
|
|
1090
|
+
// Grace period between a boot orphan's SIGTERM and reading its log to
|
|
1091
|
+
// classify the outcome — matches killOrphanClaudePid's own internal 5s
|
|
1092
|
+
// SIGKILL follow-up delay, plus a small margin so classification always runs
|
|
1093
|
+
// after that SIGKILL has had a chance to land.
|
|
1094
|
+
const BOOT_ORPHAN_KILL_GRACE_MS = 6000;
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* partitionBootOrphans(jobs, isAlive?) → { immediate: string[], deferred: string[] }
|
|
1098
|
+
*
|
|
1099
|
+
* Pure decision split for boot reconciliation. A 'running' job whose recorded
|
|
1100
|
+
* pid is still alive must NOT be classified from its log yet — the orphaned
|
|
1101
|
+
* process may still be writing to it, so reading now risks misclassifying a
|
|
1102
|
+
* job that is about to emit result:success as no_result and double-running it.
|
|
1103
|
+
* Ported from reconcileQueueOffline's cross-tick escalation (see
|
|
1104
|
+
* scripts/lib/watchdogHelpers.cjs) — here it's a single deferred window since
|
|
1105
|
+
* this process stays up to revisit it, rather than a separate short-lived
|
|
1106
|
+
* watchdog process needing another tick.
|
|
1107
|
+
*/
|
|
1108
|
+
function partitionBootOrphans(jobs, isAlive = claudePidAlive) {
|
|
1109
|
+
const immediate = [];
|
|
1110
|
+
const deferred = [];
|
|
1111
|
+
for (const j of jobs) {
|
|
1112
|
+
if (j.status !== 'running') continue;
|
|
1113
|
+
const pid = j.runtime?.pid;
|
|
1114
|
+
if (pid && isAlive(pid)) {
|
|
1115
|
+
deferred.push(j.slug);
|
|
1116
|
+
} else {
|
|
1117
|
+
immediate.push(j.slug);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return { immediate, deferred };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* applyOrphanOutcome(job, outcome, killNote?) → void
|
|
1125
|
+
*
|
|
1126
|
+
* Mutates `job` in place to finalize a boot-orphaned 'running' job given its
|
|
1127
|
+
* classified run outcome: success/failed finalize terminally; no_result/unknown
|
|
1128
|
+
* re-queues to pending bounded by ORPHAN_REQUEUE_CAP. The status-mutation
|
|
1129
|
+
* semantics (and the cap-exhaustion boundary) match the now-deleted
|
|
1130
|
+
* reconcileQueueOffline (scripts/lib/watchdogHelpers.cjs) verbatim; killNote
|
|
1131
|
+
* plumbing differs slightly (see call sites) since this path always knows
|
|
1132
|
+
* pid liveness up front rather than re-checking per tick.
|
|
1133
|
+
*/
|
|
1134
|
+
function applyOrphanOutcome(job, outcome, killNote = '') {
|
|
1135
|
+
const now = new Date().toISOString();
|
|
1136
|
+
if (outcome === 'success') {
|
|
1137
|
+
job.status = 'completed';
|
|
1138
|
+
job.exitCode = 0;
|
|
1139
|
+
job.error = null;
|
|
1140
|
+
job.finishedAt = now;
|
|
1141
|
+
delete job.runtime;
|
|
1142
|
+
} else if (outcome === 'failed') {
|
|
1143
|
+
job.status = 'failed';
|
|
1144
|
+
job.exitCode = job.exitCode ?? 1;
|
|
1145
|
+
job.error = `orphaned: app restarted while running${killNote}`;
|
|
1146
|
+
job.finishedAt = now;
|
|
1147
|
+
delete job.runtime;
|
|
1148
|
+
} else {
|
|
1149
|
+
const tries = job.orphanRetries ?? 0;
|
|
1150
|
+
if (tries < ORPHAN_REQUEUE_CAP) {
|
|
1151
|
+
resetJobFields(job, `orphaned: app restarted mid-run, re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})${killNote}`);
|
|
1152
|
+
job.orphanRetries = tries + 1;
|
|
1153
|
+
} else {
|
|
1154
|
+
job.status = 'failed';
|
|
1155
|
+
job.exitCode = job.exitCode ?? 1;
|
|
1156
|
+
job.error = `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}`;
|
|
1157
|
+
job.finishedAt = now;
|
|
1158
|
+
delete job.runtime;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1062
1163
|
/** Scan the tail of a job's log for the canonical rate-limit signal. We look
|
|
1063
1164
|
* at the last 16 KB — final result event always lands at the end.
|
|
1064
1165
|
* Uses readTail() so no raw fd lifecycle is needed here. */
|
|
@@ -1730,7 +1831,13 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1730
1831
|
// Used by the sentinel override: SCHEDULER_VERDICT: PASS + a landed
|
|
1731
1832
|
// commit together override incidental transcript noise verdicts.
|
|
1732
1833
|
const headAtExit = await gitHead(guardCwd);
|
|
1733
|
-
const committedDuringRun =
|
|
1834
|
+
const committedDuringRun = await computeCommittedDuringRun(
|
|
1835
|
+
guardCwd,
|
|
1836
|
+
guardHeadBefore,
|
|
1837
|
+
headAtExit,
|
|
1838
|
+
job.startedAt,
|
|
1839
|
+
new Date().toISOString(),
|
|
1840
|
+
);
|
|
1734
1841
|
|
|
1735
1842
|
const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
|
|
1736
1843
|
const stateForDeps = await readQueue();
|
|
@@ -2944,65 +3051,60 @@ async function init() {
|
|
|
2944
3051
|
// classifyRunOutcome calls readTail → fs.readFileSync (up to 64 KB per job).
|
|
2945
3052
|
// Pre-compute all outcomes BEFORE entering the mutate lock so the blocking I/O
|
|
2946
3053
|
// does not stall the event loop or hold the mutateTail chain during startup.
|
|
3054
|
+
//
|
|
3055
|
+
// Jobs whose recorded pid is still alive are deferred (not classified here) —
|
|
3056
|
+
// see partitionBootOrphans. Everything else (dead pid or no pid) is safe to
|
|
3057
|
+
// classify immediately below.
|
|
2947
3058
|
const bootSnap = readQueueSync();
|
|
3059
|
+
const { immediate: immediateSlugs, deferred: deferredSlugs } = partitionBootOrphans(bootSnap.jobs);
|
|
2948
3060
|
const bootOutcomes = new Map();
|
|
2949
3061
|
for (const j of bootSnap.jobs) {
|
|
2950
|
-
if (j.
|
|
3062
|
+
if (!immediateSlugs.includes(j.slug)) continue;
|
|
2951
3063
|
const logPath = j.runId ? path.join(RUNS_DIR, j.runId, `${j.slug}.log`) : null;
|
|
2952
3064
|
bootOutcomes.set(j.slug, logPath ? classifyRunOutcome(logPath) : 'unknown');
|
|
2953
3065
|
}
|
|
2954
3066
|
await mutate((state) => {
|
|
2955
3067
|
for (const j of state.jobs) {
|
|
2956
|
-
if (j.status !== 'running') continue;
|
|
2957
|
-
const pid = j.runtime?.pid;
|
|
2958
|
-
let killNote = '';
|
|
2959
|
-
if (pid) {
|
|
2960
|
-
const result = killOrphanClaudePid(pid);
|
|
2961
|
-
killNote = ` (orphan pid=${pid}: ${result})`;
|
|
2962
|
-
if (result === 'killed') {
|
|
2963
|
-
console.log(`[scheduler] boot: SIGTERM'd orphan claude pid=${pid} for ${j.slug}`);
|
|
2964
|
-
}
|
|
2965
|
-
}
|
|
3068
|
+
if (j.status !== 'running' || !immediateSlugs.includes(j.slug)) continue;
|
|
2966
3069
|
const outcome = bootOutcomes.get(j.slug) ?? 'unknown';
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
j.error = null;
|
|
2972
|
-
j.finishedAt = new Date().toISOString();
|
|
2973
|
-
delete j.runtime;
|
|
2974
|
-
console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=success → completed`);
|
|
2975
|
-
} else if (outcome === 'failed') {
|
|
2976
|
-
// The log carries a real failure result event — a genuine failure, keep it.
|
|
2977
|
-
j.status = 'failed';
|
|
2978
|
-
j.exitCode = j.exitCode ?? 1;
|
|
2979
|
-
j.error = `orphaned: app restarted while running${killNote}`;
|
|
2980
|
-
j.finishedAt = new Date().toISOString();
|
|
2981
|
-
delete j.runtime;
|
|
2982
|
-
console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=failed → failed`);
|
|
2983
|
-
} else {
|
|
2984
|
-
// no_result / unknown: the run was interrupted (host died / app restarted)
|
|
2985
|
-
// with NO evidence it failed on its own merits. Punishing the PRD here is
|
|
2986
|
-
// the wrong call — it demands a manual flip and burns an Opus fix-plan on a
|
|
2987
|
-
// job that never actually failed. Re-queue it (bounded) so an app restart
|
|
2988
|
-
// self-recovers. Mirrors the transient-kill auto-retry on the live path.
|
|
2989
|
-
const tries = j.orphanRetries ?? 0;
|
|
2990
|
-
if (tries < ORPHAN_REQUEUE_CAP) {
|
|
2991
|
-
resetJobFields(j, `orphaned: app restarted mid-run, re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})${killNote}`);
|
|
2992
|
-
j.orphanRetries = tries + 1;
|
|
2993
|
-
console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=${outcome} → re-queued (${tries + 1}/${ORPHAN_REQUEUE_CAP})`);
|
|
2994
|
-
} else {
|
|
2995
|
-
j.status = 'failed';
|
|
2996
|
-
j.exitCode = j.exitCode ?? 1;
|
|
2997
|
-
j.error = `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}`;
|
|
2998
|
-
j.finishedAt = new Date().toISOString();
|
|
2999
|
-
delete j.runtime;
|
|
3000
|
-
console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=${outcome} → failed (orphan retries exhausted)`);
|
|
3001
|
-
}
|
|
3002
|
-
}
|
|
3070
|
+
const pid = j.runtime?.pid;
|
|
3071
|
+
const killNote = pid ? ` (orphan pid=${pid}: dead)` : '';
|
|
3072
|
+
applyOrphanOutcome(j, outcome, killNote);
|
|
3073
|
+
console.log(`[scheduler] boot reconcile: slug=${j.slug} outcome=${outcome} → status=${j.status}`);
|
|
3003
3074
|
}
|
|
3004
3075
|
});
|
|
3005
3076
|
|
|
3077
|
+
// Still-alive orphans: SIGTERM (+ killOrphanClaudePid's own deferred SIGKILL
|
|
3078
|
+
// follow-up) now, but classification waits until BOOT_ORPHAN_KILL_GRACE_MS
|
|
3079
|
+
// later — reading the log while the orphan might still be writing to it
|
|
3080
|
+
// could misclassify an about-to-succeed run as no_result and double-run the
|
|
3081
|
+
// same PRD (2026-05-21 incident this guard exists for).
|
|
3082
|
+
for (const slug of deferredSlugs) {
|
|
3083
|
+
const j = bootSnap.jobs.find((x) => x.slug === slug);
|
|
3084
|
+
const pid = j?.runtime?.pid;
|
|
3085
|
+
const bootRunId = j?.runId ?? null; // captured now — guards against reconciling a DIFFERENT later run of the same slug
|
|
3086
|
+
if (!pid) continue;
|
|
3087
|
+
const result = killOrphanClaudePid(pid);
|
|
3088
|
+
const killNote = ` (orphan pid=${pid}: ${result})`;
|
|
3089
|
+
if (result === 'killed') {
|
|
3090
|
+
console.log(`[scheduler] boot: SIGTERM'd orphan claude pid=${pid} for ${slug} — deferring finalize ${BOOT_ORPHAN_KILL_GRACE_MS}ms`);
|
|
3091
|
+
}
|
|
3092
|
+
setTimeout(() => {
|
|
3093
|
+
const logPath = j.runId ? path.join(RUNS_DIR, j.runId, `${j.slug}.log`) : null;
|
|
3094
|
+
const outcome = logPath ? classifyRunOutcome(logPath) : 'unknown';
|
|
3095
|
+
mutate((state) => {
|
|
3096
|
+
const cur = state.jobs.find((x) => x.slug === slug);
|
|
3097
|
+
// Race guard: bail if the job already resolved, OR if it's already been
|
|
3098
|
+
// re-picked into a NEW run (different runId) within the grace window —
|
|
3099
|
+
// that new run is not the boot orphan we SIGTERM'd and must not be
|
|
3100
|
+
// touched by this stale classification.
|
|
3101
|
+
if (!cur || cur.status !== 'running' || cur.runId !== bootRunId) return;
|
|
3102
|
+
applyOrphanOutcome(cur, outcome, killNote);
|
|
3103
|
+
console.log(`[scheduler] boot reconcile (deferred): slug=${slug} outcome=${outcome} → status=${cur.status}`);
|
|
3104
|
+
}).catch((e) => console.error(`[scheduler] deferred boot reconcile failed for ${slug}:`, e?.message));
|
|
3105
|
+
}, BOOT_ORPHAN_KILL_GRACE_MS).unref?.();
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3006
3108
|
// If we boot up while paused with a resumeAt in the past, clear it. This
|
|
3007
3109
|
// happens when the app was closed across the reset window.
|
|
3008
3110
|
const boot = await readQueue();
|
|
@@ -3074,6 +3176,16 @@ async function init() {
|
|
|
3074
3176
|
utilization: cachedUtilization,
|
|
3075
3177
|
consecutiveFailures,
|
|
3076
3178
|
});
|
|
3179
|
+
|
|
3180
|
+
feedbackSweepTickCount++;
|
|
3181
|
+
if (feedbackSweepDue(feedbackSweepTickCount, FEEDBACK_SWEEP_TICK_INTERVAL)) {
|
|
3182
|
+
feedbackSweepTickCount = 0;
|
|
3183
|
+
try {
|
|
3184
|
+
sweepFeedback();
|
|
3185
|
+
} catch (e) {
|
|
3186
|
+
console.warn('[scheduler] feedback sweep failed', e?.message);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3077
3189
|
}, 60_000);
|
|
3078
3190
|
if (heartbeatInterval.unref) heartbeatInterval.unref();
|
|
3079
3191
|
|
|
@@ -3188,7 +3300,7 @@ const remote = {
|
|
|
3188
3300
|
},
|
|
3189
3301
|
|
|
3190
3302
|
// Exposes the module-level allocateParallelGroup (PRD 548) to callers that
|
|
3191
|
-
// only hold the `remote` object (
|
|
3303
|
+
// only hold the `remote` object (lib/prdCreate.cjs's create-prd route) —
|
|
3192
3304
|
// reuses the same allocator the file-based /develop authoring path relies
|
|
3193
3305
|
// on implicitly, rather than re-deriving NN here.
|
|
3194
3306
|
allocateParallelGroup,
|
|
@@ -3216,4 +3328,36 @@ const remote = {
|
|
|
3216
3328
|
},
|
|
3217
3329
|
};
|
|
3218
3330
|
|
|
3219
|
-
|
|
3331
|
+
// Registers the two job-management admin HTTP routes (PRD 689 — moved
|
|
3332
|
+
// verbatim out of the former standalone admin HTTP server module's
|
|
3333
|
+
// handleRequest, no behavior change) against an injected localAdminHttp.cjs
|
|
3334
|
+
// transport. `remoteObj` is accepted as a parameter (defaults to this
|
|
3335
|
+
// module's own `remote`) so the route logic stays testable in isolation
|
|
3336
|
+
// without booting Electron, matching that former module's original
|
|
3337
|
+
// dependency-injection pattern.
|
|
3338
|
+
function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
3339
|
+
adminHttp.registerRoute('GET', '/admin/scheduler/jobs', async (req, res) => {
|
|
3340
|
+
const jobs = await remoteObj.listJobs();
|
|
3341
|
+
sendJson(res, 200, jobs);
|
|
3342
|
+
});
|
|
3343
|
+
|
|
3344
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/reset-job', async (req, res) => {
|
|
3345
|
+
const raw = await readBody(req);
|
|
3346
|
+
let parsed;
|
|
3347
|
+
try {
|
|
3348
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
3349
|
+
} catch {
|
|
3350
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
const slug = typeof parsed.slug === 'string' ? parsed.slug : null;
|
|
3354
|
+
if (!slug) {
|
|
3355
|
+
sendJson(res, 400, { ok: false, error: 'missing slug' });
|
|
3356
|
+
return;
|
|
3357
|
+
}
|
|
3358
|
+
const result = await remoteObj.resetJob(slug);
|
|
3359
|
+
sendJson(res, 200, result);
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes };
|
|
@@ -328,7 +328,7 @@ loop but had no documented programmatic queueing path. This section is that path
|
|
|
328
328
|
### The `scheduler_create_prd` MCP tool
|
|
329
329
|
|
|
330
330
|
Wraps `POST /admin/scheduler/create-prd` on the loopback admin API
|
|
331
|
-
(`src/main/
|
|
331
|
+
(`src/main/lib/localAdminHttp.cjs` + `src/main/lib/prdCreate.cjs`, PRD 549/688) via `scripts/scheduler-mcp-server.cjs`, registered in this
|
|
332
332
|
repo's `.mcp.json` as the `session-manager-scheduler` MCP server. An external project wanting to
|
|
333
333
|
call it from its own automation needs the equivalent MCP server registration pointing at this
|
|
334
334
|
repo's `scripts/scheduler-mcp-server.cjs`, or can call the admin HTTP route directly (same
|
|
@@ -348,7 +348,7 @@ request/response shape) using the token at `~/.claude/session-manager/admin-api.
|
|
|
348
348
|
| `slug` | string | no | kebab-case; derived from `title` if omitted |
|
|
349
349
|
| `parallelGroup` | number | no | opt into an existing `NN` group instead of allocating a new one |
|
|
350
350
|
|
|
351
|
-
**Return** (`{nn, filename, status}`, per `
|
|
351
|
+
**Return** (`{nn, filename, status}`, per `prdCreate.cjs`'s registerAdminRoute):
|
|
352
352
|
```json
|
|
353
353
|
{ "nn": 550, "filename": "550-my-feature.md", "status": "queued" }
|
|
354
354
|
```
|
|
@@ -380,9 +380,9 @@ produces by hand.
|
|
|
380
380
|
### The app-must-be-running caveat (read this first)
|
|
381
381
|
|
|
382
382
|
**`scheduler_create_prd` only works while the session-manager Electron app is running on this
|
|
383
|
-
machine.** The admin server it depends on (`src/main/
|
|
383
|
+
machine.** The admin server it depends on (`src/main/lib/localAdminHttp.cjs`) is hosted *inside* the
|
|
384
384
|
Electron process — it binds a loopback port and writes its token to
|
|
385
|
-
`~/.claude/session-manager/admin-api.json` on app boot (see `CLAUDE.md`'s `
|
|
385
|
+
`~/.claude/session-manager/admin-api.json` on app boot (see `CLAUDE.md`'s `localAdminHttp.cjs`
|
|
386
386
|
architecture entry) and stops existing the moment the app quits — it is not a standalone daemon.
|
|
387
387
|
If the app is closed, `scheduler-mcp-server.cjs` cannot read a live port/token and every call
|
|
388
388
|
returns the error `session-manager app is not running (admin API unreachable) — start it first`.
|