claude-code-session-manager 0.35.16 → 0.35.18

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 (37) hide show
  1. package/dist/assets/{TiptapBody-BD2BTBY-.js → TiptapBody-DgFO_m3g.js} +1 -1
  2. package/dist/assets/index-4dJkn9nT.js +3559 -0
  3. package/dist/assets/index-DX2w2YhJ.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/main/__tests__/adminServer.test.cjs +205 -0
  7. package/src/main/__tests__/chat-queue.test.cjs +27 -0
  8. package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
  9. package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
  10. package/src/main/__tests__/prdCreate.test.cjs +82 -0
  11. package/src/main/__tests__/pty-write-result.test.cjs +46 -0
  12. package/src/main/__tests__/runVerify.test.cjs +146 -0
  13. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
  14. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
  15. package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
  16. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +181 -0
  17. package/src/main/adminServer.cjs +87 -2
  18. package/src/main/browserCapture.cjs +21 -7
  19. package/src/main/chatRunner.cjs +5 -1
  20. package/src/main/health.cjs +114 -3
  21. package/src/main/hives.cjs +2 -1
  22. package/src/main/ipcSchemas.cjs +33 -4
  23. package/src/main/lib/kebabCase.cjs +12 -0
  24. package/src/main/lib/memorySlug.cjs +10 -0
  25. package/src/main/lib/prdCreate.cjs +73 -0
  26. package/src/main/memoryAggregate.cjs +1 -1
  27. package/src/main/memoryTool.cjs +2 -1
  28. package/src/main/pty.cjs +7 -6
  29. package/src/main/runVerify.cjs +119 -6
  30. package/src/main/scheduler/prdParser.cjs +88 -0
  31. package/src/main/scheduler.cjs +176 -9
  32. package/src/main/templates/PRD_AUTHORING.md +126 -0
  33. package/src/main/usage.cjs +4 -0
  34. package/src/main/webRemote.cjs +70 -24
  35. package/src/preload/api.d.ts +1 -0
  36. package/dist/assets/index-BwFHVuSk.css +0 -32
  37. package/dist/assets/index-C6ep3yfh.js +0 -3559
@@ -0,0 +1,73 @@
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.
8
+ *
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).
13
+ */
14
+ 'use strict';
15
+
16
+ const fsp = require('node:fs/promises');
17
+ const path = require('node:path');
18
+ const { PRD_CREATE_SLUG_RE } = require('../ipcSchemas.cjs');
19
+ const { kebabCase } = require('./kebabCase.cjs');
20
+
21
+ const STANDARDS_PATH = path.join(
22
+ __dirname, '..', '..', '..',
23
+ 'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md',
24
+ );
25
+
26
+ async function readStandards() {
27
+ return fsp.readFile(STANDARDS_PATH, 'utf8');
28
+ }
29
+
30
+ /** Lowercase, kebab-case, strip anything outside [a-z0-9-], cap at 60 chars. */
31
+ function deriveSlugFromTitle(title) {
32
+ return kebabCase(String(title), { maxLen: 60 });
33
+ }
34
+
35
+ /**
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.
40
+ */
41
+ function buildPrdBody(input, standardsText) {
42
+ const {
43
+ title, cwd, estimateMinutes, goal, acceptanceCriteria,
44
+ implementationNotes, outOfScope,
45
+ } = input;
46
+
47
+ // No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
48
+ // filename prefix is the single source of truth for grouping; adding a
49
+ // second one here would let the two drift out of sync.
50
+ const fmLines = ['---', `title: ${title}`, `cwd: ${cwd}`, `estimateMinutes: ${estimateMinutes}`, '---', ''];
51
+
52
+ const acLines = acceptanceCriteria.map((line) => `- [ ] ${line}`).join('\n');
53
+ const oosSource = outOfScope && outOfScope.length ? outOfScope : ['(none)'];
54
+ const oosLines = oosSource.map((line) => `- ${line}`).join('\n');
55
+
56
+ const bodyLines = [
57
+ '# Goal', '', goal, '',
58
+ '# Acceptance criteria', '', acLines, '',
59
+ '# Implementation notes', '', implementationNotes, '',
60
+ '# Out of scope', '', oosLines, '',
61
+ '## Engineering standards', '', standardsText.trimEnd(), '',
62
+ ];
63
+
64
+ return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
65
+ }
66
+
67
+ module.exports = {
68
+ PRD_CREATE_SLUG_RE,
69
+ STANDARDS_PATH,
70
+ readStandards,
71
+ deriveSlugFromTitle,
72
+ buildPrdBody,
73
+ };
@@ -27,10 +27,10 @@ const { encodeCwd } = require('./lib/encodeCwd.cjs');
27
27
  const { extractJson } = require('./lib/extractJson.cjs');
