claude-code-session-manager 0.86.0 → 0.87.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/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +14 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +1 -1
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerConfig.cjs +24 -0
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1285 -143
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
- package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
- package/dist/assets/Permissions-BiYZNGYW.js +0 -3
- package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
- package/dist/assets/Settings-Cv-pRyms.js +0 -3
- package/dist/assets/settingsSchema-BJVciriw.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -12,12 +12,22 @@
|
|
|
12
12
|
* live. Active-project discovery reuses activeSessions.cjs's
|
|
13
13
|
* activeProjectCwds (the same discovery watchdogHelpers.cjs's sweep() uses)
|
|
14
14
|
* rather than re-implementing transcript scanning here.
|
|
15
|
+
*
|
|
16
|
+
* CACHING NOTE (PRD adding mtime-keyed memoization to this module): what's
|
|
17
|
+
* cached here is only WHICH DIRECTORIES EXIST, never their CONTENTS. A new
|
|
18
|
+
* PRD .md file dropped into an already-known `prds/` dir does not change
|
|
19
|
+
* that dir's own existence, so it needs no cache invalidation here at all —
|
|
20
|
+
* `schedule:rescan` (scheduler.cjs's `ipcMain.handle('schedule:rescan', …)`)
|
|
21
|
+
* still reports it because the actual file-listing happens one layer down,
|
|
22
|
+
* in scheduler/prdParser.cjs's `listPrdFiles(dir)`, which has its OWN
|
|
23
|
+
* separate mtime cache keyed on that specific dir's mtime (bumped by the new
|
|
24
|
+
* file) and is unaffected by anything cached in this module.
|
|
15
25
|
*/
|
|
16
26
|
'use strict';
|
|
17
27
|
|
|
18
28
|
const fs = require('node:fs');
|
|
19
29
|
const path = require('node:path');
|
|
20
|
-
const { activeProjectCwds, allProjectCwds } = require('
|
|
30
|
+
const { activeProjectCwds, allProjectCwds } = require('./activeSessions.cjs');
|
|
21
31
|
|
|
22
32
|
const { opsPath, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
23
33
|
|
|
@@ -53,18 +63,49 @@ function resolveEpicPrdWriteDir(cwd, epicId) {
|
|
|
53
63
|
return path.join(resolveEpicsRoot(cwd), epicId, 'prds');
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
// listEpicPrdDirs' readdir-the-Epics-root-plus-existsSync-per-subdir walk is
|
|
67
|
+
// the expensive half of prdLocations' per-reconcile()-pass cost (159/166
|
|
68
|
+
// dirs, ~430ms/~400ms measured). A new Epic's prds/ dir is always created in
|
|
69
|
+
// the SAME recursive mkdirSync call that creates `epics/<newId>/` itself
|
|
70
|
+
// (epicMint.cjs's ensureEpic, both the mint branch and the join branch's
|
|
71
|
+
// belt-and-suspenders mkdirSync) — so a brand-new `epics/<id>/prds` dir
|
|
72
|
+
// first coming into existence always bumps the Epics root's OWN mtime (a
|
|
73
|
+
// new directory entry under it), never just an existing subdir's mtime.
|
|
74
|
+
// Cache keyed on that mtime, same dir-mtime idiom as
|
|
75
|
+
// scheduler/prdParser.cjs's dirCache — no TTL, so the very next call after a
|
|
76
|
+
// mint sees the new dir with no wait.
|
|
77
|
+
const epicPrdDirsCache = new Map(); // epicsRootPath -> { mtimeMs, result }
|
|
78
|
+
|
|
56
79
|
/** Enumerate every existing `epics/<id>/prds` dir under one project cwd. */
|
|
57
80
|
function listEpicPrdDirs(cwd) {
|
|
58
81
|
let root;
|
|
59
82
|
try { root = resolveEpicsRoot(cwd); } catch { return []; }
|
|
83
|
+
let mtimeMs;
|
|
84
|
+
try {
|
|
85
|
+
mtimeMs = fs.statSync(root).mtimeMs;
|
|
86
|
+
} catch {
|
|
87
|
+
// Missing/unreadable root: nothing to scan, and any prior cache entry
|
|
88
|
+
// must not survive to be handed back once the dir later appears.
|
|
89
|
+
epicPrdDirsCache.delete(root);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const cached = epicPrdDirsCache.get(root);
|
|
93
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.result;
|
|
94
|
+
|
|
60
95
|
let entries;
|
|
61
|
-
try {
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
epicPrdDirsCache.delete(root);
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
62
102
|
const dirs = [];
|
|
63
103
|
for (const ent of entries) {
|
|
64
104
|
if (!ent.isDirectory()) continue;
|
|
65
105
|
const prdDir = path.join(root, ent.name, 'prds');
|
|
66
106
|
if (fs.existsSync(prdDir)) dirs.push(prdDir);
|
|
67
107
|
}
|
|
108
|
+
epicPrdDirsCache.set(root, { mtimeMs, result: dirs });
|
|
68
109
|
return dirs;
|
|
69
110
|
}
|
|
70
111
|
|
|
@@ -101,6 +142,83 @@ function resolvePrdWriteDir(cwd) {
|
|
|
101
142
|
return opsPath(cwd, ...PRD_SUBPATH);
|
|
102
143
|
}
|
|
103
144
|
|
|
145
|
+
// resolvePrdsDirs/resolveArchivedPrdsDirs each redo a fs.existsSync(dir) +
|
|
146
|
+
// listEpicPrdDirs(cwd) walk across EVERY project on every call, and
|
|
147
|
+
// reconcile() calls each of them once per pass. Cache the assembled result
|
|
148
|
+
// keyed on the caller's own (maxAgeMin, opts) tuple — a call with an
|
|
149
|
+
// explicit `{ projectsDir }` opts object is a DIFFERENT key from the
|
|
150
|
+
// no-opts default, so the two never read or write each other's entry — PLUS
|
|
151
|
+
// a freshness key built from every visited project's Epics-root mtimeMs,
|
|
152
|
+
// same composite-mtime-key idiom as queueHistory.cjs's historyCacheKey. The
|
|
153
|
+
// freshness key is cheap to rebuild every call (one statSync per project,
|
|
154
|
+
// far cheaper than the readdir+existsSync-per-Epic walk it guards), so a
|
|
155
|
+
// cache hit still costs O(projects) stats but skips the expensive walk
|
|
156
|
+
// entirely — "one filesystem walk" per reconcile() pass, not the full
|
|
157
|
+
// listEpicPrdDirs cost N times over.
|
|
158
|
+
//
|
|
159
|
+
// The Epics-root mtime alone has a real blind spot: `prds-archived/` is
|
|
160
|
+
// created (archiveCompletedPrd, scheduler.cjs) as a NEW entry INSIDE an
|
|
161
|
+
// ALREADY-EXISTING `epics/<id>/` dir — unlike a brand-new Epic's `prds/`,
|
|
162
|
+
// which is always created in the SAME mkdirSync call as `epics/<id>/`
|
|
163
|
+
// itself (see listEpicPrdDirs's header), that does not bump the Epics
|
|
164
|
+
// root's own mtime. Re-stating every Epic subdirectory's own mtime on every
|
|
165
|
+
// call to close that gap would cost about what the walk itself costs,
|
|
166
|
+
// defeating the cache — so this cache pairs the mtime key (instant
|
|
167
|
+
// invalidation for the case the non-negotiable freshness test covers: a
|
|
168
|
+
// brand-new Epic's `prds/` dir) with the SAME short TTL backstop
|
|
169
|
+
// src/main/lib/activeSessions.cjs's cwdScanCache already established for
|
|
170
|
+
// this exact module chain, bounding the archived-dir blind spot to one
|
|
171
|
+
// TTL window instead of leaving it stale indefinitely. Nothing reads
|
|
172
|
+
// resolveArchivedPrdsDirs off the dispatch-correctness path — the
|
|
173
|
+
// archived-twin stale-queue-row guard (scheduler.cjs's archivedTwinExists)
|
|
174
|
+
// calls listArchivedPrdDirs directly, uncached — so this is purely a
|
|
175
|
+
// reporting-freshness bound (schedule:list-prds's per-Epic PRD/run counts).
|
|
176
|
+
const ASSEMBLED_DIRS_CACHE_TTL_MS = 30_000;
|
|
177
|
+
const assembledPrdsDirsCache = new Map(); // callerKey -> { freshnessKey, cachedAt, result }
|
|
178
|
+
const assembledArchivedPrdsDirsCache = new Map();
|
|
179
|
+
|
|
180
|
+
// Sorted-keys JSON.stringify: a plain JSON.stringify(opts) would key
|
|
181
|
+
// { a: 1, b: 2 } and { b: 2, a: 1 } as two different cache entries — a real
|
|
182
|
+
// (if currently latent, since every caller today passes at most one opts
|
|
183
|
+
// key) correctness trap for a cache key.
|
|
184
|
+
function stableStringify(obj) {
|
|
185
|
+
return JSON.stringify(obj, Object.keys(obj).sort());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function callerOptsKey(maxAgeMin, opts) {
|
|
189
|
+
return `${maxAgeMin}::${opts ? stableStringify(opts) : ''}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// mtimeMs of each visited project's Epics root, joined into one string. Any
|
|
193
|
+
// project whose root doesn't exist/isn't readable contributes a stable
|
|
194
|
+
// sentinel so the key still changes the moment that root is created.
|
|
195
|
+
function epicsRootsFreshnessKey(cwds) {
|
|
196
|
+
return cwds
|
|
197
|
+
.map((cwd) => {
|
|
198
|
+
let root;
|
|
199
|
+
try { root = resolveEpicsRoot(cwd); } catch { return `${cwd}:noroot`; }
|
|
200
|
+
try { return `${root}:${fs.statSync(root).mtimeMs}`; } catch { return `${root}:-1`; }
|
|
201
|
+
})
|
|
202
|
+
.join('|');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function memoizedAssembledDirs(cache, maxAgeMin, opts, compute) {
|
|
206
|
+
const callerKey = callerOptsKey(maxAgeMin, opts);
|
|
207
|
+
const allCwds = allProjectCwds(opts);
|
|
208
|
+
const activeCwds = activeProjectCwds(maxAgeMin, opts);
|
|
209
|
+
const cwdUnion = [...new Set([...allCwds, ...activeCwds])];
|
|
210
|
+
const freshnessKey = `${allCwds.join(',')}#${activeCwds.join(',')}#${epicsRootsFreshnessKey(cwdUnion)}`;
|
|
211
|
+
|
|
212
|
+
const cached = cache.get(callerKey);
|
|
213
|
+
if (cached && cached.freshnessKey === freshnessKey && Date.now() - cached.cachedAt < ASSEMBLED_DIRS_CACHE_TTL_MS) {
|
|
214
|
+
return cached.result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = compute(allCwds, activeCwds);
|
|
218
|
+
cache.set(callerKey, { freshnessKey, cachedAt: Date.now(), result });
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
104
222
|
/**
|
|
105
223
|
* resolvePrdsDirs(maxAgeMin?, opts?) → string[]
|
|
106
224
|
*
|
|
@@ -122,34 +240,36 @@ function resolvePrdWriteDir(cwd) {
|
|
|
122
240
|
* underlying scan (e.g. `projectsDir` override for tests).
|
|
123
241
|
*/
|
|
124
242
|
function resolvePrdsDirs(maxAgeMin, opts) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// Every historical project that has a PRD dir on disk — the set that
|
|
134
|
-
// matters for discovery, regardless of when it was last touched.
|
|
135
|
-
for (const cwd of allProjectCwds(opts)) {
|
|
136
|
-
let dir;
|
|
137
|
-
try { dir = resolvePrdWriteDir(cwd); } catch { continue; }
|
|
138
|
-
if (fs.existsSync(dir)) add(dir);
|
|
139
|
-
// Epic-scoped dirs (the write layout for all new PRDs) are first-class
|
|
140
|
-
// scan sources alongside the legacy flat dir.
|
|
141
|
-
for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
|
|
142
|
-
}
|
|
243
|
+
return memoizedAssembledDirs(assembledPrdsDirsCache, maxAgeMin, opts, (allCwds, activeCwds) => {
|
|
244
|
+
const dirs = [];
|
|
245
|
+
const seen = new Set();
|
|
246
|
+
const add = (dir) => {
|
|
247
|
+
if (seen.has(dir)) return;
|
|
248
|
+
seen.add(dir);
|
|
249
|
+
dirs.push(dir);
|
|
250
|
+
};
|
|
143
251
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
252
|
+
// Every historical project that has a PRD dir on disk — the set that
|
|
253
|
+
// matters for discovery, regardless of when it was last touched.
|
|
254
|
+
for (const cwd of allCwds) {
|
|
255
|
+
let dir;
|
|
256
|
+
try { dir = resolvePrdWriteDir(cwd); } catch { continue; }
|
|
257
|
+
if (fs.existsSync(dir)) add(dir);
|
|
258
|
+
// Epic-scoped dirs (the write layout for all new PRDs) are first-class
|
|
259
|
+
// scan sources alongside the legacy flat dir.
|
|
260
|
+
for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
|
|
261
|
+
}
|
|
151
262
|
|
|
152
|
-
|
|
263
|
+
// Active projects are added unconditionally: a brand-new project has no
|
|
264
|
+
// prds/ dir yet, and callers that resolve a write destination must still
|
|
265
|
+
// find it. Scans over a non-existent dir are a harmless ENOENT no-op.
|
|
266
|
+
for (const cwd of activeCwds) {
|
|
267
|
+
try { add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
|
|
268
|
+
for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return dirs;
|
|
272
|
+
});
|
|
153
273
|
}
|
|
154
274
|
|
|
155
275
|
/**
|
|
@@ -163,26 +283,28 @@ function resolvePrdsDirs(maxAgeMin, opts) {
|
|
|
163
283
|
* history, not something list-prds should stop counting.
|
|
164
284
|
*/
|
|
165
285
|
function resolveArchivedPrdsDirs(maxAgeMin, opts) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
for (const
|
|
176
|
-
|
|
286
|
+
return memoizedAssembledDirs(assembledArchivedPrdsDirsCache, maxAgeMin, opts, (allCwds, activeCwds) => {
|
|
287
|
+
const dirs = [];
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
const add = (dir) => {
|
|
290
|
+
if (seen.has(dir)) return;
|
|
291
|
+
seen.add(dir);
|
|
292
|
+
dirs.push(dir);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
for (const cwd of allCwds) {
|
|
296
|
+
for (const dir of listArchivedPrdDirs(cwd)) {
|
|
297
|
+
if (fs.existsSync(dir)) add(dir);
|
|
298
|
+
}
|
|
177
299
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
300
|
+
for (const cwd of activeCwds) {
|
|
301
|
+
for (const dir of listArchivedPrdDirs(cwd)) {
|
|
302
|
+
if (fs.existsSync(dir)) add(dir);
|
|
303
|
+
}
|
|
182
304
|
}
|
|
183
|
-
}
|
|
184
305
|
|
|
185
|
-
|
|
306
|
+
return dirs;
|
|
307
|
+
});
|
|
186
308
|
}
|
|
187
309
|
|
|
188
310
|
/**
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* first) — which is exactly why generation silently hung on a foreign
|
|
8
8
|
* machine with no repo checked out. These four routes make the app itself
|
|
9
9
|
* the authority on paths/schema/catalog/rendering, reusing the shipped
|
|
10
|
-
* assets PRD 1088 put in the npm tarball (
|
|
11
|
-
* renderer.cjs,
|
|
10
|
+
* assets PRD 1088 put in the npm tarball (web/project-pages/renderer/dist/
|
|
11
|
+
* renderer.cjs, web/project-pages/logic/dist/logic.cjs,
|
|
12
12
|
* src/main/templates/project-pages-catalog.json,
|
|
13
13
|
* src/main/templates/project-pages-pipeline.md) rather than reimplementing
|
|
14
14
|
* any of them.
|
|
@@ -40,8 +40,8 @@ const LENSES = ['home', 'marketing', 'feature', 'architecture', 'brief'];
|
|
|
40
40
|
// requires an app restart to pick up during dev, same as that module).
|
|
41
41
|
const CATALOG_PATH = path.join(__dirname, '..', 'templates', 'project-pages-catalog.json');
|
|
42
42
|
const SPEC_PATH = path.join(__dirname, '..', 'templates', 'project-pages-pipeline.md');
|
|
43
|
-
const RENDERER_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', '
|
|
44
|
-
const LOGIC_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', '
|
|
43
|
+
const RENDERER_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', 'web', 'project-pages', 'renderer', 'dist', 'renderer.cjs');
|
|
44
|
+
const LOGIC_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', 'web', 'project-pages', 'logic', 'dist', 'logic.cjs');
|
|
45
45
|
|
|
46
46
|
let cachedCatalog = null;
|
|
47
47
|
function loadCatalog() {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* This schema is DESCRIPTIVE, not the enforcement mechanism — the actual
|
|
9
9
|
* gate a posted summary must pass is `validateProjectPageSummary` (shipped
|
|
10
|
-
* in
|
|
10
|
+
* in web/project-pages/logic/dist/logic.cjs, called by
|
|
11
11
|
* /admin/project-home/validate-summary and /admin/project-home/render).
|
|
12
12
|
* Kept hand-written rather than derived at runtime so the contract route
|
|
13
13
|
* never needs to load the TypeScript source; if summaryType.ts's shape
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
const path = require('node:path');
|
|
37
|
-
const { projectRootOf, allProjectCwds } = require('
|
|
37
|
+
const { projectRootOf, allProjectCwds } = require('./activeSessions.cjs');
|
|
38
38
|
const { readActiveIndex } = require('./epicMint.cjs');
|
|
39
39
|
const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
|
|
40
40
|
|
|
@@ -53,8 +53,15 @@ function historyPathFor(entry) {
|
|
|
53
53
|
return HISTORY_PATH;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// `kind` keeps a needs_review_entry/needs_review_resolution line (see
|
|
57
|
+
// needsReviewLedger.cjs) from colliding with a TERMINAL row that shares the
|
|
58
|
+
// same slug+runId — an entry and its resolution deliberately reference the
|
|
59
|
+
// SAME runId (that's how they're paired), so without `kind` in the dedupe
|
|
60
|
+
// key the second append would look like a replay of the first and silently
|
|
61
|
+
// never be written. Absent `kind` (every existing terminal row) buckets
|
|
62
|
+
// under 'terminal', so today's on-disk rows and callers are unaffected.
|
|
56
63
|
function jobKey(job) {
|
|
57
|
-
return `${job?.slug ?? ''}|${job?.runId ?? ''}`;
|
|
64
|
+
return `${job?.slug ?? ''}|${job?.runId ?? ''}|${job?.kind ?? 'terminal'}`;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
@@ -259,6 +266,12 @@ async function historyTerminalBySlug() {
|
|
|
259
266
|
if (!line.trim()) continue;
|
|
260
267
|
try {
|
|
261
268
|
const j = JSON.parse(line);
|
|
269
|
+
// needs_review_entry/needs_review_resolution lines (needsReviewLedger.cjs)
|
|
270
|
+
// share this file but aren't a job's terminal record — skip them, or a
|
|
271
|
+
// needs_review episode logged for a slug AFTER its real terminal row
|
|
272
|
+
// (e.g. it re-parked on a later run) would overwrite a true 'completed'
|
|
273
|
+
// with a kind-less, status-less row read as "not terminal".
|
|
274
|
+
if (j?.kind && j.kind !== 'terminal') continue;
|
|
262
275
|
if (j?.slug) map.set(j.slug, { status: j.status, finishedAt: j.finishedAt, landedCommit: j.landedCommit ?? null });
|
|
263
276
|
} catch {
|
|
264
277
|
// corrupt/partial line — ignore
|
|
@@ -301,6 +314,11 @@ async function completedSlugsForCwd(cwd) {
|
|
|
301
314
|
if (!line.trim()) continue;
|
|
302
315
|
try {
|
|
303
316
|
const j = JSON.parse(line);
|
|
317
|
+
// Skip needs_review ledger lines (needsReviewLedger.cjs) — they carry
|
|
318
|
+
// no `status` field, so treating them as a real record would let a
|
|
319
|
+
// later needs_review park/resolution silently blank out this slug's
|
|
320
|
+
// last true terminal status.
|
|
321
|
+
if (j?.kind && j.kind !== 'terminal') continue;
|
|
304
322
|
if (j?.slug) statusBySlug.set(j.slug, j.status);
|
|
305
323
|
} catch {
|
|
306
324
|
// corrupt/partial line — ignore
|
|
@@ -33,7 +33,7 @@ const fsp = require('node:fs/promises');
|
|
|
33
33
|
const path = require('node:path');
|
|
34
34
|
const os = require('node:os');
|
|
35
35
|
const crypto = require('node:crypto');
|
|
36
|
-
const { allProjectCwds, activeProjectCwds } = require('
|
|
36
|
+
const { allProjectCwds, activeProjectCwds, bustProjectCwdCache } = require('./activeSessions.cjs');
|
|
37
37
|
const { assertOpsWrite, resolveOpsRoot, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
38
38
|
const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
|
|
39
39
|
|
|
@@ -211,6 +211,11 @@ function stateCwds(opts) {
|
|
|
211
211
|
|
|
212
212
|
function bustCwdCache() {
|
|
213
213
|
cwdCache = { at: 0, cwds: [] };
|
|
214
|
+
// Without this chain, activeSessions' own scan cache hands stateCwds a
|
|
215
|
+
// stale list even after this bust — turning a caller's "I just wrote a
|
|
216
|
+
// brand-new project's first state file, make it visible now" into a
|
|
217
|
+
// no-op until activeSessions' independent TTL expires.
|
|
218
|
+
bustProjectCwdCache();
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
// ---------- merged read ----------
|
|
@@ -36,26 +36,37 @@ function claudePidAlive(pid) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* findLiveProcessForJob(job, { worktreeDir }) → pid | null
|
|
39
|
+
* findLiveProcessForJob(job, { worktreeDir, runCwd }) → pid | null
|
|
40
40
|
*
|
|
41
41
|
* Positive liveness scan for a 'running' row whose `runtime.pid` is missing —
|
|
42
42
|
* the case a missing pid must NOT be read as "the process is gone" (2026-09-06
|
|
43
43
|
* incident: 234-uranus-eight-tails-ox marked failed/never_ran while PID
|
|
44
44
|
* 2174739 was a live `claude -p` still writing to that job's own worktree).
|
|
45
45
|
*
|
|
46
|
-
* Linux-`/proc` only. Scans every numeric `/proc/<pid>` entry and matches
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
46
|
+
* Linux-`/proc` only. Scans every numeric `/proc/<pid>` entry and matches any
|
|
47
|
+
* of, in order:
|
|
48
|
+
* - `/proc/<pid>/cwd` resolves to `worktreeDir` (or a path nested under it)
|
|
49
|
+
* — sufficient alone, no cmdline check needed.
|
|
50
|
+
* - `/proc/<pid>/cmdline` contains both `claude` and the job's slug —
|
|
51
|
+
* fallback for a worktree-disabled/in-place run with no dedicated
|
|
52
|
+
* worktreeDir to match against.
|
|
53
|
+
* - `/proc/<pid>/cwd` resolves to `runCwd` (or nested under it) AND
|
|
54
|
+
* `/proc/<pid>/cmdline` contains `claude` (2026-09-13 incident:
|
|
55
|
+
* runId 2026-09-13T16-15-56-818Z ran in-place in the shared tree — no
|
|
56
|
+
* worktree, and dispatch never puts the slug into argv — so neither of
|
|
57
|
+
* the above could ever match a live pid 3138458). `runCwd` is deliberately
|
|
58
|
+
* NEVER matched alone: several concurrent jobs commonly share one tree's
|
|
59
|
+
* cwd, so a bare cwd match without the process-identity check would
|
|
60
|
+
* cross-match the wrong job's process.
|
|
61
|
+
*
|
|
62
|
+
* Returns the first matching pid, or null if none is found.
|
|
52
63
|
*
|
|
53
64
|
* Safe fallback by construction: on any platform without `/proc` (macOS,
|
|
54
65
|
* Windows) `fs.readdirSync('/proc')` throws and this returns null immediately
|
|
55
66
|
* — i.e. exactly today's behaviour (fail toward terminalizing), never a hang
|
|
56
67
|
* or a thrown error propagating to the caller.
|
|
57
68
|
*/
|
|
58
|
-
function findLiveProcessForJob(job, { worktreeDir } = {}) {
|
|
69
|
+
function findLiveProcessForJob(job, { worktreeDir, runCwd } = {}) {
|
|
59
70
|
const slug = job?.slug;
|
|
60
71
|
let entries;
|
|
61
72
|
try {
|
|
@@ -67,27 +78,107 @@ function findLiveProcessForJob(job, { worktreeDir } = {}) {
|
|
|
67
78
|
if (!/^\d+$/.test(name)) continue;
|
|
68
79
|
const pid = Number(name);
|
|
69
80
|
if (!pid || pid <= 1) continue;
|
|
70
|
-
|
|
81
|
+
|
|
82
|
+
let cwdLink = null;
|
|
83
|
+
if (worktreeDir || runCwd) {
|
|
71
84
|
try {
|
|
72
|
-
|
|
73
|
-
if (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep)) return pid;
|
|
85
|
+
cwdLink = fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
74
86
|
} catch {
|
|
75
87
|
// Process exited mid-scan, or permission denied — try the argv
|
|
76
88
|
// fallback below before giving up on this pid.
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
|
-
|
|
91
|
+
|
|
92
|
+
if (worktreeDir && cwdLink && (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep))) {
|
|
93
|
+
return pid;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let cmd = null;
|
|
97
|
+
if (slug || (runCwd && cwdLink)) {
|
|
80
98
|
try {
|
|
81
|
-
|
|
82
|
-
if (/\bclaude\b/.test(cmd) && cmd.includes(slug)) return pid;
|
|
99
|
+
cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
83
100
|
} catch {
|
|
84
101
|
// Same as above — process gone or unreadable, keep scanning.
|
|
85
102
|
}
|
|
86
103
|
}
|
|
104
|
+
|
|
105
|
+
if (slug && cmd && /\bclaude\b/.test(cmd) && cmd.includes(slug)) return pid;
|
|
106
|
+
|
|
107
|
+
if (runCwd && cwdLink && cmd && /\bclaude\b/.test(cmd) &&
|
|
108
|
+
(cwdLink === runCwd || cwdLink.startsWith(runCwd + path.sep))) {
|
|
109
|
+
return pid;
|
|
110
|
+
}
|
|
87
111
|
}
|
|
88
112
|
return null;
|
|
89
113
|
}
|
|
90
114
|
|
|
115
|
+
/**
|
|
116
|
+
* readSpawnedPidFromLog(logPath) → pid | null
|
|
117
|
+
*
|
|
118
|
+
* Recovers the pid a pidless row's spawn actually produced by reading the
|
|
119
|
+
* run's own log — proof of life that pre-dates and is independent of the
|
|
120
|
+
* `runtime.pid` persistence this reap path exists to route around (2026-09-13
|
|
121
|
+
* incident: the mutate that stamps runtime.pid failed silently while the
|
|
122
|
+
* spawn itself, and this exact log line, succeeded). Reads only the first
|
|
123
|
+
* ~8KB — scheduler.cjs's spawnJob writes `[scheduler] spawned pid=<n> ...`
|
|
124
|
+
* synchronously right after fork, always near the top of the log — so this
|
|
125
|
+
* stays cheap against a multi-MB log. Pure IO, never throws: a missing file,
|
|
126
|
+
* an empty file, or a log with no such line all return null.
|
|
127
|
+
*/
|
|
128
|
+
function readSpawnedPidFromLog(logPath) {
|
|
129
|
+
if (!logPath) return null;
|
|
130
|
+
let fd;
|
|
131
|
+
try {
|
|
132
|
+
fd = fs.openSync(logPath, 'r');
|
|
133
|
+
const buf = Buffer.alloc(8192);
|
|
134
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
135
|
+
const text = buf.toString('utf8', 0, bytesRead);
|
|
136
|
+
const match = text.match(/\[scheduler\] spawned pid=(\d+)/);
|
|
137
|
+
if (!match) return null;
|
|
138
|
+
const pid = Number(match[1]);
|
|
139
|
+
return Number.isFinite(pid) && pid > 1 ? pid : null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
} finally {
|
|
143
|
+
if (fd !== undefined) {
|
|
144
|
+
try { fs.closeSync(fd); } catch { /* already closed / never opened */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* readLogMtimeMs(logPath) → number | null
|
|
151
|
+
*
|
|
152
|
+
* Same never-throws IO contract as logHasOutput — returns the run log's
|
|
153
|
+
* last-modified time, or null when it can't be statted (missing file,
|
|
154
|
+
* permission denied).
|
|
155
|
+
*/
|
|
156
|
+
function readLogMtimeMs(logPath) {
|
|
157
|
+
if (!logPath) return null;
|
|
158
|
+
try {
|
|
159
|
+
return fs.statSync(logPath).mtimeMs;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* isLogFresh(logMtimeMs, now, windowMs) → boolean
|
|
167
|
+
*
|
|
168
|
+
* Pure predicate: true when a run log was modified within `windowMs` of
|
|
169
|
+
* `now`. A log that grew recently is positive, independent proof its process
|
|
170
|
+
* is still alive — stronger evidence than "no runtime.pid recorded", which is
|
|
171
|
+
* only bookkeeping about a side-channel write that can fail on its own (see
|
|
172
|
+
* readSpawnedPidFromLog's header). Returns false for a missing/invalid mtime
|
|
173
|
+
* or window so a caller that couldn't stat the log falls through to the
|
|
174
|
+
* existing reap logic rather than treating "unknown" as "fresh".
|
|
175
|
+
*/
|
|
176
|
+
function isLogFresh(logMtimeMs, now, windowMs) {
|
|
177
|
+
if (typeof logMtimeMs !== 'number' || Number.isNaN(logMtimeMs)) return false;
|
|
178
|
+
if (typeof windowMs !== 'number' || windowMs <= 0) return false;
|
|
179
|
+
return (now - logMtimeMs) < windowMs;
|
|
180
|
+
}
|
|
181
|
+
|
|
91
182
|
/**
|
|
92
183
|
* True when `logPath` exists and has non-zero content — the literal "did the
|
|
93
184
|
* run dir produce any log output" check that gates whether a pidless reap may
|
|
@@ -224,6 +315,20 @@ const ORPHAN_REQUEUE_CAP = 5;
|
|
|
224
315
|
* work (2026-09-06 incident — see findLiveProcessForJob's header). Omitting
|
|
225
316
|
* `findLiveProcess` (existing callers/tests) preserves prior behaviour
|
|
226
317
|
* exactly: every pidless row past grace reaps, none are ever recovered.
|
|
318
|
+
*
|
|
319
|
+
* Two more optional injections, checked BEFORE `findLiveProcess`, for a
|
|
320
|
+
* pidless row past grace (2026-09-13 incident — see readSpawnedPidFromLog's
|
|
321
|
+
* and isLogFresh's own headers):
|
|
322
|
+
* - `getLogMtimeMs` (`(job) → number | null`): if the row's run log was
|
|
323
|
+
* modified within `logFreshWindowMs`, the row is skipped entirely — not
|
|
324
|
+
* pushed to `reapable` OR `recovered` — because a growing log is positive
|
|
325
|
+
* proof of life even with no pid evidence at all. One `warnings` entry is
|
|
326
|
+
* still emitted so a permanently-pidless-but-chatty row stays visible.
|
|
327
|
+
* - `getLogPid` (`(job) → pid | null`): reads the run's own log for the pid
|
|
328
|
+
* its spawn actually produced. If `pidAlive(pid)` is true, the row is
|
|
329
|
+
* diverted into `recovered` exactly like a `findLiveProcess` hit.
|
|
330
|
+
* Both default to a no-op when omitted, so existing callers/tests are
|
|
331
|
+
* unaffected.
|
|
227
332
|
*/
|
|
228
333
|
/**
|
|
229
334
|
* Pure, never-throws formatter for the dispatch-phase breadcrumb appended to
|
|
@@ -246,7 +351,47 @@ function formatDispatchPhaseSuffix(j) {
|
|
|
246
351
|
: ` (last dispatch phase: ${j.dispatchPhase})`;
|
|
247
352
|
}
|
|
248
353
|
|
|
249
|
-
|
|
354
|
+
/**
|
|
355
|
+
* resolvePidlessFailureOverride(job) → { verdict, landedCommit, reason } | null
|
|
356
|
+
*
|
|
357
|
+
* The evidence-before-failure guard this PRD adds (1173): a pidless reap
|
|
358
|
+
* about to stamp 'failed' purely because `runtime.pid` was never recorded
|
|
359
|
+
* must first check whether THIS ROW already carries a `landedCommit` — proof
|
|
360
|
+
* (from an earlier dispatch of the same slug, since `landedCommit` is
|
|
361
|
+
* deliberately never cleared by a reset — see scheduler.cjs's comment near
|
|
362
|
+
* resetJobFields) that a real commit landed on the branch. Returns null when
|
|
363
|
+
* there is no such evidence, leaving the existing 'failed' transition for a
|
|
364
|
+
* genuinely-never-spawned row completely untouched.
|
|
365
|
+
*
|
|
366
|
+
* DESTINATION CHOICE — needs_review, never completed. `landedCommit` is
|
|
367
|
+
* real git evidence (it is only ever stamped from an actual HEAD advance or
|
|
368
|
+
* a proven branch-integration — see jobLandedCommitThisRun / the dead-pid
|
|
369
|
+
* reap's own isBranchAlreadyIntegrated+computeCommittedDuringRun proof in
|
|
370
|
+
* scheduler.cjs), so it is never speculative in the sense of "guessed before
|
|
371
|
+
* the work ran". But the pidless-reap path performs NO fresh integration
|
|
372
|
+
* check of its own for the CURRENT run (that proof only runs for the dead-pid
|
|
373
|
+
* branch's `dead` entries with outcome==='success' — see
|
|
374
|
+
* scheduler.cjs's integrationResults loop) — so at this call site there is no
|
|
375
|
+
* re-verified guarantee that the recorded commit corresponds to *this*
|
|
376
|
+
* dispatch rather than surviving, unreaped, from an earlier one. Real
|
|
377
|
+
* evidence that under-specifies correspondence to the current run is exactly
|
|
378
|
+
* the needs_review shape, not completed: a human (or the finish-protocol
|
|
379
|
+
* commit-guard on the next dispatch) can resolve it conclusively with
|
|
380
|
+
* `git show <sha>`, but this pure predicate must not guess.
|
|
381
|
+
*/
|
|
382
|
+
function resolvePidlessFailureOverride(job) {
|
|
383
|
+
const landedCommit = job?.landedCommit || null;
|
|
384
|
+
if (!landedCommit) return null;
|
|
385
|
+
return {
|
|
386
|
+
verdict: 'pidless_reap_with_landed_commit',
|
|
387
|
+
landedCommit,
|
|
388
|
+
reason: `pidless reap found no runtime.pid, but this row already carries landedCommit ${landedCommit} — routing to needs_review instead of failed; verify with \`git show ${landedCommit}\``,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function selectReapableJobs(jobs, now, {
|
|
393
|
+
pidAlive, grace, findLiveProcess, getLogMtimeMs, getLogPid, logFreshWindowMs,
|
|
394
|
+
} = {}) {
|
|
250
395
|
const reapable = [];
|
|
251
396
|
const warnings = [];
|
|
252
397
|
const recovered = [];
|
|
@@ -265,6 +410,22 @@ function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}
|
|
|
265
410
|
}
|
|
266
411
|
const ageMs = now - startedAt;
|
|
267
412
|
if (ageMs < grace) continue; // spawn may still be mid-flight
|
|
413
|
+
|
|
414
|
+
const logMtimeMs = typeof getLogMtimeMs === 'function' ? getLogMtimeMs(j) : null;
|
|
415
|
+
if (isLogFresh(logMtimeMs, now, logFreshWindowMs)) {
|
|
416
|
+
warnings.push({
|
|
417
|
+
slug: j.slug,
|
|
418
|
+
reason: `pidless row skipped — run log modified ${Math.round((now - logMtimeMs) / 1000)}s ago, still alive by log evidence`,
|
|
419
|
+
});
|
|
420
|
+
continue; // neither reapable nor recovered — the log proves it's alive
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const logPid = typeof getLogPid === 'function' ? getLogPid(j) : null;
|
|
424
|
+
if (logPid && pidAlive(logPid)) {
|
|
425
|
+
recovered.push({ slug: j.slug, pid: logPid });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
268
429
|
const livePid = typeof findLiveProcess === 'function' ? findLiveProcess(j) : null;
|
|
269
430
|
if (livePid) {
|
|
270
431
|
recovered.push({ slug: j.slug, pid: livePid });
|
|
@@ -275,6 +436,7 @@ function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}
|
|
|
275
436
|
pid: null,
|
|
276
437
|
pidless: true,
|
|
277
438
|
reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed${formatDispatchPhaseSuffix(j)}`,
|
|
439
|
+
failureOverride: resolvePidlessFailureOverride(j),
|
|
278
440
|
});
|
|
279
441
|
}
|
|
280
442
|
return { reapable, warnings, recovered };
|
|
@@ -343,7 +505,11 @@ module.exports = {
|
|
|
343
505
|
findLiveProcessForJob,
|
|
344
506
|
logHasOutput,
|
|
345
507
|
resolvePidlessGateOutcome,
|
|
508
|
+
resolvePidlessFailureOverride,
|
|
346
509
|
isAlreadySatisfiedOnMain,
|
|
347
510
|
resolveCommitGuardOutcome,
|
|
348
511
|
formatDispatchPhaseSuffix,
|
|
512
|
+
readSpawnedPidFromLog,
|
|
513
|
+
readLogMtimeMs,
|
|
514
|
+
isLogFresh,
|
|
349
515
|
};
|
|
@@ -124,6 +124,14 @@ const ScheduleJobSchema = z
|
|
|
124
124
|
// finalize (both the normal completion path and reapDeadRunningJobs).
|
|
125
125
|
dispatchPhase: z.string().optional(),
|
|
126
126
|
dispatchPhaseAt: z.string().optional(),
|
|
127
|
+
// needs_review episode bookkeeping (needsReviewLedger.cjs / PRD: needs_review
|
|
128
|
+
// durability): stamped by scheduleJobTransitions.cjs the instant a job
|
|
129
|
+
// transitions INTO 'needs_review', so the RESOLUTION line written when it
|
|
130
|
+
// later transitions back OUT can reference the same runId and compute a
|
|
131
|
+
// truthful dwell time — same lifecycle as heldReason/dispatchPhase (stamped
|
|
132
|
+
// at entry, deleted at resolution).
|
|
133
|
+
needsReviewEntryRunId: z.string().nullable().optional(),
|
|
134
|
+
needsReviewEnteredAt: z.string().nullable().optional(),
|
|
127
135
|
})
|
|
128
136
|
.passthrough();
|
|
129
137
|
|