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.
- package/dist/assets/{TiptapBody-BD2BTBY-.js → TiptapBody-DgFO_m3g.js} +1 -1
- package/dist/assets/index-4dJkn9nT.js +3559 -0
- package/dist/assets/index-DX2w2YhJ.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/adminServer.test.cjs +205 -0
- package/src/main/__tests__/chat-queue.test.cjs +27 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
- package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
- package/src/main/__tests__/prdCreate.test.cjs +82 -0
- package/src/main/__tests__/pty-write-result.test.cjs +46 -0
- package/src/main/__tests__/runVerify.test.cjs +146 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +181 -0
- package/src/main/adminServer.cjs +87 -2
- package/src/main/browserCapture.cjs +21 -7
- package/src/main/chatRunner.cjs +5 -1
- package/src/main/health.cjs +114 -3
- package/src/main/hives.cjs +2 -1
- package/src/main/ipcSchemas.cjs +33 -4
- package/src/main/lib/kebabCase.cjs +12 -0
- package/src/main/lib/memorySlug.cjs +10 -0
- package/src/main/lib/prdCreate.cjs +73 -0
- package/src/main/memoryAggregate.cjs +1 -1
- package/src/main/memoryTool.cjs +2 -1
- package/src/main/pty.cjs +7 -6
- package/src/main/runVerify.cjs +119 -6
- package/src/main/scheduler/prdParser.cjs +88 -0
- package/src/main/scheduler.cjs +176 -9
- package/src/main/templates/PRD_AUTHORING.md +126 -0
- package/src/main/usage.cjs +4 -0
- package/src/main/webRemote.cjs +70 -24
- package/src/preload/api.d.ts +1 -0
- package/dist/assets/index-BwFHVuSk.css +0 -32
- 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');
|
package/src/main/memoryTool.cjs
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
package/src/main/runVerify.cjs
CHANGED
|
@@ -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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
};
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
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 };
|