28
28
  const { writeJson } = require('./config.cjs');
29
29
  const config = require('./config.cjs');
30
+ const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
30
31
 
31
32
  const HOME = os.homedir();
32
33
  const CLUSTERS_DIR = path.join(HOME, '.claude', 'session-manager', 'memory-clusters');
33
- const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
34
34
 
35
35
  function memoryDir(workspace) {
36
36
  return path.join(HOME, '.claude', 'projects', workspace, 'memory');
@@ -30,9 +30,10 @@ const path = require('node:path');
30
30
  const os = require('node:os');
31
31
  const config = require('./config.cjs');
32
32
 
33
+ const { MEMORY_SLUG_RE: SLUG_RE } = require('./lib/memorySlug.cjs');
34
+
33
35
  const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
34
36
  const MAX_ENTRIES = 1000;
35
- const SLUG_RE = /^[a-z0-9-_]+\.md$/;
36
37
 
37
38
  const { encodeCwd: encodeWorkspace } = require('./lib/encodeCwd.cjs');
38
39
 
package/src/main/pty.cjs CHANGED
@@ -188,19 +188,19 @@ class PtyManager {
188
188
  // Tab was removed or never existed — tell the renderer so it can surface
189
189
  // "skipped" feedback rather than silently dropping the write.
190
190
  sendIfAlive(this.window, 'pty:write-error', { tabId, reason: 'no-pty' });
191
- return;
191
+ return { ok: false, reason: 'no-pty' };
192
192
  }
193
193
  try {
194
194
  s.proc.write(data);
195
+ return { ok: true };
195
196
  } catch (err) {
196
197
  // node-pty throws synchronously (or the underlying net.Socket emits an
197
198
  // error that node-pty re-throws) when writing to an exited process.
198
199
  // Catch here so the uncaught-exception handler never sees it, and notify
199
200
  // the renderer to surface "skipped" feedback.
200
- sendIfAlive(this.window, 'pty:write-error', {
201
- tabId,
202
- reason: String(err?.message || 'write-failed'),
203
- });
201
+ const reason = String(err?.message || 'write-failed');
202
+ sendIfAlive(this.window, 'pty:write-error', { tabId, reason });
203
+ return { ok: false, reason };
204
204
  }
205
205
  }
206
206
 
@@ -245,8 +245,9 @@ function registerPtyHandlers() {
245
245
  ipcMain.on('pty:resize', (_e, payload) => { try { manager.resize(s.ptyResize.parse(payload)); } catch { /* ignore */ } });
246
246
  ipcMain.on('pty:kill', (_e, tabId) => {
247
247
  if (typeof tabId !== 'string') return;
248
+ // manager.kill() already drops superagent run state for tabId — see kill()
249
+ // above. Do not duplicate that call here.
248
250
  manager.kill(tabId);
249
- try { require('./superagent.cjs').dropTab(tabId); } catch { /* superagent module not initialized (e2e) */ }
250
251
  });
251
252
  }
252
253
 
@@ -36,9 +36,13 @@
36
36
 
37
37
  const fs = require('node:fs');
38
38
  const path = require('node:path');
39
+ const { execFileSync } = require('node:child_process');
39
40
 
40
41
  const VERDICTS_SCHEMA_VERSION = 1;
41
42
 
43
+ // Bound on the `gh pr view` postcondition check below (§ merge-main exemption).
44
+ const GH_CHECK_TIMEOUT_MS = 15_000;
45
+
42
46
  // ─── content pattern detectors ───────────────────────────────────────────────
43
47
 
