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.
Files changed (38) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
  5. package/dist/index.html +2 -2
  6. package/package.json +2 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
  9. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  10. package/scripts/lib/activeSessions.cjs +117 -0
  11. package/scripts/lib/watchdogHelpers.cjs +829 -0
  12. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  13. package/src/main/__tests__/docEdit.test.cjs +164 -2
  14. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  15. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  16. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  17. package/src/main/browserAgentServer.cjs +11 -10
  18. package/src/main/chatRunner.cjs +21 -2
  19. package/src/main/docEdit.cjs +125 -5
  20. package/src/main/health.cjs +15 -0
  21. package/src/main/index.cjs +12 -6
  22. package/src/main/ipcSchemas.cjs +14 -0
  23. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  24. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  25. package/src/main/lib/localAdminHttp.cjs +157 -0
  26. package/src/main/lib/personaImportHealth.cjs +161 -0
  27. package/src/main/lib/prdCreate.cjs +107 -17
  28. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  29. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  30. package/src/main/scheduler.cjs +198 -54
  31. package/src/main/templates/PRD_AUTHORING.md +4 -4
  32. package/src/main/transcripts.cjs +1 -85
  33. package/src/preload/api.d.ts +12 -1
  34. package/src/preload/index.cjs +6 -0
  35. package/dist/assets/index-CTTjT08J.css +0 -32
  36. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  37. package/src/main/__tests__/adminServer.test.cjs +0 -380
  38. package/src/main/adminServer.cjs +0 -242
@@ -1,22 +1,28 @@
1
1
  /**
2
- * prdCreate.cjs — PRD-body builder for the create-prd admin route (PRD 549,
3
- * gh-issue-6). Pure functions only: no filesystem writes, no NN allocation,
4
- * no HTTP. adminServer.cjs owns orchestration (auth, cwd validation via
5
- * config.cjs's validatePath, NN allocation via the injected remote,
6
- * writing via remote.writePrd -> config.cjs's writeTextAtomic) so this
7
- * module stays trivially unit-testable.
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
- * Standards are read fresh from disk on every call (no in-process caching)
10
- * so a live edit to standards.md is picked up by the next create-prd call
11
- * without an app restart same one-concept-one-implementation reasoning
12
- * that keeps the /develop skill re-reading it fresh per PRD (see SKILL.md).
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
- * verbatim engineering standards), matching the structure `/develop`'s
38
- * SKILL.md documents: frontmatter, then Goal / Acceptance criteria /
39
- * Implementation notes / Out of scope / Engineering standards, in order.
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, standardsText) {
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', '', standardsText.trimEnd(), '',
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 external
42
- // watchdog's auto-PRD intake (scripts/scheduler-watchdog.cjs `sweep()`) — it
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 };
@@ -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 to derive
207
- // committedDuringRun from the recorded run window — the live commit-guard uses
208
- // gitHead() instead. Never throws; git-unavailable → false (no override, job
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 = !!(guardHeadBefore && headAtExit && guardHeadBefore !== headAtExit);
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.status !== 'running') continue;
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
- if (outcome === 'success') {
2968
- // Job finished cleanly before the crash keep the win.
2969
- j.status = 'completed';
2970
- j.exitCode = 0;
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 (adminServer.cjs's create-prd route) —
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
- 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, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload };
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/adminServer.cjs`, PRD 549) via `scripts/scheduler-mcp-server.cjs`, registered in this
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 `adminServer.cjs`):
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/adminServer.cjs`) is hosted *inside* the
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 `adminServer.cjs`
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`.