claude-code-session-manager 0.58.0 → 0.60.0
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/README.md +2 -2
- package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-BtVPGBaq.js} +1 -1
- package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
- package/dist/assets/{index-DEQzGYa6.js → index-DtNip-Zx.js} +1076 -1081
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +41 -1
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +9 -2
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +26 -3
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +36 -0
- package/src/main/lib/epicMint.cjs +6 -1
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +16 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/scheduler.cjs +228 -49
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +75 -9
- package/src/preload/index.cjs +3 -0
package/src/main/scheduler.cjs
CHANGED
|
@@ -63,6 +63,7 @@ const prdParser = require('./scheduler/prdParser.cjs');
|
|
|
63
63
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
64
64
|
const { enqueueExternalPrompt } = require('./chatRunner.cjs');
|
|
65
65
|
const { appendResponseEventIfKnown } = require('./promptSessionEvents.cjs');
|
|
66
|
+
const { maybeEnqueueValidationPrompt } = require('./lib/epicValidationHook.cjs');
|
|
66
67
|
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
67
68
|
const { verifyRun } = require('./runVerify.cjs');
|
|
68
69
|
const { latestTerminalOutcomeForSlug, COMPLETED_EQUIVALENT_VERDICTS } = require('./lib/terminalRunOutcome.cjs');
|
|
@@ -87,7 +88,7 @@ const queueOps = require('./queueOps.cjs');
|
|
|
87
88
|
// home-dir layout.
|
|
88
89
|
const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
|
|
89
90
|
const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
|
|
90
|
-
const { buildContextDigest } = require('./lib/epicContextDigest.cjs');
|
|
91
|
+
const { buildContextDigest, composeExecutorPrompt } = require('./lib/epicContextDigest.cjs');
|
|
91
92
|
|
|
92
93
|
// ---------- origin session resolution (PRD 832) ----------
|
|
93
94
|
// An Epic IS a tagged claude session — job rows carry the originating
|
|
@@ -359,15 +360,6 @@ const HEARTBEAT_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'sc
|
|
|
359
360
|
const HEARTBEAT_MAX_BYTES = 1024 * 1024;
|
|
360
361
|
// DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
|
|
361
362
|
|
|
362
|
-
// Read lazily (not captured at module-load time) so tests can stub the env
|
|
363
|
-
// per-case without vi.resetModules(); production behavior is unaffected
|
|
364
|
-
// since the env var never changes mid-process.
|
|
365
|
-
function getEnvCap() {
|
|
366
|
-
return process.env.SM_SCHEDULER_MAX_CONCURRENCY
|
|
367
|
-
? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
|
|
368
|
-
: null;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
363
|
// Each headless claude -p job can shell out to tsc/vite/pytest and grow well
|
|
372
364
|
// past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
|
|
373
365
|
// after the 2026-06-16 OOM: 3 concurrent cross-project jobs + their build
|
|
@@ -392,7 +384,6 @@ const OOM_SCORE_ADJ_JOB = 500;
|
|
|
392
384
|
|
|
393
385
|
const DEFAULT_CONFIG = {
|
|
394
386
|
offsetMinutes: 15,
|
|
395
|
-
concurrencyCap: getEnvCap() ?? 3,
|
|
396
387
|
defaultCwd: DEFAULT_PROJECT_CWD,
|
|
397
388
|
// 'when-available' = poll usage and fire whenever utilization < threshold.
|
|
398
389
|
// 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
|
|
@@ -757,6 +748,16 @@ async function runPrdMigration() {
|
|
|
757
748
|
meta: { cwd, reason: f.reason },
|
|
758
749
|
});
|
|
759
750
|
}
|
|
751
|
+
// Deliberately left behind because a live job still points at them
|
|
752
|
+
// (PRD 992). Logged so a permanently-stuck flat PRD is visible rather
|
|
753
|
+
// than looking like a clean consolidation.
|
|
754
|
+
for (const s of c.skipped ?? []) {
|
|
755
|
+
logs.writeLine({
|
|
756
|
+
level: 'info', scope: 'scheduler',
|
|
757
|
+
message: `flat-PRD consolidation: left ${s.file} in place`,
|
|
758
|
+
meta: { cwd, reason: s.reason },
|
|
759
|
+
});
|
|
760
|
+
}
|
|
760
761
|
} catch (e) {
|
|
761
762
|
logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'flat-PRD consolidation failed', meta: { cwd, error: e?.message } });
|
|
762
763
|
}
|
|
@@ -1450,6 +1451,18 @@ let cancelToken = { cancelled: false };
|
|
|
1450
1451
|
// Last memory-gate observation; included in snapshot for renderer visibility.
|
|
1451
1452
|
let lastMemGate = null;
|
|
1452
1453
|
|
|
1454
|
+
// Last tickQueue outcome, kept for the UI. tickQueue already computes a precise
|
|
1455
|
+
// reason for every way a batch can come back empty (dependency holds, slot
|
|
1456
|
+
// exhaustion, the memory gate, pause, manual policy) and used to throw all of
|
|
1457
|
+
// it into console.log — so the Home slot count read "5" while the queue was
|
|
1458
|
+
// actually pinned at 1 with nothing on screen explaining why. This is that
|
|
1459
|
+
// explanation, recorded once per tick and broadcast with the rest of the state.
|
|
1460
|
+
let lastTick = null;
|
|
1461
|
+
function recordTick(outcome, extra = {}) {
|
|
1462
|
+
lastTick = { ...outcome, ...extra, at: new Date().toISOString() };
|
|
1463
|
+
return outcome;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1453
1466
|
/**
|
|
1454
1467
|
* Pure: applies clearPause()'s effect on the tick cancel-token.
|
|
1455
1468
|
*
|
|
@@ -1505,10 +1518,19 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
|
|
|
1505
1518
|
lastFailureKind,
|
|
1506
1519
|
},
|
|
1507
1520
|
memGate: lastMemGate,
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1521
|
+
lastTick,
|
|
1522
|
+
// The machine-wide slot pool IS the concurrency limit — there is no
|
|
1523
|
+
// separate scheduler cap any more. `source` distinguishes the
|
|
1524
|
+
// SM_SESSION_SLOTS env override from the persisted Home-tab value so the
|
|
1525
|
+
// UI can disable its own control when the env has taken over.
|
|
1526
|
+
effectiveConcurrency: (() => {
|
|
1527
|
+
const snap = sessionSlots.snapshot();
|
|
1528
|
+
return {
|
|
1529
|
+
cap: snap.total,
|
|
1530
|
+
free: Math.max(0, snap.total - snap.inUse),
|
|
1531
|
+
source: snap.envOverride ? 'env' : 'pool',
|
|
1532
|
+
};
|
|
1533
|
+
})(),
|
|
1512
1534
|
};
|
|
1513
1535
|
if (withPaths) {
|
|
1514
1536
|
payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: queueStore.MACHINE_STATE_PATH };
|
|
@@ -1797,6 +1819,43 @@ function extractResultTextFromLog(logPath) {
|
|
|
1797
1819
|
}
|
|
1798
1820
|
}
|
|
1799
1821
|
|
|
1822
|
+
/**
|
|
1823
|
+
* resolveNotifyPrd(job, parsePrdRaw) → Promise<parsed PRD | null>
|
|
1824
|
+
*
|
|
1825
|
+
* Locate and parse a finishing job's PRD for the notification path, trying
|
|
1826
|
+
* the live Epic-scoped dir first (findPrdDir scans candidatePrdsDirs) and
|
|
1827
|
+
* then the archived twin (archivedPrdPathForJob scans listArchivedPrdDirs).
|
|
1828
|
+
*
|
|
1829
|
+
* Both lookups are required, and `prdPathForJob` is deliberately NOT used
|
|
1830
|
+
* here (PRD 985). It resolves to `resolvePrdWriteDir(cwd)` — the RETIRED
|
|
1831
|
+
* flat `<cwd>/session-manager-operations/scheduler/prds/` dir, which today
|
|
1832
|
+
* holds only zero-byte `.reserved-NNN` number stubs and no PRD at all, so it
|
|
1833
|
+
* ENOENT'd for every Epic-scoped PRD. On top of that, archiveCompletedPrd
|
|
1834
|
+
* renames the file into `prds-archived/` immediately BEFORE the notify call
|
|
1835
|
+
* (see tickQueue's newlyCompletedPrds loop), so even a correct live-dir
|
|
1836
|
+
* lookup finds nothing by the time we run. The net effect was that `prd`
|
|
1837
|
+
* was always null on the completed path: sourcePromptId never resolved, no
|
|
1838
|
+
* transcript turn was written, no response event was appended, and the
|
|
1839
|
+
* completed PRD silently never reported back to the Epic that authored it.
|
|
1840
|
+
* Verified live: PRDs 961/962/963/964/967 all completed 2026-08-03 and not
|
|
1841
|
+
* one response event landed on either authoring Epic's chain.
|
|
1842
|
+
*
|
|
1843
|
+
* Never throws — an unreadable/absent PRD returns null and each caller
|
|
1844
|
+
* falls back to the job's own `epicId`.
|
|
1845
|
+
*/
|
|
1846
|
+
async function resolveNotifyPrd(job, parsePrdRaw) {
|
|
1847
|
+
if (!job || !job.slug) return null;
|
|
1848
|
+
const liveDir = await findPrdDir(job.slug).catch(() => null);
|
|
1849
|
+
const livePath = liveDir ? safeSlugPathIn(liveDir, job.slug) : null;
|
|
1850
|
+
if (livePath) {
|
|
1851
|
+
const parsed = await parsePrdRaw(livePath).catch(() => null);
|
|
1852
|
+
if (parsed) return parsed;
|
|
1853
|
+
}
|
|
1854
|
+
const archivedPath = archivedPrdPathForJob(job);
|
|
1855
|
+
if (!archivedPath) return null;
|
|
1856
|
+
return await parsePrdRaw(archivedPath).catch(() => null);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1800
1859
|
/**
|
|
1801
1860
|
* notifyOriginatingTab(job) → void
|
|
1802
1861
|
*
|
|
@@ -1835,18 +1894,38 @@ async function notifyOriginatingTab(job, {
|
|
|
1835
1894
|
appendResponseEvent = appendResponseEventIfKnown,
|
|
1836
1895
|
appendTranscriptTurn = promptSessionTranscript.appendTurn,
|
|
1837
1896
|
readResultFromLog = extractResultTextFromLog,
|
|
1897
|
+
enqueueValidation = maybeEnqueueValidationPrompt,
|
|
1838
1898
|
} = {}) {
|
|
1839
1899
|
try {
|
|
1840
|
-
const
|
|
1841
|
-
const prd = await parsePrdRaw(prdPath).catch(() => null);
|
|
1900
|
+
const prd = await resolveNotifyPrd(job, parsePrdRaw);
|
|
1842
1901
|
const message = `PRD ${job.slug} finished: ${job.status}. Check Scheduler for details.`;
|
|
1843
1902
|
|
|
1903
|
+
// The EPIC this check-in belongs to. Deliberately does NOT consider
|
|
1904
|
+
// `sourceTabId` — that is a Terminal tab id, not an Epic id, and
|
|
1905
|
+
// appending an Epic event chain onto one would be a category error (a
|
|
1906
|
+
// pre-813 PRD carrying only sourceTabId must still take the
|
|
1907
|
+
// chat:external-send path below; covered by the 'no sourcePromptId at
|
|
1908
|
+
// all (pre-813 PRD)' test).
|
|
1909
|
+
//
|
|
1910
|
+
// `job.epicId` is the last resort and is what makes the notification
|
|
1911
|
+
// survive an entirely unreadable PRD: the queue row carries the Epic FK
|
|
1912
|
+
// independently of the .md on disk (20 of 21 history rows had it when
|
|
1913
|
+
// PRD 985 was written), so a parse failure degrades to "notified without
|
|
1914
|
+
// frontmatter detail" instead of "silently dropped". Mirrors
|
|
1915
|
+
// notifyNeedsReview, which has had this fallback since PRD 854 — which
|
|
1916
|
+
// is precisely why needs_review routing kept working while
|
|
1917
|
+
// completed/failed routing did not.
|
|
1918
|
+
const epicId = prd?.sourcePromptId || job.epicId || null;
|
|
1919
|
+
|
|
1844
1920
|
// Persist the job's real result text (not just the short status chip
|
|
1845
1921
|
// above) to the durable per-Epic transcript, keyed off whichever id
|
|
1846
|
-
// notifyOriginatingTab would otherwise notify.
|
|
1847
|
-
//
|
|
1848
|
-
//
|
|
1849
|
-
|
|
1922
|
+
// notifyOriginatingTab would otherwise notify. Unlike the event append
|
|
1923
|
+
// above, this one DOES accept sourceTabId — the durable transcript is
|
|
1924
|
+
// keyed by whatever id the run is associated with, not strictly by Epic
|
|
1925
|
+
// (pre-existing behaviour, unchanged by PRD 985 apart from the epicId
|
|
1926
|
+
// fallback). Best-effort: a missing cwd/epic id, an unreadable run log,
|
|
1927
|
+
// or an IPC error here must never block the notification below.
|
|
1928
|
+
const epicIdForTranscript = prd?.sourcePromptId || prd?.sourceTabId || job.epicId || null;
|
|
1850
1929
|
if (epicIdForTranscript && job.cwd) {
|
|
1851
1930
|
try {
|
|
1852
1931
|
const logPath = job.runId ? path.join(RUNS_DIR, job.runId, `${job.slug}.log`) : null;
|
|
@@ -1860,12 +1939,46 @@ async function notifyOriginatingTab(job, {
|
|
|
1860
1939
|
}
|
|
1861
1940
|
}
|
|
1862
1941
|
|
|
1863
|
-
if (
|
|
1864
|
-
|
|
1942
|
+
if (epicId) {
|
|
1943
|
+
// PRD 986: the check-in event is born validation:'unvalidated' — never
|
|
1944
|
+
// 'verified' — regardless of the job's self-reported outcome. The
|
|
1945
|
+
// check-in is a request to validate, not an assertion of done.
|
|
1946
|
+
const routed = await appendResponseEvent(job.cwd || null, epicId, message, {
|
|
1947
|
+
prdSlug: job.slug,
|
|
1948
|
+
outcome: job.status,
|
|
1949
|
+
validation: 'unvalidated',
|
|
1950
|
+
}).catch((e) => {
|
|
1865
1951
|
console.error('[scheduler] notifyOriginatingTab appendResponseEvent error', job?.slug, e);
|
|
1866
1952
|
return false;
|
|
1867
1953
|
});
|
|
1868
|
-
if (routed)
|
|
1954
|
+
if (routed) {
|
|
1955
|
+
// PRD 986: a successful check-in append triggers ONE validation
|
|
1956
|
+
// prompt into the authoring Epic's own session, asking it to verify
|
|
1957
|
+
// every acceptance criterion against the real working tree and reply
|
|
1958
|
+
// VERIFIED or REFUTED. Fire-and-forget, same convention as this
|
|
1959
|
+
// function's own call site in tickQueue: a failure to enqueue is
|
|
1960
|
+
// logged inside the hook, never thrown, and never blocks the job's
|
|
1961
|
+
// status transition or the response-event append (which already
|
|
1962
|
+
// happened above). All gating (kill-switch SM_EPIC_VALIDATION_DISABLE,
|
|
1963
|
+
// active-Epic check, once-per-(epicId, prdSlug), loop guard, slot-pool
|
|
1964
|
+
// routing) lives in lib/epicValidationHook.cjs.
|
|
1965
|
+
try {
|
|
1966
|
+
enqueueValidation(
|
|
1967
|
+
{
|
|
1968
|
+
cwd: job.cwd || null,
|
|
1969
|
+
epicId,
|
|
1970
|
+
prdSlug: job.slug,
|
|
1971
|
+
prdPath: prd?.path || archivedPrdPathForJob(job) || null,
|
|
1972
|
+
outcome: job.status,
|
|
1973
|
+
eventValidation: 'unvalidated',
|
|
1974
|
+
},
|
|
1975
|
+
{ sendPrompt },
|
|
1976
|
+
);
|
|
1977
|
+
} catch (e) {
|
|
1978
|
+
console.error('[scheduler] notifyOriginatingTab enqueueValidation error', job?.slug, e);
|
|
1979
|
+
}
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1869
1982
|
}
|
|
1870
1983
|
|
|
1871
1984
|
// appendResponseEvent already refused above (unknown id, completed
|
|
@@ -1874,7 +1987,7 @@ async function notifyOriginatingTab(job, {
|
|
|
1874
1987
|
// re-checks against its own live PromptSession store. Deliberate
|
|
1875
1988
|
// defense-in-depth (main's disk-backed check vs. the renderer's
|
|
1876
1989
|
// in-memory one can disagree/race), not a redundant duplicate to prune.
|
|
1877
|
-
let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || null;
|
|
1990
|
+
let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || job.epicId || null;
|
|
1878
1991
|
if (!targetTabId) {
|
|
1879
1992
|
const jobCwd = job.cwd || null;
|
|
1880
1993
|
if (jobCwd) {
|
|
@@ -1916,14 +2029,23 @@ async function notifyNeedsReview(job, report, {
|
|
|
1916
2029
|
} = {}) {
|
|
1917
2030
|
try {
|
|
1918
2031
|
if (!job || !report || !report.filed) return false;
|
|
1919
|
-
const prd = await
|
|
2032
|
+
const prd = await resolveNotifyPrd(job, parsePrdRaw);
|
|
1920
2033
|
const epicId = prd?.sourcePromptId || job.epicId || null;
|
|
1921
2034
|
if (!epicId || !job.cwd) {
|
|
1922
2035
|
console.log(`[scheduler] notifyNeedsReview: no authoring Epic for ${job.slug}, report only`);
|
|
1923
2036
|
return false;
|
|
1924
2037
|
}
|
|
1925
2038
|
const message = `${report.summary}. Root-cause report: ${report.path}`;
|
|
1926
|
-
|
|
2039
|
+
// PRD 986: like every scheduler check-in, born 'unvalidated' — the RCA
|
|
2040
|
+
// report is itself a question, not a verified outcome. No validation
|
|
2041
|
+
// prompt is enqueued here: that fires only for the terminal
|
|
2042
|
+
// completed/failed path (notifyOriginatingTab); needs_review already
|
|
2043
|
+
// routes its own question via this very report.
|
|
2044
|
+
return await appendResponseEvent(job.cwd, epicId, message, {
|
|
2045
|
+
prdSlug: job.slug,
|
|
2046
|
+
outcome: 'needs_review',
|
|
2047
|
+
validation: 'unvalidated',
|
|
2048
|
+
});
|
|
1927
2049
|
} catch (e) {
|
|
1928
2050
|
console.error('[scheduler] notifyNeedsReview error', job?.slug, e);
|
|
1929
2051
|
return false;
|
|
@@ -2157,7 +2279,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2157
2279
|
try {
|
|
2158
2280
|
const digestText = await buildContextDigest({ cwd, epicId: digestEpicId });
|
|
2159
2281
|
if (digestText) {
|
|
2160
|
-
prompt =
|
|
2282
|
+
prompt = composeExecutorPrompt({ prdBody: prompt, digestText });
|
|
2161
2283
|
contextDigestApplied = true;
|
|
2162
2284
|
}
|
|
2163
2285
|
} catch (e) {
|
|
@@ -3184,20 +3306,34 @@ function tickQueue() {
|
|
|
3184
3306
|
}
|
|
3185
3307
|
if (state.paused) {
|
|
3186
3308
|
console.log('[scheduler] tickQueue skipped: paused');
|
|
3187
|
-
return { fired: false, reason: 'paused' };
|
|
3309
|
+
return recordTick({ fired: false, reason: 'paused' }, { detail: 'scheduler paused' });
|
|
3188
3310
|
}
|
|
3189
3311
|
if (cancelToken.cancelled) return { fired: false, reason: 'cancelled' };
|
|
3190
3312
|
|
|
3191
3313
|
await reconcile(state);
|
|
3192
|
-
|
|
3193
|
-
|
|
3314
|
+
// Session-Manager's machine-wide slot pool is the ONLY concurrency limit
|
|
3315
|
+
// the picker answers to (plus the memory gate below). The scheduler used
|
|
3316
|
+
// to also carry a private `concurrencyCap` of 3 — the exact per-consumer
|
|
3317
|
+
// cap that sessionSlots.cjs was written to replace — which silently
|
|
3318
|
+
// ceilinged the queue at 3 while the pool the user configured said 5.
|
|
3319
|
+
const freeSlots = sessionSlots.available();
|
|
3320
|
+
const { batch, reason: holdReason, holds } = pickNextBatch(state.jobs, runningSet, freeSlots);
|
|
3321
|
+
if (batch.length === 0 && freeSlots === 0) {
|
|
3322
|
+
const snap = sessionSlots.snapshot();
|
|
3323
|
+
const pendingCount = state.jobs.filter((j) => j.status === 'pending').length;
|
|
3324
|
+
console.log(`[scheduler] slot gate: 0 of ${snap.total} session slots free (${snap.holders.map((h) => h.owner).join(', ')}) — deferring ${pendingCount} pending job(s)`);
|
|
3325
|
+
return recordTick(
|
|
3326
|
+
{ fired: false, reason: 'slots-exhausted', deferredCount: pendingCount, holders: snap.holders },
|
|
3327
|
+
{ detail: `${snap.total} of ${snap.total} session slots busy (${snap.holders.map((h) => h.owner).join(', ')})`, holds },
|
|
3328
|
+
);
|
|
3329
|
+
}
|
|
3194
3330
|
if (batch.length === 0) {
|
|
3195
3331
|
// Queue drained — run the definition-of-done gate fire-and-forget.
|
|
3196
3332
|
// Non-blocking: does not hold the mutate lock; errors are logged, not thrown.
|
|
3197
3333
|
runDefinitionOfDoneOnDrain(state, { cancelToken }).catch((err) => {
|
|
3198
3334
|
console.log(`[scheduler] dod-drain: ${err?.message ?? String(err)}`);
|
|
3199
3335
|
});
|
|
3200
|
-
if (holdReason) return { fired: false, reason: 'held', detail: holdReason };
|
|
3336
|
+
if (holdReason) return recordTick({ fired: false, reason: 'held', detail: holdReason }, { holds });
|
|
3201
3337
|
// Distinguish "genuinely nothing to do" from "the batch was already
|
|
3202
3338
|
// fired by a concurrent tickQueue() call" (e.g. the periodic
|
|
3203
3339
|
// when-available poll winning a race against a manual force-tick —
|
|
@@ -3210,9 +3346,9 @@ function tickQueue() {
|
|
|
3210
3346
|
state.jobs.filter((j) => j.status === 'running').length,
|
|
3211
3347
|
);
|
|
3212
3348
|
if (runningCount > 0) {
|
|
3213
|
-
return { fired: false, reason: 'already-running', runningCount };
|
|
3349
|
+
return recordTick({ fired: false, reason: 'already-running', runningCount }, { holds });
|
|
3214
3350
|
}
|
|
3215
|
-
return { fired: false, reason: 'drained' };
|
|
3351
|
+
return recordTick({ fired: false, reason: 'drained' }, { holds });
|
|
3216
3352
|
}
|
|
3217
3353
|
|
|
3218
3354
|
const availableMb = getAvailableMemMb();
|
|
@@ -3220,23 +3356,19 @@ function tickQueue() {
|
|
|
3220
3356
|
// job is never started into the host's own headroom (that path OOM-kills
|
|
3221
3357
|
// Electron and SIGHUPs every pty — 2026-06-16 incident).
|
|
3222
3358
|
const jobBudgetMb = availableForJobs(availableMb, RESERVED_HOST_MB);
|
|
3223
|
-
//
|
|
3224
|
-
//
|
|
3225
|
-
const
|
|
3226
|
-
|
|
3227
|
-
const snap = sessionSlots.snapshot();
|
|
3228
|
-
console.log(`[scheduler] slot gate: 0 of ${snap.total} session slots free (${snap.holders.map((h) => h.owner).join(', ')}) — deferring ${batch.length} job(s)`);
|
|
3229
|
-
return { fired: false, reason: 'slots-exhausted', deferredCount: batch.length, holders: snap.holders };
|
|
3230
|
-
}
|
|
3231
|
-
const allowed = Math.min(
|
|
3232
|
-
slotAllowed,
|
|
3233
|
-
memoryLimitedBatchSize(jobBudgetMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length),
|
|
3359
|
+
// The slot pool already bounded `batch` (it was passed as freeSlots to
|
|
3360
|
+
// pickNextBatch above), so only the memory gate can narrow it further.
|
|
3361
|
+
const allowed = memoryLimitedBatchSize(
|
|
3362
|
+
jobBudgetMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length,
|
|
3234
3363
|
);
|
|
3235
3364
|
if (allowed === 0) {
|
|
3236
3365
|
const threshold = RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
|
|
3237
3366
|
console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job × ${runningSet.size + 1}) — deferring ${batch.length} job(s)`);
|
|
3238
3367
|
lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
|
|
3239
|
-
return
|
|
3368
|
+
return recordTick(
|
|
3369
|
+
{ fired: false, reason: 'memory-deferred', deferredCount: batch.length, availableMb, threshold },
|
|
3370
|
+
{ detail: `memory gate: ${availableMb} MB free, need ${threshold} MB`, holds },
|
|
3371
|
+
);
|
|
3240
3372
|
}
|
|
3241
3373
|
const gatedBatch = batch.slice(0, allowed);
|
|
3242
3374
|
if (gatedBatch.length < batch.length) {
|
|
@@ -3257,7 +3389,7 @@ function tickQueue() {
|
|
|
3257
3389
|
// spawnJob is fire-and-forget; it calls tickQueue() on completion.
|
|
3258
3390
|
spawnJob(job, runId, runDir, state.config.defaultCwd).catch(() => {});
|
|
3259
3391
|
}
|
|
3260
|
-
return { fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup };
|
|
3392
|
+
return recordTick({ fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup }, { holds });
|
|
3261
3393
|
});
|
|
3262
3394
|
tickTail = next.catch(() => {});
|
|
3263
3395
|
return next;
|
|
@@ -3628,6 +3760,50 @@ function isUnresolvableNeedsReview(job, { hasRunDir }) {
|
|
|
3628
3760
|
* commit-guard) — verifyRun can't see git, so re-scanning it would falsely
|
|
3629
3761
|
* heal an unfinished job. Exported for tests.
|
|
3630
3762
|
*/
|
|
3763
|
+
/**
|
|
3764
|
+
* healRefusalReason(job, verdict, committedDuringRun) → string | null
|
|
3765
|
+
*
|
|
3766
|
+
* Guards reverifyNeedsReview's needs_review → completed self-heal. Returns a
|
|
3767
|
+
* refusal reason when the heal must NOT happen, or null to allow it.
|
|
3768
|
+
*
|
|
3769
|
+
* THE INCIDENT (PRD 983, 2026-08-03). Job 972 ran 34 s, edited zero files and
|
|
3770
|
+
* exited 0. verifyRun correctly raised `no_verdict_sentinel` (no commit AND no
|
|
3771
|
+
* SCHEDULER_VERDICT sentinel) with downgradeTo 'needs_review', and the job was
|
|
3772
|
+
* parked. One minute later this self-heal pass promoted it to `completed`, and
|
|
3773
|
+
* that false green shipped to the user.
|
|
3774
|
+
*
|
|
3775
|
+
* The mechanism is subtle and worth spelling out, because the individual parts
|
|
3776
|
+
* all look correct: `no_verdict_sentinel` is in RESCANNABLE_VERDICTS, so the
|
|
3777
|
+
* job was re-verified. The rescan recomputes `committedDuringRun` via
|
|
3778
|
+
* `committedInWindow`, which runs `git log --all --since=<startedAt>
|
|
3779
|
+
* --until=<finishedAt+60s>` with NO author, message, or slug filter — it
|
|
3780
|
+
* answers "did ANY commit land in this repo during that window", not "did THIS
|
|
3781
|
+
* job commit". With up to 3 concurrent scheduler jobs plus interactive
|
|
3782
|
+
* sessions sharing one repo, a sibling job's commit lands inside the window
|
|
3783
|
+
* routinely. `committedDuringRun` therefore flipped to true on the rescan,
|
|
3784
|
+
* runVerify's `if (sentinel === null && !committedDuringRun)` no longer fired,
|
|
3785
|
+
* `no_verdict_sentinel` was never re-raised, the issue list came back empty,
|
|
3786
|
+
* and the verdict concluded `clean` — a COMPLETED_EQUIVALENT verdict, so the
|
|
3787
|
+
* job healed. Another job's commit was silently credited to this one.
|
|
3788
|
+
*
|
|
3789
|
+
* The rule: a run that emitted no sentinel and landed no commit ATTRIBUTABLE
|
|
3790
|
+
* TO ITSELF has produced no evidence it did anything, and repo-wide
|
|
3791
|
+
* time-window evidence is not attribution. `landedCommit` is per-job and
|
|
3792
|
+
* survives a resetJob (resetJobFields), so it is the honest signal here.
|
|
3793
|
+
* Bias to needs_review: a false yellow costs a human glance, a false green
|
|
3794
|
+
* costs a silently-unfixed bug — which is exactly what happened.
|
|
3795
|
+
*/
|
|
3796
|
+
function healRefusalReason(job, verdict, committedDuringRun) {
|
|
3797
|
+
if (!job || !verdict) return null;
|
|
3798
|
+
if (!COMPLETED_EQUIVALENT_VERDICTS.has(verdict.verdict)) return null;
|
|
3799
|
+
if (job.verifierVerdict !== 'no_verdict_sentinel') return null;
|
|
3800
|
+
// A commit this job actually recorded as its own is real evidence; the
|
|
3801
|
+
// repo-wide window scan is not.
|
|
3802
|
+
if (job.landedCommit) return null;
|
|
3803
|
+
return 'no_verdict_sentinel with no job-attributable commit — refusing to heal'
|
|
3804
|
+
+ ` (committedInWindow=${committedDuringRun === true} is repo-wide, not proof this job delivered)`;
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3631
3807
|
function isRescanCandidate(job) {
|
|
3632
3808
|
return !!job
|
|
3633
3809
|
&& job.status === 'needs_review'
|
|
@@ -3729,7 +3905,10 @@ async function reverifyNeedsReview() {
|
|
|
3729
3905
|
priorLandedCommit,
|
|
3730
3906
|
});
|
|
3731
3907
|
} catch { leftForReview.push({ slug: job.slug, reason: 'verifyRun threw' }); continue; }
|
|
3732
|
-
|
|
3908
|
+
const refusal = healRefusalReason(job, v, committedDuringRun);
|
|
3909
|
+
if (refusal) {
|
|
3910
|
+
leftForReview.push({ slug: job.slug, reason: refusal });
|
|
3911
|
+
} else if (v && COMPLETED_EQUIVALENT_VERDICTS.has(v.verdict)) {
|
|
3733
3912
|
healed.push(job.slug);
|
|
3734
3913
|
} else {
|
|
3735
3914
|
leftForReview.push({ slug: job.slug, reason: v ? `${v.verdict}: ${v.reason}` : 'null verdict' });
|
|
@@ -4595,4 +4774,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
4595
4774
|
});
|
|
4596
4775
|
}
|
|
4597
4776
|
|
|
4598
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
|
|
4777
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Project Home</title>
|
|
7
|
+
<meta name="description" content="Project Home — a hosted document generated from this project's own repo, not yet generated for this project." />
|
|
8
|
+
<style>
|
|
9
|
+
/* Design tokens copied from src/renderer/lib/projectPages/library/kit.tsx's
|
|
10
|
+
PK palette/fonts — no font files are embedded here (unlike the
|
|
11
|
+
generated lenses), so the font-family stacks fall back to their system
|
|
12
|
+
alternatives (Georgia / system-ui / ui-monospace). This keeps the
|
|
13
|
+
document a zero-network, zero-JS static file while still reading as
|
|
14
|
+
the same visual system: same colors, same type scale, same shapes. */
|
|
15
|
+
:root {
|
|
16
|
+
--pk-paper: #f6efe1;
|
|
17
|
+
--pk-panel: #efe6d3;
|
|
18
|
+
--pk-card: #fbf6ec;
|
|
19
|
+
--pk-deep: #2a221a;
|
|
20
|
+
--pk-edge: #e0d3b8;
|
|
21
|
+
--pk-rule: #d9c9a8;
|
|
22
|
+
--pk-ink: #2a221a;
|
|
23
|
+
--pk-ink-soft: #5b4a36;
|
|
24
|
+
--pk-ink-mute: #8a7a60;
|
|
25
|
+
--pk-accent: #b85c34;
|
|
26
|
+
--pk-accent-deep: #9c4a26;
|
|
27
|
+
--pk-sage: #6f7d52;
|
|
28
|
+
--pk-butter: #e4b85a;
|
|
29
|
+
--pk-serif: 'Newsreader', Georgia, serif;
|
|
30
|
+
--pk-sans: 'Geist', system-ui, sans-serif;
|
|
31
|
+
--pk-mono: 'IBM Plex Mono', ui-monospace, monospace;
|
|
32
|
+
}
|
|
33
|
+
* { box-sizing: border-box; }
|
|
34
|
+
html, body { margin: 0; padding: 0; background: var(--pk-paper); color: var(--pk-ink); }
|
|
35
|
+
body { font-family: var(--pk-sans); }
|
|
36
|
+
.eyebrow {
|
|
37
|
+
font-family: var(--pk-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
|
|
38
|
+
color: var(--pk-accent); font-weight: 500;
|
|
39
|
+
}
|
|
40
|
+
.pill {
|
|
41
|
+
display: inline-flex; align-items: center; gap: 6px; font-family: var(--pk-mono); font-size: 11px;
|
|
42
|
+
color: var(--pk-ink-soft); background: transparent; border: 1px solid var(--pk-rule); border-radius: 999px;
|
|
43
|
+
padding: 3px 9px; white-space: nowrap;
|
|
44
|
+
}
|
|
45
|
+
h1 {
|
|
46
|
+
margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: clamp(30px, 5vw, 44px);
|
|
47
|
+
line-height: 1.1; letter-spacing: -0.015em; color: var(--pk-ink);
|
|
48
|
+
}
|
|
49
|
+
h2 {
|
|
50
|
+
margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: 24px; line-height: 1.15;
|
|
51
|
+
letter-spacing: -0.01em; color: var(--pk-ink);
|
|
52
|
+
}
|
|
53
|
+
p { margin: 0; font-family: var(--pk-sans); font-size: 16px; line-height: 1.62; color: var(--pk-ink-soft); }
|
|
54
|
+
section { padding: clamp(28px, 6vw, 72px); }
|
|
55
|
+
.hero { border-bottom: 1px solid var(--pk-rule); background: var(--pk-panel); }
|
|
56
|
+
.hero-inner { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
|
57
|
+
.cta {
|
|
58
|
+
display: inline-flex; width: fit-content; align-items: center; gap: 8px; font-family: var(--pk-sans);
|
|
59
|
+
font-size: 14px; font-weight: 600; padding: 11px 20px; border-radius: 9px; border: 1px solid transparent;
|
|
60
|
+
background: var(--pk-accent); color: #fff;
|
|
61
|
+
}
|
|
62
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
|
|
63
|
+
.card {
|
|
64
|
+
background: var(--pk-card); border: 1px solid var(--pk-edge); border-radius: 12px; padding: 20px;
|
|
65
|
+
display: flex; flex-direction: column; gap: 8px;
|
|
66
|
+
}
|
|
67
|
+
.card .k { font-family: var(--pk-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--pk-ink-mute); }
|
|
68
|
+
.card .t { font-family: var(--pk-serif); font-size: 18px; color: var(--pk-ink); }
|
|
69
|
+
.card .d { font-size: 13.5px; line-height: 1.55; color: var(--pk-ink-soft); }
|
|
70
|
+
.footnote { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 6px; }
|
|
71
|
+
.footnote .label { font-family: var(--pk-mono); font-size: 11px; color: var(--pk-ink-mute); }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<section class="hero">
|
|
76
|
+
<div class="hero-inner">
|
|
77
|
+
<span class="pill">Shipped default — not yet generated for this project</span>
|
|
78
|
+
<div class="eyebrow">Project Home</div>
|
|
79
|
+
<h1>This is Project Home's shipped default page.</h1>
|
|
80
|
+
<p>
|
|
81
|
+
Project Home is a hosted document — a self-contained page describing this specific
|
|
82
|
+
project: what it is, its main areas, and what it's for. Session-manager ships this
|
|
83
|
+
default so the page is never empty, but it does not describe your project yet.
|
|
84
|
+
Nothing shown on this page is specific to any real project.
|
|
85
|
+
</p>
|
|
86
|
+
<a class="cta" href="#" onclick="return false;">Generate My Project Home</a>
|
|
87
|
+
<p style="font-size: 13px; color: var(--pk-ink-mute);">
|
|
88
|
+
Press "Generate My Project Home" in the app (this link is inert inside this static
|
|
89
|
+
preview) to replace this page with one generated from your project's own repo.
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
<section>
|
|
94
|
+
<div class="grid">
|
|
95
|
+
<div class="card">
|
|
96
|
+
<div class="k">What it reads</div>
|
|
97
|
+
<div class="t">Your repo, live</div>
|
|
98
|
+
<div class="d">Generation reads this project's own files, git history, and CLAUDE.md conventions — never invented content.</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="card">
|
|
101
|
+
<div class="k">What it produces</div>
|
|
102
|
+
<div class="t">A static document</div>
|
|
103
|
+
<div class="d">A self-contained HTML page — inline styling, no network calls, no live app state — that replaces this default at a fixed path.</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="card">
|
|
106
|
+
<div class="k">How it stays fresh</div>
|
|
107
|
+
<div class="t">On demand</div>
|
|
108
|
+
<div class="d">Regenerate any time from Project Home; this default only ever shows before the first generation.</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</section>
|
|
112
|
+
<section>
|
|
113
|
+
<div class="footnote">
|
|
114
|
+
<div class="label">About this page</div>
|
|
115
|
+
<p style="font-size: 13.5px;">
|
|
116
|
+
Shipped with Session Manager as a build-time asset — the same design system as a
|
|
117
|
+
generated Project Home page, but honest, generic copy instead of project-specific
|
|
118
|
+
content. See "About these templates" for how a generated page is built.
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
</section>
|
|
122
|
+
</body>
|
|
123
|
+
</html>
|