44
48
  /**
@@ -445,6 +449,69 @@ function scanSentinel(resultEvent, events) {
445
449
  return null;
446
450
  }
447
451
 
452
+ // ─── merge-main postcondition exemption ──────────────────────────────────────
453
+
454
+ /**
455
+ * True for the `NN-prXXX-merge-main` / `NN-fix-prXXX-merge-main` slug
456
+ * convention already used dozens of times in this queue (merge-current-main
457
+ * PRDs targeting a shared repo's PR branch). Narrow, mechanical string check —
458
+ * this is the only slug shape exempted from the `pass_no_commit` flag below.
459
+ */
460
+ function isMergeMainSlug(slug) {
461
+ return typeof slug === 'string' && /-merge-main$/.test(slug);
462
+ }
463
+
464
+ /**
465
+ * Extract the target PR number for a `-merge-main` PRD. The convention embeds
466
+ * it two ways: in the frontmatter title/body as "PR #<n>" (preferred — the
467
+ * human-authored, unambiguous source), and in the slug itself as "prNNN"
468
+ * (fallback, for PRD text that doesn't spell it out). Returns null if neither
469
+ * source yields a number.
470
+ */
471
+ function extractMergeMainPrNumber(slug, prdFullText) {
472
+ if (typeof prdFullText === 'string') {
473
+ const m = prdFullText.match(/PR\s*#(\d+)/i);
474
+ if (m) return parseInt(m[1], 10);
475
+ }
476
+ const sm = typeof slug === 'string' ? slug.match(/pr(\d+)-merge-main$/) : null;
477
+ if (sm) return parseInt(sm[1], 10);
478
+ return null;
479
+ }
480
+
481
+ /**
482
+ * Independently re-check a PR's mergeable state via `gh pr view`. Used only to
483
+ * decide whether a `-merge-main` PRD's unsubstantiated PASS (no commit landed
484
+ * during the run) reflects a target that was already satisfied by an
485
+ * out-of-band actor before this run started — see the 2026-07-18 false-positive
486
+ * feedback item.
487
+ *
488
+ * Bounded (15s) and fully fail-safe: ANY error (gh missing/unauthed, network,
489
+ * timeout, malformed JSON, non-existent PR) resolves `{ ok: false }` rather
490
+ * than throwing, so a failure here only ever falls back to today's behavior —
491
+ * it can never turn a real failure into a false "verified".
492
+ *
493
+ * `execImpl` is injectable (defaults to `child_process.execFileSync`) so unit
494
+ * tests can stub the subprocess call without shelling out to a real `gh`.
495
+ *
496
+ * @returns {{ ok: boolean, data?: { mergeable: string, mergeStateStatus: string }, error?: string }}
497
+ */
498
+ function checkMergeablePr({ cwd, prNumber, timeoutMs = GH_CHECK_TIMEOUT_MS, execImpl = execFileSync }) {
499
+ try {
500
+ const out = execImpl(
501
+ 'gh',
502
+ ['pr', 'view', String(prNumber), '--json', 'mergeable,mergeStateStatus'],
503
+ { cwd, timeout: timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
504
+ );
505
+ const data = JSON.parse(out);
506
+ if (data && data.mergeable === 'MERGEABLE' && data.mergeStateStatus !== 'CONFLICTING') {
507
+ return { ok: true, data };
508
+ }
509
+ return { ok: false, data };
510
+ } catch (e) {
511
+ return { ok: false, error: e?.message ?? String(e) };
512
+ }
513
+ }
514
+
448
515
  // ─── main verifier ────────────────────────────────────────────────────────────
449
516
 
450
517
  /**
@@ -467,9 +534,13 @@ function scanSentinel(resultEvent, events) {
467
534
  * even without a PASS sentinel. Only
468
535
  * set by the boot reverify self-heal
469
536
  * pass for pre-sentinel legacy runs.
537
+ * @param {Function} [params.ghExecImpl] Test-only override for the `gh pr view`
538
+ * subprocess call used by the -merge-main
539
+ * postcondition exemption. Defaults to
540
+ * child_process.execFileSync.
470
541
  * @returns {Promise<{verdict:string, reason:string, downgradeTo:string|null}>}
471
542
  */
472
- async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false }) {
543
+ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false, ghExecImpl }) {
473
544
  const { slug } = queueEntry;
474
545
  const logPath = path.join(runDir, `${slug}.log`);
475
546
  const verdictsPath = path.join(runDir, `${slug}.verdicts.json`);
@@ -491,8 +562,10 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
491
562
  try {
492
563
  // ── Read PRD body (strip frontmatter) ──────────────────────────────────
493
564
  let prdBody = '';
565
+ let prdFullText = '';
494
566
  try {
495
567
  const prdText = fs.readFileSync(prdPath, 'utf8');
568
+ prdFullText = prdText;
496
569
  if (prdText.startsWith('---\n')) {
497
570
  const end = prdText.indexOf('\n---', 4);
498
571
  prdBody = end !== -1 ? prdText.slice(end + 4).trim() : prdText;
@@ -650,11 +723,48 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
650
723
  // signal and stays covered by this check.
651
724
  const isFixPlanJob = /^\d+-fix-/.test(queueEntry?.slug || '');
652
725
  if (sentinel === 'pass' && !committedDuringRun && !isFixPlanJob) {
653
- issues.push({
654
- verdict: 'pass_no_commit',
655
- reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
656
- priority: 1,
657
- });
726
+ // EXEMPTION: `-merge-main` PRDs (see isMergeMainSlug) can genuinely and
727
+ // correctly find nothing left to do when an out-of-band actor (a human,
728
+ // another agent, a sibling scheduler job) already merged/updated the
729
+ // target PR branch before this run started. Before flagging, independently
730
+ // re-check the PR's real mergeable state via `gh` — if it confirms the
731
+ // target is already clean, this is not a false PASS, it's a correct one.
732
+ // Fails safe: any `gh` error falls straight through to the pass_no_commit
733
+ // flag below, exactly as before this exemption existed.
734
+ let mergeMainVerified = false;
735
+ if (isMergeMainSlug(slug)) {
736
+ const prNumber = extractMergeMainPrNumber(slug, prdFullText);
737
+ if (prNumber != null) {
738
+ const ghResult = checkMergeablePr({
739
+ cwd: queueEntry?.cwd,
740
+ prNumber,
741
+ ...(ghExecImpl ? { execImpl: ghExecImpl } : {}),
742
+ });
743
+ if (ghResult.ok) {
744
+ mergeMainVerified = true;
745
+ return conclude(
746
+ 'pass_no_commit_target_verified',
747
+ `SCHEDULER_VERDICT: PASS with no commit, but independently verified PR #${prNumber} is mergeable `
748
+ + `(mergeStateStatus: ${ghResult.data.mergeStateStatus}) — target was already satisfied by an `
749
+ + 'out-of-band actor before this run started',
750
+ null,
751
+ {
752
+ ...(annotations.length ? { annotations } : {}),
753
+ sentinel,
754
+ verifiedPrNumber: prNumber,
755
+ ghMergeState: ghResult.data,
756
+ },
757
+ );
758
+ }
759
+ }
760
+ }
761
+ if (!mergeMainVerified) {
762
+ issues.push({
763
+ verdict: 'pass_no_commit',
764
+ reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
765
+ priority: 1,
766
+ });
767
+ }
658
768
  }
659
769
 
660
770
  if (issues.length === 0) {
@@ -726,4 +836,7 @@ module.exports = {
726
836
  checkDeps,
727
837
  parseLog,
728
838
  scanSentinel,
839
+ isMergeMainSlug,
840
+ extractMergeMainPrNumber,
841
+ checkMergeablePr,
729
842
  };
@@ -141,10 +141,98 @@ function _resetCache() {
141
141
  prdFileCache.clear();
142
142
  }
143
143
 
144
+ // ────────────────────────────────────────────── group allocation
145
+ //
146
+ // `NN` (the filename prefix, parsed above by groupFromName) is both the
147
+ // authoring counter AND the parallel-group key the scheduler groups by.
148
+ // Two authors computing "max NN in prds/" concurrently can allocate the
149
+ // same NN for unrelated PRDs, which silently merges two projects' work
150
+ // into one parallel group (incident 2026-07-14/2026-07-15, PRDs 05-11 vs
151
+ // 06-09, then 545 collided again). allocateParallelGroup() closes that
152
+ // race with an O(n) full-directory scan (not a caller-narrowed glob — see
153
+ // PRD_AUTHORING.md's `'^10[0-9]'` cautionary example) plus an O_EXCL
154
+ // reservation file, so two concurrent callers can never walk away with
155
+ // the same new NN.
156
+ //
157
+ // A reservation is a zero-byte `.reserved-<NN>` file created with the
158
+ // 'wx' (O_CREAT|O_EXCL) flag — the OS guarantees only one of two racing
159
+ // `open()` calls for the same path succeeds. Reservations are counted
160
+ // alongside real `NN-slug.md` files when computing the next max, so a
161
+ // crash between "reserve NN" and "author NN-slug.md" just permanently
162
+ // retires that one NN rather than wedging future allocations. This keeps
163
+ // the filesystem itself as the single source of truth (no separate
164
+ // `.next-nn`/registry file that could drift out of sync with prds/).
165
+ //
166
+ // Callers who want an EXISTING group (deliberate parallel siblings, e.g.
167
+ // three PRDs sharing 545) never call this — they just write the shared
168
+ // `NN-` prefix directly, same as today. This function only mints NEW
169
+ // group numbers.
170
+
171
+ const RESERVATION_RE = /^\.reserved-(\d+)$/;
172
+ const GROUP_PREFIX_RE = /^(\d+)-/;
173
+ const MAX_RESERVE_ATTEMPTS = 1000;
174
+
175
+ /**
176
+ * Highest `NN` currently in use under prdsDir, across real `NN-slug.md`
177
+ * PRD files (any digit count, unpadded or not — matches groupFromName
178
+ * above) AND in-flight `.reserved-NN` markers. Full directory scan, no
179
+ * narrowed glob. Returns 0 if the directory is empty/missing.
180
+ */
181
+ async function maxParallelGroupInUse(prdsDir) {
182
+ let entries;
183
+ try {
184
+ entries = await fsp.readdir(prdsDir);
185
+ } catch {
186
+ return 0;
187
+ }
188
+ let max = 0;
189
+ for (const name of entries) {
190
+ if (name.startsWith('.')) {
191
+ const rm = name.match(RESERVATION_RE);
192
+ if (rm) max = Math.max(max, Number(rm[1]));
193
+ continue;
194
+ }
195
+ if (!name.endsWith('.md')) continue;
196
+ const m = name.match(GROUP_PREFIX_RE);
197
+ if (m) max = Math.max(max, Number(m[1]));
198
+ }
199
+ return max;
200
+ }
201
+
202
+ /**
203
+ * Atomically allocate a brand-new parallel-group number under prdsDir.
204
+ * Returns the reserved NN. Reservation survives a crash mid-allocation
205
+ * (the `.reserved-NN` marker just sits there forever, retiring that one
206
+ * number) and never wedges future callers, since each attempt only needs
207
+ * the marker for its OWN candidate to not already exist.
208
+ */
209
+ async function allocateParallelGroup(prdsDir) {
210
+ await fsp.mkdir(prdsDir, { recursive: true });
211
+ let candidate = (await maxParallelGroupInUse(prdsDir)) + 1;
212
+ for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
213
+ const markerPath = path.join(prdsDir, `.reserved-${candidate}`);
214
+ let fh;
215
+ try {
216
+ fh = await fsp.open(markerPath, 'wx');
217
+ } catch (e) {
218
+ if (e && e.code === 'EEXIST') {
219
+ candidate += 1;
220
+ continue;
221
+ }
222
+ throw e;
223
+ }
224
+ await fh.close();
225
+ return candidate;
226
+ }
227
+ throw new Error(`allocateParallelGroup: exhausted ${MAX_RESERVE_ATTEMPTS} reservation attempts starting at ${candidate}`);
228
+ }
229
+
144
230
  module.exports = {
145
231
  parsePrdRaw,
146
232
  parsePrd,
147
233
  listPrdFiles,
234
+ allocateParallelGroup,
235
+ maxParallelGroupInUse,
148
236
  PRD_READ_MAX_BYTES,
149
237
  _resetCache,
150
238
  };
@@ -500,6 +500,17 @@ async function listPrdFiles() {
500
500
  return prdParser.listPrdFiles(PRDS_DIR);
501
501
  }
502
502
 
503
+ /**
504
+ * Atomically mint a brand-new parallel-group NN for a PRD about to be
505
+ * authored under PRDS_DIR. See prdParser.allocateParallelGroup for the
506
+ * collision-proof mechanics. Callers wanting to join an EXISTING group
507
+ * (deliberate parallel siblings) skip this and just reuse the NN prefix.
508
+ */
509
+ async function allocateParallelGroup() {
510
+ ensureDirs();
511
+ return prdParser.allocateParallelGroup(PRDS_DIR);
512
+ }
513
+
503
514
  /**
504
515
  * Best-effort kill of a child claude PID that the previous app instance spawned
505
516
  * but never reaped. Used by init() to clean up the orphan tree on boot.
@@ -709,6 +720,37 @@ let cancelToken = { cancelled: false };
709
720
  // Last memory-gate observation; included in snapshot for renderer visibility.
710
721
  let lastMemGate = null;
711
722
 
723
+ /**
724
+ * Pure: applies clearPause()'s effect on the tick cancel-token.
725
+ *
726
+ * ROOT CAUSE (2026-07-14 stall, PRD 543/544): setPaused() cancels the
727
+ * in-flight tick batch by setting cancelToken.cancelled = true (e.g. when a
728
+ * job's run is rate-limited). That flag was previously only ever reset back
729
+ * to false inside runDueJobs() (used by the force-tick/run-now IPC handlers
730
+ * and the resume-timer callback). clearPause() itself — the function the
731
+ * poll-loop's auth/network auto-recovery and the manual "Resume" button
732
+ * actually call — never reset it. So once ANY pause fired and was later
733
+ * cleared through one of THOSE paths instead of runDueJobs(), cancelToken
734
+ * .cancelled stayed permanently true, and tickQueue()'s very first guard
735
+ * (`if (cancelToken.cancelled) return {fired:false, reason:'cancelled'}`)
736
+ * silently short-circuited every future tick — from spawnJob's own
737
+ * post-completion tick, from the when-available poll, and from the
738
+ * dead-process reaper — forever, even though queue.json's `paused` field
739
+ * was correctly null and every other gate (enabled, concurrency, memory)
740
+ * said go. This explains the full incident: lastRunAt froze at the last
741
+ * tick that got past the guard, new PRDs kept appearing as `pending`
742
+ * because reconcile() also runs independently from the `schedule:state` IPC
743
+ * read (unrelated to tickQueue), and only a manual force-tick (which goes
744
+ * through runDueJobs()) could ever unwedge it.
745
+ *
746
+ * Exported so this regression is unit-testable without touching the real
747
+ * scheduler's fs-backed queue.json.
748
+ */
749
+ function applyPauseCleared(wasPaused, token) {
750
+ if (wasPaused) token.cancelled = false;
751
+ return token;
752
+ }
753
+
712
754
  function attachWindow(w) { mainWindow = w; }
713
755
 
714
756
  /**
@@ -839,6 +881,8 @@ async function clearPause(source) {
839
881
  s.paused = null;
840
882
  return true;
841
883
  });
884
+ // Un-cancel the tick guard on every recovery path, not just runDueJobs().
885
+ applyPauseCleared(wasPaused, cancelToken);
842
886
  // Track manual clears for the auto-pause cooldown.
843
887
  if (source === 'manual' || source === 'run-now') {
844
888
  pauseClearedManuallyAt = Date.now();
@@ -883,6 +927,69 @@ function detectRateLimitInLog(logPath) {
883
927
  }
884
928
  }
885
929
 
930
+ /** Scan the tail of a job's log for a network-outage signal: the structured
931
+ * `terminal_reason":"api_error"` field alongside a network-class error
932
+ * string. This is NOT a real code defect — spawning an auto-fix
933
+ * investigation for it just burns a second run diagnosing an outage (PRD
934
+ * 543's ENOTFOUND incident, 2026-07-14). Deliberately narrow: only fires on
935
+ * the structured terminal_reason field, never on ad-hoc "error" text that
936
+ * legitimately appears in TDD red-phase transcripts. */
937
+ function detectNetworkErrorInLog(logPath) {
938
+ try {
939
+ const text = readTail(logPath, 16384);
940
+ if (!text) return false;
941
+ if (!/"terminal_reason":"api_error"/.test(text)) return false;
942
+ return /ENOTFOUND/.test(text)
943
+ || /ECONNREFUSED/.test(text)
944
+ || /ETIMEDOUT/.test(text)
945
+ || /EAI_AGAIN/.test(text)
946
+ || /network is unreachable/i.test(text);
947
+ } catch {
948
+ return false;
949
+ }
950
+ }
951
+
952
+ // Bounded retry cap for transient (signal-kill or network-outage) failures.
953
+ // A small constant, not a config knob: the point is that it is impossible to
954
+ // loop unboundedly (queueOps.cjs lints for unbounded loops; see the fizzpop
955
+ // poll-hang in PRD_AUTHORING.md for why an unbounded retry is a real hazard).
956
+ const TRANSIENT_RETRY_CAP = 2;
957
+
958
+ /**
959
+ * Classify a failed job's outcome as one of: retry it (transient, bounded),
960
+ * fail it without auto-fix (transient but unsafe to retry, or the retry cap
961
+ * is exhausted), or investigate it (a genuine code failure). Pure/no I/O so
962
+ * the transient-vs-terminal boundary can be unit-tested directly rather than
963
+ * only through a live spawnJob run.
964
+ *
965
+ * A 143/137 exit within the idle-watchdog window is a signal-kill transient
966
+ * (external kill, not a real failure — see the call site's long-form
967
+ * rationale). A `terminal_reason:"api_error"` + network-class error
968
+ * (ENOTFOUND etc., detected by detectNetworkErrorInLog) is an outage
969
+ * transient (PRD 543/545 incident) — same bounded-retry treatment, not a
970
+ * second parallel classifier.
971
+ */
972
+ function classifyFailureOutcome({ exitCode, networkError, durationMs, transientRetries, newlyDirtyCount }) {
973
+ const signalTransient = (exitCode === 143 || exitCode === 137) && durationMs < IDLE_OUTPUT_KILL_MS;
974
+ const networkTransient = networkError === true;
975
+ const transient = signalTransient || networkTransient;
976
+ if (!transient) return { action: 'investigate' };
977
+
978
+ const transientKind = networkTransient ? 'network' : `exit=${exitCode}`;
979
+ const retries = transientRetries ?? 0;
980
+ // A transient failure can still leave partial work on disk (the 543
981
+ // ENOTFOUND run wrote its renderer, then died before committing). Requeuing
982
+ // over that dirt would either re-implement on top of it or conflict with
983
+ // it — refuse and fail instead of silently re-running over orphaned edits.
984
+ if (newlyDirtyCount > 0) {
985
+ return { action: 'fail-dirty', transientKind, newlyDirtyCount };
986
+ }
987
+ if (retries >= TRANSIENT_RETRY_CAP) {
988
+ return { action: 'fail-cap', transientKind, retries };
989
+ }
990
+ return { action: 'retry', transientKind, retries };
991
+ }
992
+
886
993
  // ---------- execution ----------
887
994
 
888
995
  function pickRunDir() {
@@ -1119,14 +1226,15 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
1119
1226
  sl(`\n[scheduler] exit code=${effectiveCode} (raw code=${exitCode} signal=${signal}) ` +
1120
1227
  `duration=${Math.round(durationMs / 1000)}s\n`);
1121
1228
  const rateLimited = effectiveCode !== 0 && detectRateLimitInLog(logPath);
1229
+ const networkError = effectiveCode !== 0 && !rateLimited && detectNetworkErrorInLog(logPath);
1122
1230
  // Sync write: child 'exit' handler must flush meta before resolve()
1123
1231
  // so the spawnJob mutate() that follows sees the persisted exit code.
1124
1232
  config.writeJsonSync(metaPath, {
1125
- slug: job.slug, cwd, sessionId, exitCode: effectiveCode, rateLimited,
1233
+ slug: job.slug, cwd, sessionId, exitCode: effectiveCode, rateLimited, networkError,
1126
1234
  startedAt, finishedAt: Date.now(), durationMs,
1127
1235
  agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${exitCode}` : null,
1128
1236
  });
1129
- resolve({ exitCode: effectiveCode, durationMs, rateLimited, sessionId });
1237
+ resolve({ exitCode: effectiveCode, durationMs, rateLimited, networkError, sessionId });
1130
1238
  },
1131
1239
  });
1132
1240
 
@@ -1538,7 +1646,14 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1538
1646
  let effectiveStatus;
1539
1647
  if (res.exitCode !== 0) {
1540
1648
  effectiveStatus = 'failed';
1541
- } else if (!verifyResult || verifyResult.verdict === 'clean') {
1649
+ } else if (
1650
+ !verifyResult
1651
+ || verifyResult.verdict === 'clean'
1652
+ // pass_no_commit_target_verified: -merge-main postcondition exemption
1653
+ // (runVerify.cjs) — an independently gh-confirmed clean merge target,
1654
+ // not a plain unsubstantiated PASS. Completed, same as 'clean'.
1655
+ || verifyResult.verdict === 'pass_no_commit_target_verified'
1656
+ ) {
1542
1657
  effectiveStatus = 'completed';
1543
1658
  } else if (verifyResult.downgradeTo === 'pending') {
1544
1659
  // HALT or deps_unmet: reset to pending so the job re-fires.
@@ -1641,15 +1756,58 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1641
1756
  // 67 s.) Genuine watchdog kills (idle ≥20 min, deadman 4 h) run longer than
1642
1757
  // the threshold and still fall through to investigation.
1643
1758
  const ec = failedJobSnapshot.exitCode;
1644
- const transient = (ec === 143 || ec === 137) && res.durationMs < IDLE_OUTPUT_KILL_MS;
1645
1759
  const retries = failedJobSnapshot.transientRetries ?? 0;
1646
- if (transient && retries < 2) {
1647
- console.log(`[scheduler] transient failure (exit=${ec} dur=${res.durationMs}ms) auto-retry ${retries + 1}/2 for ${job.slug}`);
1760
+ // Only pay for the extra git status call when the failure is plausibly
1761
+ // transient a real code failure never needs the dirty-tree check.
1762
+ const maybeTransient = (ec === 143 || ec === 137) || res.networkError === true;
1763
+ let newlyDirtyCount = 0;
1764
+ let dirtySample = '';
1765
+ if (maybeTransient) {
1766
+ const afterFailure = await uncommittedChanges(guardCwd);
1767
+ const baseSet = new Set(guardBaseline || []);
1768
+ const newlyDirty = (afterFailure || []).filter((p) => !baseSet.has(p));
1769
+ newlyDirtyCount = newlyDirty.length;
1770
+ dirtySample = newlyDirty.slice(0, 3).join(', ');
1771
+ }
1772
+ const decision = classifyFailureOutcome({
1773
+ exitCode: ec,
1774
+ networkError: res.networkError,
1775
+ durationMs: res.durationMs,
1776
+ transientRetries: retries,
1777
+ newlyDirtyCount,
1778
+ });
1779
+
1780
+ if (decision.action === 'retry') {
1781
+ console.log(`[scheduler] transient failure (${decision.transientKind} dur=${res.durationMs}ms) — auto-retry ${decision.retries + 1}/${TRANSIENT_RETRY_CAP} for ${job.slug}`);
1648
1782
  await mutate((s) => {
1649
1783
  const i = s.jobs.findIndex((x) => x.slug === job.slug);
1650
1784
  if (i >= 0) {
1651
1785
  resetJobFields(s.jobs[i], null);
1652
- s.jobs[i].transientRetries = retries + 1;
1786
+ s.jobs[i].transientRetries = decision.retries + 1;
1787
+ }
1788
+ });
1789
+ await broadcast();
1790
+ } else if (decision.action === 'fail-dirty') {
1791
+ console.log(`[scheduler] transient failure (${decision.transientKind}) for ${job.slug} left ${newlyDirtyCount} uncommitted file(s) (e.g. ${dirtySample}) — not auto-requeuing`);
1792
+ await mutate((s) => {
1793
+ const i = s.jobs.findIndex((x) => x.slug === job.slug);
1794
+ if (i >= 0) {
1795
+ s.jobs[i].status = 'failed';
1796
+ s.jobs[i].error = `transient failure (${decision.transientKind}) left ${newlyDirtyCount} uncommitted file(s) in working tree (e.g. ${dirtySample}) — not auto-requeued to avoid overwriting partial work; review and commit or discard manually`;
1797
+ }
1798
+ });
1799
+ await broadcast();
1800
+ // No auto-fix investigation: this isn't a code defect, and the
1801
+ // dirty tree needs a human, not a diagnosis run.
1802
+ } else if (decision.action === 'fail-cap') {
1803
+ // Retry cap exhausted: fail terminally, but a transient classification
1804
+ // never spawns an auto-fix investigation — there is no code defect to
1805
+ // diagnose, only a recurring outage.
1806
+ console.log(`[scheduler] transient failure (${decision.transientKind}) for ${job.slug} exhausted retry cap (${decision.retries}/${TRANSIENT_RETRY_CAP}) — failing without auto-fix`);
1807
+ await mutate((s) => {
1808
+ const i = s.jobs.findIndex((x) => x.slug === job.slug);
1809
+ if (i >= 0) {
1810
+ s.jobs[i].error = `transient failure (${decision.transientKind}) exhausted retry cap (${decision.retries}/${TRANSIENT_RETRY_CAP}) — marking failed without auto-fix investigation`;
1653
1811
  }
1654
1812
  });
1655
1813
  await broadcast();
@@ -2184,7 +2342,10 @@ async function reverifyNeedsReview() {
2184
2342
  allowPreSentinelHeal: true,
2185
2343
  });
2186
2344
  } catch { leftForReview.push({ slug: job.slug, reason: 'verifyRun threw' }); continue; }
2187
- if (v && v.verdict === 'clean') {
2345
+ // pass_no_commit_target_verified: -merge-main postcondition exemption
2346
+ // (runVerify.cjs) — same "heal it" treatment as 'clean', see spawnJob's
2347
+ // effectiveStatus branch above for the primary-path equivalent.
2348
+ if (v && (v.verdict === 'clean' || v.verdict === 'pass_no_commit_target_verified')) {
2188
2349
  healed.push(job.slug);
2189
2350
  } else {
2190
2351
  leftForReview.push({ slug: job.slug, reason: v ? `${v.verdict}: ${v.reason}` : 'null verdict' });
@@ -2818,6 +2979,12 @@ const remote = {
2818
2979
  return state.jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
2819
2980
  },
2820
2981
 
2982
+ // Exposes the module-level allocateParallelGroup (PRD 548) to callers that
2983
+ // only hold the `remote` object (adminServer.cjs's create-prd route) —
2984
+ // reuses the same allocator the file-based /develop authoring path relies
2985
+ // on implicitly, rather than re-deriving NN here.
2986
+ allocateParallelGroup,
2987
+
2821
2988
  async runNow() {
2822
2989
  await clearPause('run-now');
2823
2990
  runDueJobs().catch((e) => logs.writeLine({
@@ -2841,4 +3008,4 @@ const remote = {
2841
3008
  },
2842
3009
  };
2843
3010
 
2844
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, 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 };
3011
+ 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 };