claude-code-session-manager 0.57.1 → 0.59.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-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/index-BPnfPdLW.js +3222 -0
- package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- 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__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- 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__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- 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/chatRunner.cjs +10 -1
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +3 -5
- package/src/main/ipcSchemas.cjs +72 -4
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +86 -164
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +95 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- 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/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +302 -68
- 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 +100 -57
- package/src/preload/index.cjs +9 -7
- package/dist/assets/index-DCa3QvAi.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
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');
|
|
@@ -77,7 +78,7 @@ const {
|
|
|
77
78
|
} = require('./lib/schedulerConfig.cjs');
|
|
78
79
|
const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('./lib/schedulerBatch.cjs');
|
|
79
80
|
const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
|
|
80
|
-
const {
|
|
81
|
+
const { writeRcaReport, extractRcaBlock } = require('./lib/rcaReport.cjs');
|
|
81
82
|
const queueHistory = require('./lib/queueHistory.cjs');
|
|
82
83
|
const queueOps = require('./queueOps.cjs');
|
|
83
84
|
// Feedback-auto-PRD sweep — formerly only run by the external scheduler-watchdog
|
|
@@ -87,6 +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');
|
|
91
|
+
const { buildContextDigest } = require('./lib/epicContextDigest.cjs');
|
|
90
92
|
|
|
91
93
|
// ---------- origin session resolution (PRD 832) ----------
|
|
92
94
|
// An Epic IS a tagged claude session — job rows carry the originating
|
|
@@ -358,15 +360,6 @@ const HEARTBEAT_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'sc
|
|
|
358
360
|
const HEARTBEAT_MAX_BYTES = 1024 * 1024;
|
|
359
361
|
// DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
|
|
360
362
|
|
|
361
|
-
// Read lazily (not captured at module-load time) so tests can stub the env
|
|
362
|
-
// per-case without vi.resetModules(); production behavior is unaffected
|
|
363
|
-
// since the env var never changes mid-process.
|
|
364
|
-
function getEnvCap() {
|
|
365
|
-
return process.env.SM_SCHEDULER_MAX_CONCURRENCY
|
|
366
|
-
? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
|
|
367
|
-
: null;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
363
|
// Each headless claude -p job can shell out to tsc/vite/pytest and grow well
|
|
371
364
|
// past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
|
|
372
365
|
// after the 2026-06-16 OOM: 3 concurrent cross-project jobs + their build
|
|
@@ -391,7 +384,6 @@ const OOM_SCORE_ADJ_JOB = 500;
|
|
|
391
384
|
|
|
392
385
|
const DEFAULT_CONFIG = {
|
|
393
386
|
offsetMinutes: 15,
|
|
394
|
-
concurrencyCap: getEnvCap() ?? 3,
|
|
395
387
|
defaultCwd: DEFAULT_PROJECT_CWD,
|
|
396
388
|
// 'when-available' = poll usage and fire whenever utilization < threshold.
|
|
397
389
|
// 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
|
|
@@ -756,6 +748,16 @@ async function runPrdMigration() {
|
|
|
756
748
|
meta: { cwd, reason: f.reason },
|
|
757
749
|
});
|
|
758
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
|
+
}
|
|
759
761
|
} catch (e) {
|
|
760
762
|
logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'flat-PRD consolidation failed', meta: { cwd, error: e?.message } });
|
|
761
763
|
}
|
|
@@ -1421,8 +1423,8 @@ let heartbeatInterval = null;
|
|
|
1421
1423
|
// (The 5-minute feedback sweep that used to piggyback on this heartbeat is
|
|
1422
1424
|
// gone: it scanned each active project's session-manager-operations/feedback/
|
|
1423
1425
|
// and auto-queued a /process-feedback PRD. Both the folder and that skill are
|
|
1424
|
-
// retired
|
|
1425
|
-
//
|
|
1426
|
+
// retired, and nothing auto-creates work any more — a parked job is surfaced
|
|
1427
|
+
// back into the Epic that authored its PRD instead. See lib/rcaReport.cjs.)
|
|
1426
1428
|
// In-memory set of slugs currently spawned in this process. Prevents
|
|
1427
1429
|
// double-spawn when runDueJobs() is called while jobs are in flight.
|
|
1428
1430
|
const runningSet = new Set();
|
|
@@ -1449,6 +1451,18 @@ let cancelToken = { cancelled: false };
|
|
|
1449
1451
|
// Last memory-gate observation; included in snapshot for renderer visibility.
|
|
1450
1452
|
let lastMemGate = null;
|
|
1451
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
|
+
|
|
1452
1466
|
/**
|
|
1453
1467
|
* Pure: applies clearPause()'s effect on the tick cancel-token.
|
|
1454
1468
|
*
|
|
@@ -1504,10 +1518,19 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
|
|
|
1504
1518
|
lastFailureKind,
|
|
1505
1519
|
},
|
|
1506
1520
|
memGate: lastMemGate,
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
+
})(),
|
|
1511
1534
|
};
|
|
1512
1535
|
if (withPaths) {
|
|
1513
1536
|
payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: queueStore.MACHINE_STATE_PATH };
|
|
@@ -1796,6 +1819,43 @@ function extractResultTextFromLog(logPath) {
|
|
|
1796
1819
|
}
|
|
1797
1820
|
}
|
|
1798
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
|
+
|
|
1799
1859
|
/**
|
|
1800
1860
|
* notifyOriginatingTab(job) → void
|
|
1801
1861
|
*
|
|
@@ -1834,18 +1894,38 @@ async function notifyOriginatingTab(job, {
|
|
|
1834
1894
|
appendResponseEvent = appendResponseEventIfKnown,
|
|
1835
1895
|
appendTranscriptTurn = promptSessionTranscript.appendTurn,
|
|
1836
1896
|
readResultFromLog = extractResultTextFromLog,
|
|
1897
|
+
enqueueValidation = maybeEnqueueValidationPrompt,
|
|
1837
1898
|
} = {}) {
|
|
1838
1899
|
try {
|
|
1839
|
-
const
|
|
1840
|
-
const prd = await parsePrdRaw(prdPath).catch(() => null);
|
|
1900
|
+
const prd = await resolveNotifyPrd(job, parsePrdRaw);
|
|
1841
1901
|
const message = `PRD ${job.slug} finished: ${job.status}. Check Scheduler for details.`;
|
|
1842
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
|
+
|
|
1843
1920
|
// Persist the job's real result text (not just the short status chip
|
|
1844
1921
|
// above) to the durable per-Epic transcript, keyed off whichever id
|
|
1845
|
-
// notifyOriginatingTab would otherwise notify.
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
1848
|
-
|
|
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;
|
|
1849
1929
|
if (epicIdForTranscript && job.cwd) {
|
|
1850
1930
|
try {
|
|
1851
1931
|
const logPath = job.runId ? path.join(RUNS_DIR, job.runId, `${job.slug}.log`) : null;
|
|
@@ -1859,12 +1939,46 @@ async function notifyOriginatingTab(job, {
|
|
|
1859
1939
|
}
|
|
1860
1940
|
}
|
|
1861
1941
|
|
|
1862
|
-
if (
|
|
1863
|
-
|
|
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) => {
|
|
1864
1951
|
console.error('[scheduler] notifyOriginatingTab appendResponseEvent error', job?.slug, e);
|
|
1865
1952
|
return false;
|
|
1866
1953
|
});
|
|
1867
|
-
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
|
+
}
|
|
1868
1982
|
}
|
|
1869
1983
|
|
|
1870
1984
|
// appendResponseEvent already refused above (unknown id, completed
|
|
@@ -1873,7 +1987,7 @@ async function notifyOriginatingTab(job, {
|
|
|
1873
1987
|
// re-checks against its own live PromptSession store. Deliberate
|
|
1874
1988
|
// defense-in-depth (main's disk-backed check vs. the renderer's
|
|
1875
1989
|
// in-memory one can disagree/race), not a redundant duplicate to prune.
|
|
1876
|
-
let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || null;
|
|
1990
|
+
let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || job.epicId || null;
|
|
1877
1991
|
if (!targetTabId) {
|
|
1878
1992
|
const jobCwd = job.cwd || null;
|
|
1879
1993
|
if (jobCwd) {
|
|
@@ -1893,6 +2007,51 @@ async function notifyOriginatingTab(job, {
|
|
|
1893
2007
|
}
|
|
1894
2008
|
}
|
|
1895
2009
|
|
|
2010
|
+
/**
|
|
2011
|
+
* notifyNeedsReview(job, report) → Promise<boolean>
|
|
2012
|
+
*
|
|
2013
|
+
* A job parked in `needs_review` is a question, not a result: something about
|
|
2014
|
+
* the PRD was unclear, unverifiable, or ran past its acceptance criteria.
|
|
2015
|
+
* That question belongs in the conversation that WROTE the PRD, so this
|
|
2016
|
+
* appends a 'response' event to the authoring Epic's own chain (the same
|
|
2017
|
+
* channel notifyOriginatingTab uses for completed/failed, which deliberately
|
|
2018
|
+
* excludes needs_review — isNotifiableTerminalStatus). It resolves the Epic
|
|
2019
|
+
* from the PRD's `sourcePromptId`, falling back to the job's own `epicId`.
|
|
2020
|
+
*
|
|
2021
|
+
* Join-only by construction: appendResponseEventIfKnown refuses an unknown or
|
|
2022
|
+
* non-active session and returns false. Nothing here can create an Epic — if
|
|
2023
|
+
* no open authoring Epic exists, the root-cause report in the run directory
|
|
2024
|
+
* is the whole record and the Scheduler tab is where it surfaces.
|
|
2025
|
+
*/
|
|
2026
|
+
async function notifyNeedsReview(job, report, {
|
|
2027
|
+
parsePrdRaw = prdParser.parsePrdRaw,
|
|
2028
|
+
appendResponseEvent = appendResponseEventIfKnown,
|
|
2029
|
+
} = {}) {
|
|
2030
|
+
try {
|
|
2031
|
+
if (!job || !report || !report.filed) return false;
|
|
2032
|
+
const prd = await resolveNotifyPrd(job, parsePrdRaw);
|
|
2033
|
+
const epicId = prd?.sourcePromptId || job.epicId || null;
|
|
2034
|
+
if (!epicId || !job.cwd) {
|
|
2035
|
+
console.log(`[scheduler] notifyNeedsReview: no authoring Epic for ${job.slug}, report only`);
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
const message = `${report.summary}. Root-cause report: ${report.path}`;
|
|
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
|
+
});
|
|
2049
|
+
} catch (e) {
|
|
2050
|
+
console.error('[scheduler] notifyNeedsReview error', job?.slug, e);
|
|
2051
|
+
return false;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
1896
2055
|
/** Scan the tail of a job's log for the canonical rate-limit signal. We look
|
|
1897
2056
|
* at the last 16 KB — final result event always lands at the end.
|
|
1898
2057
|
* Uses readTail() so no raw fd lifecycle is needed here. */
|
|
@@ -2109,6 +2268,25 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2109
2268
|
}
|
|
2110
2269
|
}
|
|
2111
2270
|
|
|
2271
|
+
// Prepend the Epic's own session digest (PRD 950/958) when this job traces
|
|
2272
|
+
// back to a known Epic — additive only, never mutates the PRD body itself.
|
|
2273
|
+
// A missing/unresolved epicId or a digest build failure is a silent no-op:
|
|
2274
|
+
// the PRD's own body must remain sufficient to complete the job on its own.
|
|
2275
|
+
const digestEpicId = job.epicId ?? job.sourcePromptId ?? null;
|
|
2276
|
+
const originSessionId = resolveOriginSessionId(cwd, digestEpicId);
|
|
2277
|
+
let contextDigestApplied = false;
|
|
2278
|
+
if (originSessionId) {
|
|
2279
|
+
try {
|
|
2280
|
+
const digestText = await buildContextDigest({ cwd, epicId: digestEpicId });
|
|
2281
|
+
if (digestText) {
|
|
2282
|
+
prompt = digestText + '\n\n' + prompt;
|
|
2283
|
+
contextDigestApplied = true;
|
|
2284
|
+
}
|
|
2285
|
+
} catch (e) {
|
|
2286
|
+
safeLog(`[scheduler] context digest build failed (job still dispatches without it): ${e?.message ?? e}\n`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2112
2290
|
const promptCheck = validatePromptForSpawn(prompt, prdPath);
|
|
2113
2291
|
if (!promptCheck.ok) {
|
|
2114
2292
|
safeLog(`[scheduler] ${promptCheck.error}\n`);
|
|
@@ -2262,7 +2440,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2262
2440
|
sl(`\n[scheduler] ${errMsg}\n`);
|
|
2263
2441
|
// Sync write: inside a Promise executor callback; must flush meta
|
|
2264
2442
|
// before resolve() so the spawnJob mutate() that follows sees it.
|
|
2265
|
-
config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs, schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA });
|
|
2443
|
+
config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs, schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA, originSessionId, contextDigestApplied });
|
|
2266
2444
|
resolve({ exitCode: -1, durationMs, error: errMsg, sessionId });
|
|
2267
2445
|
return;
|
|
2268
2446
|
}
|
|
@@ -2293,6 +2471,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2293
2471
|
startedAt, finishedAt: Date.now(), durationMs,
|
|
2294
2472
|
agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${exitCode}` : null,
|
|
2295
2473
|
schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA,
|
|
2474
|
+
originSessionId, contextDigestApplied,
|
|
2296
2475
|
});
|
|
2297
2476
|
resolve({ exitCode: effectiveCode, durationMs, rateLimited, networkError, sessionId });
|
|
2298
2477
|
},
|
|
@@ -2610,19 +2789,19 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
2610
2789
|
return;
|
|
2611
2790
|
}
|
|
2612
2791
|
sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
|
|
2613
|
-
// Fold the investigation's <RCA> summary into the
|
|
2614
|
-
//
|
|
2792
|
+
// Fold the investigation's <RCA> summary into the root-cause report already
|
|
2793
|
+
// written for this job (needs_review jobs only — writeRcaReport no-ops when
|
|
2615
2794
|
// failedJob has no verifierVerdict, e.g. plain 'failed' jobs never got one).
|
|
2616
2795
|
const investigationText = extractRcaBlock(readTail(investigationLogPath, 64 * 1024));
|
|
2617
2796
|
if (investigationText) {
|
|
2618
|
-
|
|
2797
|
+
writeRcaReport({
|
|
2619
2798
|
job: failedJob,
|
|
2620
2799
|
runDir,
|
|
2621
2800
|
verdict: failedJob.verifierVerdict,
|
|
2622
2801
|
annotations: failedJob.verifierAnnotations,
|
|
2623
2802
|
investigationText,
|
|
2624
2803
|
}).catch((e) => {
|
|
2625
|
-
console.error('[scheduler]
|
|
2804
|
+
console.error('[scheduler] writeRcaReport (investigation enrichment) error', failedJob.slug, e);
|
|
2626
2805
|
});
|
|
2627
2806
|
}
|
|
2628
2807
|
if (fs.existsSync(fixPath)) {
|
|
@@ -3006,14 +3185,16 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
3006
3185
|
if (needsReviewRcaSnapshot) {
|
|
3007
3186
|
// Fire-and-forget, mirroring the DoD drain hook: never blocks the status
|
|
3008
3187
|
// transition, never throws to this caller.
|
|
3009
|
-
|
|
3188
|
+
writeRcaReport({
|
|
3010
3189
|
job: needsReviewRcaSnapshot,
|
|
3011
3190
|
runDir,
|
|
3012
3191
|
verdict: needsReviewRcaSnapshot.verifierVerdict,
|
|
3013
3192
|
annotations: needsReviewRcaSnapshot.verifierAnnotations,
|
|
3014
|
-
})
|
|
3015
|
-
|
|
3016
|
-
|
|
3193
|
+
})
|
|
3194
|
+
.then((report) => notifyNeedsReview(needsReviewRcaSnapshot, report))
|
|
3195
|
+
.catch((e) => {
|
|
3196
|
+
console.error('[scheduler] writeRcaReport error', job.slug, e);
|
|
3197
|
+
});
|
|
3017
3198
|
}
|
|
3018
3199
|
|
|
3019
3200
|
if (actuallyFailed && failedJobSnapshot) {
|
|
@@ -3125,20 +3306,34 @@ function tickQueue() {
|
|
|
3125
3306
|
}
|
|
3126
3307
|
if (state.paused) {
|
|
3127
3308
|
console.log('[scheduler] tickQueue skipped: paused');
|
|
3128
|
-
return { fired: false, reason: 'paused' };
|
|
3309
|
+
return recordTick({ fired: false, reason: 'paused' }, { detail: 'scheduler paused' });
|
|
3129
3310
|
}
|
|
3130
3311
|
if (cancelToken.cancelled) return { fired: false, reason: 'cancelled' };
|
|
3131
3312
|
|
|
3132
3313
|
await reconcile(state);
|
|
3133
|
-
|
|
3134
|
-
|
|
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
|
+
}
|
|
3135
3330
|
if (batch.length === 0) {
|
|
3136
3331
|
// Queue drained — run the definition-of-done gate fire-and-forget.
|
|
3137
3332
|
// Non-blocking: does not hold the mutate lock; errors are logged, not thrown.
|
|
3138
3333
|
runDefinitionOfDoneOnDrain(state, { cancelToken }).catch((err) => {
|
|
3139
3334
|
console.log(`[scheduler] dod-drain: ${err?.message ?? String(err)}`);
|
|
3140
3335
|
});
|
|
3141
|
-
if (holdReason) return { fired: false, reason: 'held', detail: holdReason };
|
|
3336
|
+
if (holdReason) return recordTick({ fired: false, reason: 'held', detail: holdReason }, { holds });
|
|
3142
3337
|
// Distinguish "genuinely nothing to do" from "the batch was already
|
|
3143
3338
|
// fired by a concurrent tickQueue() call" (e.g. the periodic
|
|
3144
3339
|
// when-available poll winning a race against a manual force-tick —
|
|
@@ -3151,9 +3346,9 @@ function tickQueue() {
|
|
|
3151
3346
|
state.jobs.filter((j) => j.status === 'running').length,
|
|
3152
3347
|
);
|
|
3153
3348
|
if (runningCount > 0) {
|
|
3154
|
-
return { fired: false, reason: 'already-running', runningCount };
|
|
3349
|
+
return recordTick({ fired: false, reason: 'already-running', runningCount }, { holds });
|
|
3155
3350
|
}
|
|
3156
|
-
return { fired: false, reason: 'drained' };
|
|
3351
|
+
return recordTick({ fired: false, reason: 'drained' }, { holds });
|
|
3157
3352
|
}
|
|
3158
3353
|
|
|
3159
3354
|
const availableMb = getAvailableMemMb();
|
|
@@ -3161,23 +3356,19 @@ function tickQueue() {
|
|
|
3161
3356
|
// job is never started into the host's own headroom (that path OOM-kills
|
|
3162
3357
|
// Electron and SIGHUPs every pty — 2026-06-16 incident).
|
|
3163
3358
|
const jobBudgetMb = availableForJobs(availableMb, RESERVED_HOST_MB);
|
|
3164
|
-
//
|
|
3165
|
-
//
|
|
3166
|
-
const
|
|
3167
|
-
|
|
3168
|
-
const snap = sessionSlots.snapshot();
|
|
3169
|
-
console.log(`[scheduler] slot gate: 0 of ${snap.total} session slots free (${snap.holders.map((h) => h.owner).join(', ')}) — deferring ${batch.length} job(s)`);
|
|
3170
|
-
return { fired: false, reason: 'slots-exhausted', deferredCount: batch.length, holders: snap.holders };
|
|
3171
|
-
}
|
|
3172
|
-
const allowed = Math.min(
|
|
3173
|
-
slotAllowed,
|
|
3174
|
-
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,
|
|
3175
3363
|
);
|
|
3176
3364
|
if (allowed === 0) {
|
|
3177
3365
|
const threshold = RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
|
|
3178
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)`);
|
|
3179
3367
|
lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
|
|
3180
|
-
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
|
+
);
|
|
3181
3372
|
}
|
|
3182
3373
|
const gatedBatch = batch.slice(0, allowed);
|
|
3183
3374
|
if (gatedBatch.length < batch.length) {
|
|
@@ -3198,7 +3389,7 @@ function tickQueue() {
|
|
|
3198
3389
|
// spawnJob is fire-and-forget; it calls tickQueue() on completion.
|
|
3199
3390
|
spawnJob(job, runId, runDir, state.config.defaultCwd).catch(() => {});
|
|
3200
3391
|
}
|
|
3201
|
-
return { fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup };
|
|
3392
|
+
return recordTick({ fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup }, { holds });
|
|
3202
3393
|
});
|
|
3203
3394
|
tickTail = next.catch(() => {});
|
|
3204
3395
|
return next;
|
|
@@ -3569,6 +3760,50 @@ function isUnresolvableNeedsReview(job, { hasRunDir }) {
|
|
|
3569
3760
|
* commit-guard) — verifyRun can't see git, so re-scanning it would falsely
|
|
3570
3761
|
* heal an unfinished job. Exported for tests.
|
|
3571
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
|
+
|
|
3572
3807
|
function isRescanCandidate(job) {
|
|
3573
3808
|
return !!job
|
|
3574
3809
|
&& job.status === 'needs_review'
|
|
@@ -3670,7 +3905,10 @@ async function reverifyNeedsReview() {
|
|
|
3670
3905
|
priorLandedCommit,
|
|
3671
3906
|
});
|
|
3672
3907
|
} catch { leftForReview.push({ slug: job.slug, reason: 'verifyRun threw' }); continue; }
|
|
3673
|
-
|
|
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)) {
|
|
3674
3912
|
healed.push(job.slug);
|
|
3675
3913
|
} else {
|
|
3676
3914
|
leftForReview.push({ slug: job.slug, reason: v ? `${v.verdict}: ${v.reason}` : 'null verdict' });
|
|
@@ -4399,12 +4637,9 @@ const remote = {
|
|
|
4399
4637
|
if (candidate && fs.existsSync(candidate)) { dir = d; break; }
|
|
4400
4638
|
}
|
|
4401
4639
|
if (!dir) {
|
|
4402
|
-
// Every PRD must join an EXISTING,
|
|
4403
|
-
//
|
|
4404
|
-
//
|
|
4405
|
-
// /propose-epic + Approve & start) may write PRDs; this write path
|
|
4406
|
-
// is not itself a human-intent gate, so it must not become one by
|
|
4407
|
-
// accident.
|
|
4640
|
+
// Every PRD must join an EXISTING, human-created Epic — ensureEpic
|
|
4641
|
+
// is join-only for every caller but the New Epic UI (epicMint.cjs's
|
|
4642
|
+
// SINGLE-CREATOR LAW), so this can never conjure one.
|
|
4408
4643
|
const { fm } = splitFrontmatter(body);
|
|
4409
4644
|
try {
|
|
4410
4645
|
const epic = await ensureEpic(cwd, {
|
|
@@ -4415,11 +4650,10 @@ const remote = {
|
|
|
4415
4650
|
// a literal-equality join. Any other id (e.g. a PromptTicket.id)
|
|
4416
4651
|
// simply won't match and this call throws below.
|
|
4417
4652
|
epicId: fm.sourcePromptId,
|
|
4418
|
-
|
|
4419
|
-
// Join-only call (mintIfMissing:false, explicit epicId): the
|
|
4653
|
+
// Join-only call (no mintAuthority, explicit epicId): the
|
|
4420
4654
|
// explicitEpicId branch in ensureEpic returns before `source` is
|
|
4421
4655
|
// ever read, so this is a no-op today — kept for symmetry/audit
|
|
4422
|
-
// trail if that join path ever grows a source-touch
|
|
4656
|
+
// trail if that join path ever grows a source-touch.
|
|
4423
4657
|
source: { producer: 'scheduler-dispatch', prdSlug: slug },
|
|
4424
4658
|
});
|
|
4425
4659
|
dir = epic.prdDir;
|
|
@@ -4428,7 +4662,7 @@ const remote = {
|
|
|
4428
4662
|
return {
|
|
4429
4663
|
ok: false,
|
|
4430
4664
|
error: `no existing Epic to join (sourcePromptId=${fm.sourcePromptId ?? 'none'}): ${e?.message ?? 'unknown error'}. `
|
|
4431
|
-
+ 'Create the Epic first via the New Epic UI
|
|
4665
|
+
+ 'Create the Epic first via the New Epic UI, then pass its id as sourcePromptId.',
|
|
4432
4666
|
};
|
|
4433
4667
|
}
|
|
4434
4668
|
}
|
|
@@ -4438,7 +4672,7 @@ const remote = {
|
|
|
4438
4672
|
if (dir === PRDS_DIR) ensureDirs();
|
|
4439
4673
|
}
|
|
4440
4674
|
|
|
4441
|
-
// writePrd only ever JOINS an existing Epic now (
|
|
4675
|
+
// writePrd only ever JOINS an existing Epic now (no mintAuthority
|
|
4442
4676
|
// above) — it can never mint one, so there is no orphaned-mint case left
|
|
4443
4677
|
// to roll back here (contrast the old PRD 825/851 cleanup, removed).
|
|
4444
4678
|
const resolved = safeSlugPathIn(dir, slug);
|
|
@@ -4540,4 +4774,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
4540
4774
|
});
|
|
4541
4775
|
}
|
|
4542
4776
|
|
|
4543
|
-
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, 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 };
|