claude-code-session-manager 0.89.0 → 0.91.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-tuj-j1kQ.js → AgentLibrary-zkgdnCnG.js} +1 -1
- package/dist/assets/DataModel-CNFdqxrR.js +1 -0
- package/dist/assets/{History-DylcasUk.js → History-C-qijfXz.js} +2 -2
- package/dist/assets/{Hooks--2xoag9a.js → Hooks-DGxt1nBd.js} +3 -3
- package/dist/assets/{HostBilko-oaw8paAh.js → HostBilko-sIYTK-m9.js} +1 -1
- package/dist/assets/{Library-DzKA09Iy.js → Library-51Jx0oGl.js} +1 -1
- package/dist/assets/{ListDetail-CEdcO80v.js → ListDetail-ByNJU-ie.js} +1 -1
- package/dist/assets/MarkdownEditor-Wwcpe9O1.js +1 -0
- package/dist/assets/{McpServers-BB7ayW7W.js → McpServers-Bs5xPbTG.js} +2 -2
- package/dist/assets/{Memory-BOdYvrL4.js → Memory-BsHGInmd.js} +6 -6
- package/dist/assets/{Panel-Yf3SxsRv.js → Panel-ilEDcV0m.js} +1 -1
- package/dist/assets/{Permissions-BYCpHNy5.js → Permissions-Djgc3TFk.js} +3 -3
- package/dist/assets/{Plugins-B0lXiYqV.js → Plugins-BBojr1mt.js} +2 -2
- package/dist/assets/{ProvenanceBadge-T7x8LWcV.js → ProvenanceBadge-Cz1VLnXb.js} +1 -1
- package/dist/assets/{SaveBar-BdVb4DbR.js → SaveBar-Cj0wz84u.js} +1 -1
- package/dist/assets/Scheduler-D4Ekm93R.js +16 -0
- package/dist/assets/{ScopeSwitcher-WVhmNsL5.js → ScopeSwitcher-DA5MqWli.js} +1 -1
- package/dist/assets/{Settings-Bt4dVsl6.js → Settings-B1Kfgjvl.js} +2 -2
- package/dist/assets/{SkillReferenceGraph-R-0YykV8.js → SkillReferenceGraph-Dew-1uMy.js} +1 -1
- package/dist/assets/{Skills-DXV_LdJP.js → Skills-DzuhnXy9.js} +2 -2
- package/dist/assets/SystemPrompt-CNcCwEev.js +1 -0
- package/dist/assets/{TagLibrary-B9OuqU6M.js → TagLibrary-D0plTZgP.js} +1 -1
- package/dist/assets/{TiptapBody-HKKmB0AE.js → TiptapBody-BlmUt7SR.js} +1 -1
- package/dist/assets/{Toggle-CBggPonH.js → Toggle-yTfx2R-e.js} +1 -1
- package/dist/assets/{index-BHTX4OTc.css → index-CRPJslSA.css} +1 -1
- package/dist/assets/{index-BcJqLDiY.js → index-ju-h7BFf.js} +444 -444
- package/dist/assets/{settingsSchema-BYE9DVw_.js → settingsSchema-BkcnLBYk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/README.md +1 -0
- package/src/main/__tests__/bilkoHost-integration.test.cjs +22 -9
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +12 -0
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
- package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
- package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
- package/src/main/bilkoHost.cjs +6 -7
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +108 -45
- package/src/main/health.cjs +20 -3
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
- package/src/main/lib/effectiveModelInfo.cjs +3 -3
- package/src/main/lib/epicDelegationStats.cjs +3 -2
- package/src/main/lib/epicSpawnCwd.cjs +36 -5
- package/src/main/lib/epicSpawnPlan.cjs +119 -0
- package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
- package/src/main/lib/epicTranscriptPath.cjs +184 -0
- package/src/main/lib/gitWorktree.cjs +1 -1
- package/src/main/lib/mcpToolCatalog.cjs +6 -1
- package/src/main/lib/prdCreate.cjs +27 -1
- package/src/main/lib/prdFrontmatter.cjs +16 -5
- package/src/main/lib/rcaReport.cjs +27 -3
- package/src/main/lib/schedulerPaths.cjs +27 -4
- package/src/main/lib/telemetryClient.cjs +11 -4
- package/src/main/lib/terminalRunOutcome.cjs +1 -0
- package/src/main/pty.cjs +1 -1
- package/src/main/runVerify.cjs +89 -1
- package/src/main/scheduler.cjs +17 -4
- package/src/main/templates/PRD_AUTHORING.md +11 -0
- package/src/main/transcripts.cjs +28 -7
- package/src/preload/api.d.ts +4 -2
- package/src/preload/index.cjs +1 -0
- package/dist/assets/DataModel-DOim5SXW.js +0 -1
- package/dist/assets/MarkdownEditor-BMyo0F7s.js +0 -1
- package/dist/assets/Scheduler-DlOCWioV.js +0 -14
- package/dist/assets/SystemPrompt-DiQRo_WF.js +0 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicTranscriptPath.cjs — answers "where does this Epic session's JSONL
|
|
5
|
+
* transcript actually live?".
|
|
6
|
+
*
|
|
7
|
+
* The CLI writes `~/.claude/projects/<encodeCwd(spawn cwd)>/<id>.jsonl`, and an
|
|
8
|
+
* Epic's spawn cwd is its isolated worktree dir (chatRunner.cjs via
|
|
9
|
+
* resolveEpicSpawnCwd), NOT the project cwd. Turn 1 can still land under the
|
|
10
|
+
* project encoding (the worktree mint is fire-and-forget), and the worktree dir
|
|
11
|
+
* itself is routinely swept from /tmp while its transcript survives under
|
|
12
|
+
* ~/.claude/projects. So candidates are built from the recorded `worktree.dir`
|
|
13
|
+
* even when that directory no longer exists.
|
|
14
|
+
*
|
|
15
|
+
* Bounded: never enumerates ~/.claude/projects, only stats its candidate list.
|
|
16
|
+
* The active-index.json read and the archive scan are memoised per cwd on
|
|
17
|
+
* mtimeMs + size (same shape as transcripts.cjs's usageCache) because
|
|
18
|
+
* usageFor() can call this for up to 500 ids in one IPC.
|
|
19
|
+
*
|
|
20
|
+
* Ops-root hazard (see epicSpawnCwd.cjs): `cwd` here is the PROJECT cwd used
|
|
21
|
+
* for ops-root reads; this returns transcript paths only, never a spawn cwd.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { encodeCwd } = require('./encodeCwd.cjs');
|
|
28
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
29
|
+
const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
|
|
30
|
+
|
|
31
|
+
/** Map<cwd, { key, sessions }> — active-index.json parse, keyed on mtime+size. */
|
|
32
|
+
const indexCache = new Map();
|
|
33
|
+
/** Map<cwd, { dirKey, files: Map<name,{key,sid,dir}>, byId: Map<sid,string[]> }> */
|
|
34
|
+
const archiveCache = new Map();
|
|
35
|
+
|
|
36
|
+
function __resetCacheForTests() {
|
|
37
|
+
indexCache.clear();
|
|
38
|
+
archiveCache.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emptyResult() {
|
|
42
|
+
return { path: null, candidates: [], existsAnywhere: false, existingPaths: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function statKey(statSync, p) {
|
|
46
|
+
try {
|
|
47
|
+
const s = statSync(p);
|
|
48
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
49
|
+
} catch {
|
|
50
|
+
return 'missing';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function opsFile(cwd, ...segments) {
|
|
55
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
56
|
+
return opsPath(cwd, 'prompt-sessions', ...segments);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sessions map from active-index.json; O(1) stat when unchanged. */
|
|
60
|
+
function cachedSessions(cwd, deps, statSync) {
|
|
61
|
+
const key = statKey(statSync, opsFile(cwd, 'active-index.json'));
|
|
62
|
+
const hit = indexCache.get(cwd);
|
|
63
|
+
if (hit && hit.key === key) return hit.sessions;
|
|
64
|
+
let sessions = {};
|
|
65
|
+
try {
|
|
66
|
+
sessions = (deps.readActiveIndex || readActiveIndex)(cwd).sessions || {};
|
|
67
|
+
} catch {
|
|
68
|
+
sessions = {};
|
|
69
|
+
}
|
|
70
|
+
indexCache.set(cwd, { key, sessions });
|
|
71
|
+
return sessions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Worktree dirs recorded for `sid` across archived per-Epic JSON files. */
|
|
75
|
+
function archivedWorktreeDirs(cwd, sid, statSync) {
|
|
76
|
+
let dirPath;
|
|
77
|
+
try {
|
|
78
|
+
dirPath = opsFile(cwd);
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const dirKey = statKey(statSync, dirPath);
|
|
83
|
+
let entry = archiveCache.get(cwd);
|
|
84
|
+
if (entry && entry.dirKey === dirKey) return entry.byId.get(sid) || [];
|
|
85
|
+
const prevFiles = entry ? entry.files : new Map();
|
|
86
|
+
const files = new Map();
|
|
87
|
+
let names = [];
|
|
88
|
+
try {
|
|
89
|
+
names = fs.readdirSync(dirPath);
|
|
90
|
+
} catch {
|
|
91
|
+
names = [];
|
|
92
|
+
}
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
if (!name.endsWith('.json') || name === 'active-index.json') continue;
|
|
95
|
+
const file = path.join(dirPath, name);
|
|
96
|
+
const key = statKey(statSync, file);
|
|
97
|
+
const prev = prevFiles.get(name);
|
|
98
|
+
if (prev && prev.key === key) {
|
|
99
|
+
files.set(name, prev);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
let rec = { key, sid: null, dir: null };
|
|
103
|
+
try {
|
|
104
|
+
const s = JSON.parse(fs.readFileSync(file, 'utf8'))?.session;
|
|
105
|
+
if (s && typeof s.claudeSessionId === 'string' && typeof s.worktree?.dir === 'string') {
|
|
106
|
+
rec = { key, sid: s.claudeSessionId, dir: s.worktree.dir };
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// malformed archive → no hint
|
|
110
|
+
}
|
|
111
|
+
files.set(name, rec);
|
|
112
|
+
}
|
|
113
|
+
const byId = new Map();
|
|
114
|
+
for (const rec of files.values()) {
|
|
115
|
+
if (!rec.sid || !rec.dir) continue;
|
|
116
|
+
const list = byId.get(rec.sid) || [];
|
|
117
|
+
list.push(rec.dir);
|
|
118
|
+
byId.set(rec.sid, list);
|
|
119
|
+
}
|
|
120
|
+
entry = { dirKey, files, byId };
|
|
121
|
+
archiveCache.set(cwd, entry);
|
|
122
|
+
return byId.get(sid) || [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
|
|
127
|
+
* @returns {{ path: string|null, candidates: string[], existsAnywhere: boolean, existingPaths: string[] }}
|
|
128
|
+
*/
|
|
129
|
+
function resolveEpicTranscriptPath({ cwd, claudeSessionId, deps = {} } = {}) {
|
|
130
|
+
if (!cwd || typeof cwd !== 'string' || !claudeSessionId || typeof claudeSessionId !== 'string') {
|
|
131
|
+
return emptyResult();
|
|
132
|
+
}
|
|
133
|
+
// The id becomes a filename — refuse anything that could escape the project dir.
|
|
134
|
+
if (/[\\/\0]/.test(claudeSessionId) || claudeSessionId === '.' || claudeSessionId === '..') {
|
|
135
|
+
return emptyResult();
|
|
136
|
+
}
|
|
137
|
+
const statSync = deps.statSync || fs.statSync;
|
|
138
|
+
const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
|
|
139
|
+
const file = (dir) => path.join(projectsDir, encodeCwd(dir), `${claudeSessionId}.jsonl`);
|
|
140
|
+
|
|
141
|
+
const worktreeDirs = [];
|
|
142
|
+
try {
|
|
143
|
+
const sessions = cachedSessions(cwd, deps, statSync);
|
|
144
|
+
let inIndex = false;
|
|
145
|
+
for (const s of Object.values(sessions)) {
|
|
146
|
+
if (s && s.claudeSessionId === claudeSessionId) {
|
|
147
|
+
inIndex = true;
|
|
148
|
+
if (typeof s.worktree?.dir === 'string' && s.worktree.dir) worktreeDirs.push(s.worktree.dir);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!inIndex) worktreeDirs.push(...archivedWorktreeDirs(cwd, claudeSessionId, statSync));
|
|
152
|
+
} catch {
|
|
153
|
+
// unreadable ops state → fall through to whatever candidates we have
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let spawnCwd = cwd;
|
|
157
|
+
try {
|
|
158
|
+
spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId, deps: { ...deps, readActiveIndex: () => ({ sessions: cachedSessions(cwd, deps, statSync) }) } }) || cwd;
|
|
159
|
+
} catch {
|
|
160
|
+
spawnCwd = cwd;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const candidates = [...new Set([file(spawnCwd), ...worktreeDirs.map(file), file(cwd)])];
|
|
164
|
+
|
|
165
|
+
const existing = [];
|
|
166
|
+
for (const p of candidates) {
|
|
167
|
+
try {
|
|
168
|
+
const st = statSync(p);
|
|
169
|
+
if (st.isFile()) existing.push({ p, mtimeMs: st.mtimeMs, size: st.size });
|
|
170
|
+
} catch {
|
|
171
|
+
// absent
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const existingPaths = existing.map((e) => e.p);
|
|
175
|
+
const newest = (list) => list.reduce((a, b) => (b.mtimeMs > a.mtimeMs ? b : a));
|
|
176
|
+
const nonEmpty = existing.filter((e) => e.size > 0);
|
|
177
|
+
let chosen = candidates[0];
|
|
178
|
+
if (nonEmpty.length) chosen = newest(nonEmpty).p;
|
|
179
|
+
else if (existing.length) chosen = existing[0].p;
|
|
180
|
+
|
|
181
|
+
return { path: chosen, candidates, existsAnywhere: existing.length > 0, existingPaths };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { resolveEpicTranscriptPath, __resetCacheForTests };
|
|
@@ -330,7 +330,7 @@ async function removeWorktreeDir(cwd, dir) {
|
|
|
330
330
|
/* best-effort */
|
|
331
331
|
}
|
|
332
332
|
const parentDir = path.dirname(dir);
|
|
333
|
-
// Guard against ever rmdir-ing the worktree base (
|
|
333
|
+
// Guard against ever rmdir-ing the worktree base (persistent state dir or SM_WORKTREE_ROOT) itself — every real caller's
|
|
334
334
|
// `dir` is `<kind root>/<hash>/<key>`, so `parentDir` is always the hash
|
|
335
335
|
// dir, never the tmpdir root, but this keeps the guarantee explicit rather
|
|
336
336
|
// than relying solely on call-site discipline.
|
|
@@ -96,7 +96,12 @@ const MCP_TOOL_CATALOG = [
|
|
|
96
96
|
+ 'and is rejected at write time if it does not name a real persona file. '
|
|
97
97
|
+ 'If this PRD has no dependsOn of its own AND the target Epic already has incomplete PRDs, '
|
|
98
98
|
+ '`disposition` ("append" or "new-head") is REQUIRED — the write is refused without it (a '
|
|
99
|
-
+ 'headless job caller instead gets a logged "append" default, since no human is present to ask).'
|
|
99
|
+
+ 'headless job caller instead gets a logged "append" default, since no human is present to ask). '
|
|
100
|
+
+ '`deliverable` ("artifact") + `artifactPaths` declare an ARTIFACT-ONLY PRD: `artifactPaths` are the '
|
|
101
|
+
+ 'files this PRD produces that are NOT meant to be committed (git-excluded), they are stat-checked on '
|
|
102
|
+
+ 'disk by the verifier, and declaring them is the ONLY way an artifact-only PRD can pass the finish '
|
|
103
|
+
+ 'protocol without a commit. Each requires the other (the write is refused if only one is given), and '
|
|
104
|
+
+ 'no path may contain "..".',
|
|
100
105
|
whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
|
|
101
106
|
whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
|
|
102
107
|
+ '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
|
|
@@ -75,7 +75,7 @@ function deriveSlugFromTitle(title) {
|
|
|
75
75
|
function buildPrdBody(input) {
|
|
76
76
|
const {
|
|
77
77
|
title, cwd, estimateMinutes, goal, acceptanceCriteria,
|
|
78
|
-
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition,
|
|
78
|
+
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition, deliverable, artifactPaths,
|
|
79
79
|
} = input;
|
|
80
80
|
|
|
81
81
|
// No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
|
|
@@ -114,6 +114,9 @@ function buildPrdBody(input) {
|
|
|
114
114
|
// site above); a first-ever PRD in an Epic, or one with its own explicit
|
|
115
115
|
// dependsOn, has nothing to decide against and omits this key.
|
|
116
116
|
if (disposition) fmLines.push(`disposition: ${disposition}`);
|
|
117
|
+
// Artifact-only declaration — validated by createPrd() before this runs.
|
|
118
|
+
if (deliverable) fmLines.push(`deliverable: ${deliverable}`);
|
|
119
|
+
if (artifactPaths && artifactPaths.length) fmLines.push(`artifactPaths: [${artifactPaths.join(', ')}]`);
|
|
117
120
|
// Opt-in exclusive-lease flag (PRD 1107): serializes this job against
|
|
118
121
|
// every other job machine-wide for its run, for a PRD whose acceptance
|
|
119
122
|
// criteria are wall-clock/timing measurements that CPU contention from
|
|
@@ -317,6 +320,29 @@ async function createPrd(input, remote) {
|
|
|
317
320
|
if (input.parallelGroup != null) {
|
|
318
321
|
console.warn(`[prdCreate] parallelGroup input is deprecated and ignored (got ${input.parallelGroup}) — numbers are unique per project; use dependsOn for ordering`);
|
|
319
322
|
}
|
|
323
|
+
// Artifact-only declaration (deliverable + artifactPaths): fail-closed,
|
|
324
|
+
// checked BEFORE the NN allocation so a refusal burns no number.
|
|
325
|
+
const hasArtifactPaths = Array.isArray(input.artifactPaths) && input.artifactPaths.length > 0;
|
|
326
|
+
if (input.deliverable != null && input.deliverable !== 'artifact') {
|
|
327
|
+
return { ok: false, status: 400, error: 'deliverable must be exactly "artifact" when given' };
|
|
328
|
+
}
|
|
329
|
+
if (input.deliverable === 'artifact' && !hasArtifactPaths) {
|
|
330
|
+
return { ok: false, status: 400, error: 'deliverable: "artifact" requires a non-empty artifactPaths — name every git-excluded file this PRD produces' };
|
|
331
|
+
}
|
|
332
|
+
if (input.artifactPaths != null && input.deliverable !== 'artifact') {
|
|
333
|
+
return { ok: false, status: 400, error: 'artifactPaths requires deliverable: "artifact" — artifactPaths given without it' };
|
|
334
|
+
}
|
|
335
|
+
if (hasArtifactPaths) {
|
|
336
|
+
for (const ap of input.artifactPaths) {
|
|
337
|
+
if (typeof ap !== 'string' || !ap.trim() || /[\r\n,\[\]]/.test(ap)) {
|
|
338
|
+
return { ok: false, status: 400, error: `artifactPaths entry ${JSON.stringify(ap)} must be a non-empty single-line path without commas or brackets` };
|
|
339
|
+
}
|
|
340
|
+
if (ap.includes('..')) {
|
|
341
|
+
return { ok: false, status: 400, error: `artifactPaths entry "${ap}" must not contain ".."` };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
320
346
|
const nn = await remote.allocateParallelGroup(input.cwd);
|
|
321
347
|
const filenameSlug = `${nn}-${slug}`;
|
|
322
348
|
|
|
@@ -77,8 +77,8 @@ function splitFrontmatter(raw) {
|
|
|
77
77
|
* never emitted, which is how `scheduler_update_prd` clears a dependency.
|
|
78
78
|
*/
|
|
79
79
|
|
|
80
|
-
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition']);
|
|
81
|
-
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition'];
|
|
80
|
+
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths']);
|
|
81
|
+
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths'];
|
|
82
82
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
83
83
|
|
|
84
84
|
function indentOf(line) {
|
|
@@ -185,6 +185,17 @@ function applyKey(fm, key, after) {
|
|
|
185
185
|
// full rationale. Only these two values are recognized.
|
|
186
186
|
if (v === 'append' || v === 'new-head') fm.disposition = v;
|
|
187
187
|
return;
|
|
188
|
+
case 'deliverable':
|
|
189
|
+
// Artifact-only PRD declaration — only the literal `artifact` is
|
|
190
|
+
// recognized; anything else is dropped so a typo can never opt in.
|
|
191
|
+
if (v === 'artifact') fm.deliverable = v;
|
|
192
|
+
return;
|
|
193
|
+
case 'artifactPaths': {
|
|
194
|
+
// Inline `[a, b]` list, same shape as dependsOn.
|
|
195
|
+
const list = parseInlineList(after);
|
|
196
|
+
if (list) fm.artifactPaths = list;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
188
199
|
}
|
|
189
200
|
}
|
|
190
201
|
|
|
@@ -220,10 +231,10 @@ function parsePrdFile(text) {
|
|
|
220
231
|
|
|
221
232
|
if (RECOGNIZED_KEYS.has(key)) {
|
|
222
233
|
applyKey(fm, key, after);
|
|
223
|
-
if (key === 'dependsOn') {
|
|
224
|
-
if (fm
|
|
234
|
+
if (key === 'dependsOn' || key === 'artifactPaths') {
|
|
235
|
+
if (fm[key]) {
|
|
225
236
|
if (!fm._raw) fm._raw = {};
|
|
226
|
-
fm._raw[key] = { line, parsed: fm
|
|
237
|
+
fm._raw[key] = { line, parsed: fm[key] };
|
|
227
238
|
}
|
|
228
239
|
} else {
|
|
229
240
|
const parsed = parseScalar(after);
|
|
@@ -150,13 +150,35 @@ function extractRcaBlock(text) {
|
|
|
150
150
|
|
|
151
151
|
const ALREADY_SHIPPED_RE = /already (fully )?(satisfied|implemented|committed|done|shipped)|was (already )?(implemented|committed) in|nothing (new )?to commit|no (code )?changes were needed/i;
|
|
152
152
|
const SELF_QUEUE_SKILL_RE = /Launching skill: session-manager-dev:(develop|process-feedback)/;
|
|
153
|
-
|
|
153
|
+
// SELF_QUEUE rules must match an actual INVOCATION, never a bare tool-name
|
|
154
|
+
// mention. Incident: sigma `816-prepare-157-copy-citation-extras-patch`, run
|
|
155
|
+
// `2026-09-20T18-17-03-476Z` — a 23 KB log (well under the 64 KB `readTail`
|
|
156
|
+
// window) whose single `ScheduleWakeup` occurrence was the harness's
|
|
157
|
+
// `{"type":"system","subtype":"init",...,"tools":[...]}` tool list — was
|
|
158
|
+
// classified `failure-class: self-queue` in its root-cause report. The wakeup
|
|
159
|
+
// rule now requires a stream-json tool_use `"name":"ScheduleWakeup"` field, and
|
|
160
|
+
// classifyFailure strips init events before any rule runs.
|
|
161
|
+
const SELF_QUEUE_WAKEUP_RE = /"name"\s*:\s*"ScheduleWakeup"/;
|
|
154
162
|
const STUCK_LOOP_RE = /\b(until\s|while\s+true|sleep\s)/i;
|
|
155
163
|
const AC_CHECKBOX_RE = /^\s*[-*]\s*\[[xX]\]/;
|
|
156
164
|
const SENTINEL_PASS_RE = /SCHEDULER_VERDICT:\s*PASS/;
|
|
157
165
|
const STUCK_LOOP_WINDOW = 40; // "final lines" per AC
|
|
158
166
|
const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
|
|
159
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Drop every stream-json harness init event (`"type":"system"` +
|
|
170
|
+
* `"subtype":"init"`) from a log tail. That line names every available tool
|
|
171
|
+
* and would otherwise false-match tool-name/word rules. Other system events
|
|
172
|
+
* are kept. Pure; single pass, O(n) in characters.
|
|
173
|
+
*/
|
|
174
|
+
function stripHarnessInitEvents(logTail) {
|
|
175
|
+
if (!logTail) return '';
|
|
176
|
+
return logTail
|
|
177
|
+
.split('\n')
|
|
178
|
+
.filter((l) => !(l.includes('"subtype":"init"') && l.includes('"type":"system"') && l.trimStart().startsWith('{')))
|
|
179
|
+
.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
160
182
|
/**
|
|
161
183
|
* Classify why a job likely landed in needs_review, from the log tail plus
|
|
162
184
|
* the verifier verdict. Pure/deterministic — no LLM, no I/O.
|
|
@@ -164,8 +186,9 @@ const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
|
|
|
164
186
|
* Complexity: O(n) over log-tail lines (n bounded — callers pass a tail, not
|
|
165
187
|
* a full log).
|
|
166
188
|
*/
|
|
167
|
-
function classifyFailure({ verdict, logTail }) {
|
|
168
|
-
const
|
|
189
|
+
function classifyFailure({ verdict, logTail: rawLogTail }) {
|
|
190
|
+
const logTail = stripHarnessInitEvents(rawLogTail);
|
|
191
|
+
const lines = logTail.split('\n');
|
|
169
192
|
|
|
170
193
|
// Checked before every other rule: an unambiguous, materially-checked
|
|
171
194
|
// verdict (the scheduler itself validated the executor's claimed paths
|
|
@@ -399,6 +422,7 @@ module.exports = {
|
|
|
399
422
|
// Pure helpers, exported for unit tests.
|
|
400
423
|
humanVerdict,
|
|
401
424
|
classifyFailure,
|
|
425
|
+
stripHarnessInitEvents,
|
|
402
426
|
extractAcceptanceCriteria,
|
|
403
427
|
extractRcaBlock,
|
|
404
428
|
buildRcaMarkdown,
|
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* executor jobs BY DESIGN (children inherit env), so a redirected app runs
|
|
14
14
|
* redirected jobs.
|
|
15
15
|
*
|
|
16
|
-
* Worktree roots are NOT under schedulerHome (they live
|
|
17
|
-
* but are resolved here all the same, via SM_WORKTREE_ROOT (else
|
|
16
|
+
* Worktree roots are NOT under schedulerHome (they live in a per-user state dir)
|
|
17
|
+
* but are resolved here all the same, via SM_WORKTREE_ROOT (else persistentWorktreeBase()),
|
|
18
18
|
* so the job and epic roots always move together under one override.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
const fs = require('node:fs');
|
|
21
22
|
const os = require('node:os');
|
|
22
23
|
const path = require('node:path');
|
|
23
24
|
|
|
@@ -109,8 +110,29 @@ function instanceLockPath() { return path.join(schedulerHome(), 'scheduler-owner
|
|
|
109
110
|
/** Where heap snapshots are written — the scheduler home itself. */
|
|
110
111
|
function heapSnapshotDir() { return schedulerHome(); }
|
|
111
112
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Persistent per-user default for worktrees: $XDG_STATE_HOME/session-manager (absolute values only,
|
|
115
|
+
* per the XDG spec) else ~/.local/state/session-manager. NOT os.tmpdir(): a systemd tmp.conf `D /tmp`
|
|
116
|
+
* empties /tmp at boot, and the Claude CLI keys an Epic's transcript to its SPAWN cwd (the worktree),
|
|
117
|
+
* so a tmp-resident worktree took the transcript's directory with it ("lost sessions").
|
|
118
|
+
* It is also deliberately outside every project repo and outside ~/.claude/projects: what /tmp bought
|
|
119
|
+
* (a checkout never nested in the repo, so the repo's globs, `git status` and packaging never see it)
|
|
120
|
+
* is kept by living in a per-user state dir instead of beside the project.
|
|
121
|
+
*/
|
|
122
|
+
function persistentWorktreeBase() {
|
|
123
|
+
const xdg = process.env.XDG_STATE_HOME;
|
|
124
|
+
const stateHome = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), '.local', 'state');
|
|
125
|
+
return path.join(stateHome, 'session-manager');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** SM_WORKTREE_ROOT, else persistentWorktreeBase() (created 0700 on demand) — parent of every managed worktree root. */
|
|
129
|
+
function worktreeBase() {
|
|
130
|
+
if (process.env.SM_WORKTREE_ROOT) return assertNotLiveRoot(process.env.SM_WORKTREE_ROOT, 'worktreeBase');
|
|
131
|
+
// Guard BEFORE mkdir: under vitest an unset SM_WORKTREE_ROOT must throw, never create a live dir.
|
|
132
|
+
const base = assertNotLiveRoot(persistentWorktreeBase(), 'worktreeBase');
|
|
133
|
+
try { fs.mkdirSync(base, { recursive: true, mode: 0o700 }); } catch { /* best-effort; createWorktree surfaces a real failure */ }
|
|
134
|
+
return base;
|
|
135
|
+
}
|
|
114
136
|
|
|
115
137
|
/** Managed worktree root for `kind` ('job' | 'epic'), resolved fresh on every call. */
|
|
116
138
|
function worktreeRoot(kind) {
|
|
@@ -158,6 +180,7 @@ module.exports = {
|
|
|
158
180
|
adminTokenPath,
|
|
159
181
|
machineStateLogCwd,
|
|
160
182
|
worktreeBase,
|
|
183
|
+
persistentWorktreeBase,
|
|
161
184
|
worktreeRoot,
|
|
162
185
|
watchdogLogsDir,
|
|
163
186
|
restartRequestPath,
|
|
@@ -374,12 +374,19 @@ async function persistSent() {
|
|
|
374
374
|
await config.writeJson(sentPath(), { ids: S.sentOrder, updatedAt: new Date().toISOString() }, { mode: 0o600 });
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
// Records are immutable once queued, so their serialized size is computed once. Without
|
|
378
|
+
// this, enforceCap() re-stringified the whole queue on every append: O(n^2) stringifies
|
|
379
|
+
// (~15s idle for 5000 appends) and the reason the cap tests blew their budget under load.
|
|
380
|
+
const recordBytesCache = new WeakMap();
|
|
377
381
|
function recordBytes(r) {
|
|
382
|
+
const hit = recordBytesCache.get(r);
|
|
383
|
+
if (hit !== undefined) return hit;
|
|
384
|
+
let n = 0;
|
|
378
385
|
try {
|
|
379
|
-
|
|
380
|
-
} catch {
|
|
381
|
-
|
|
382
|
-
|
|
386
|
+
n = Buffer.byteLength(JSON.stringify(r), 'utf8');
|
|
387
|
+
} catch { /* unserializable -> 0 */ }
|
|
388
|
+
recordBytesCache.set(r, n);
|
|
389
|
+
return n;
|
|
383
390
|
}
|
|
384
391
|
|
|
385
392
|
/** Oldest-first eviction, bounded by count and total bytes. O(n) per call, n <= QUEUE_CAP_COUNT. */
|
|
@@ -21,6 +21,7 @@ const COMPLETED_EQUIVALENT_VERDICTS = new Set([
|
|
|
21
21
|
'pass_no_commit_target_verified',
|
|
22
22
|
'pass_no_commit_already_shipped',
|
|
23
23
|
'pass_no_commit_prior_run_verified',
|
|
24
|
+
'pass_no_commit_artifact_verified',
|
|
24
25
|
]);
|
|
25
26
|
|
|
26
27
|
// Bounds the scan: only the newest few run dirs are stat'd per slug, never
|
package/src/main/pty.cjs
CHANGED
|
@@ -181,7 +181,7 @@ class PtyManager {
|
|
|
181
181
|
// returns its isolated worktree dir when one exists — `cwd` itself is
|
|
182
182
|
// NEVER repointed; only this actual PTY spawn option is. See
|
|
183
183
|
// epicSpawnCwd.cjs's header comment (the ops-root hazard) for why.
|
|
184
|
-
const spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId });
|
|
184
|
+
const spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId, deps: { restore: true } });
|
|
185
185
|
if (spawnCwd !== cwd) {
|
|
186
186
|
console.log(`[pty] tabId=${tabId} isolated in worktree ${spawnCwd}`);
|
|
187
187
|
}
|
package/src/main/runVerify.cjs
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
const fs = require('node:fs');
|
|
41
41
|
const path = require('node:path');
|
|
42
42
|
const { execFileSync } = require('node:child_process');
|
|
43
|
+
const { parsePrdFile } = require('./lib/prdFrontmatter.cjs');
|
|
43
44
|
|
|
44
45
|
const VERDICTS_SCHEMA_VERSION = 1;
|
|
45
46
|
|
|
@@ -778,6 +779,57 @@ function allDeliverablesAlreadyTracked({ cwd, paths, execImpl = execFileSync, ti
|
|
|
778
779
|
}
|
|
779
780
|
}
|
|
780
781
|
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Material check for a DECLARED artifact-only PRD (`deliverable: artifact` +
|
|
785
|
+
* `artifactPaths`). Returns true only when every declared path resolves inside
|
|
786
|
+
* `cwd`, is a non-empty regular file, and has an mtime inside the run window
|
|
787
|
+
* (startedAt - 60s .. (finishedAt ?? now) + 120s). Never throws: any doubt
|
|
788
|
+
* (no paths, `..`, escape, missing, non-file, empty, stale, fs error) → false.
|
|
789
|
+
* `cwd` may be a list of roots; a path is accepted if it verifies under any.
|
|
790
|
+
* Complexity O(paths).
|
|
791
|
+
*/
|
|
792
|
+
function artifactsVerifiedOnDisk({ cwd, paths, startedAt, finishedAt, fsImpl = fs } = {}) {
|
|
793
|
+
return artifactsCheck({ cwd, paths, startedAt, finishedAt, fsImpl }).ok;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function artifactsCheck({ cwd, paths, startedAt, finishedAt, fsImpl = fs }) {
|
|
797
|
+
const fail = { ok: false, verified: [] };
|
|
798
|
+
try {
|
|
799
|
+
const roots = (Array.isArray(cwd) ? cwd : [cwd]).filter((r) => typeof r === 'string' && r);
|
|
800
|
+
if (!roots.length || !Array.isArray(paths) || paths.length === 0) return fail;
|
|
801
|
+
const start = Date.parse(startedAt ?? '');
|
|
802
|
+
if (Number.isNaN(start)) return fail;
|
|
803
|
+
const endParsed = finishedAt ? Date.parse(finishedAt) : NaN;
|
|
804
|
+
const end = Number.isNaN(endParsed) ? Date.now() : endParsed;
|
|
805
|
+
const lo = start - 60_000;
|
|
806
|
+
const hi = end + 120_000;
|
|
807
|
+
const verified = [];
|
|
808
|
+
for (const p of paths) {
|
|
809
|
+
if (typeof p !== 'string' || !p || p.includes('\0')) return fail;
|
|
810
|
+
if (p.split(/[\\/]/).includes('..')) return fail;
|
|
811
|
+
let hit = null;
|
|
812
|
+
for (const root of roots) {
|
|
813
|
+
const base = path.resolve(root);
|
|
814
|
+
const abs = path.resolve(base, p);
|
|
815
|
+
const rel = path.relative(base, abs);
|
|
816
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
817
|
+
let st;
|
|
818
|
+
try { st = fsImpl.statSync(abs); } catch { continue; }
|
|
819
|
+
if (!st.isFile() || st.size <= 0) continue;
|
|
820
|
+
if (st.mtimeMs < lo || st.mtimeMs > hi) continue;
|
|
821
|
+
hit = { path: p, size: st.size, mtime: new Date(st.mtimeMs).toISOString() };
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
if (!hit) return fail;
|
|
825
|
+
verified.push(hit);
|
|
826
|
+
}
|
|
827
|
+
return { ok: true, verified };
|
|
828
|
+
} catch {
|
|
829
|
+
return fail;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
781
833
|
// ─── main verifier ────────────────────────────────────────────────────────────
|
|
782
834
|
|
|
783
835
|
/**
|
|
@@ -830,7 +882,7 @@ function allDeliverablesAlreadyTracked({ cwd, paths, execImpl = execFileSync, ti
|
|
|
830
882
|
*/
|
|
831
883
|
async function verifyRun({
|
|
832
884
|
runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false,
|
|
833
|
-
ghExecImpl, priorLandedCommit = null, jobLandedCommitThisRun = null, exitCode = null,
|
|
885
|
+
ghExecImpl, priorLandedCommit = null, jobLandedCommitThisRun = null, exitCode = null, worktreeDir = null,
|
|
834
886
|
}) {
|
|
835
887
|
const { slug } = queueEntry;
|
|
836
888
|
const logPath = path.join(runDir, `${slug}.log`);
|
|
@@ -1202,7 +1254,42 @@ async function verifyRun({
|
|
|
1202
1254
|
);
|
|
1203
1255
|
}
|
|
1204
1256
|
}
|
|
1257
|
+
// EXEMPTION: a PRD that DECLARES `deliverable: artifact` + `artifactPaths`
|
|
1258
|
+
// (all deliverables live in a git-excluded dir, so "no commit" is
|
|
1259
|
+
// correct). Reads the declared list from frontmatter — never scrapes
|
|
1260
|
+
// prose — and stat-checks each file on disk (non-empty, in the run
|
|
1261
|
+
// window). Paths resolve against queueEntry.cwd, then retry against the
|
|
1262
|
+
// run's worktree dir when the caller supplies one. Any doubt falls
|
|
1263
|
+
// through to pass_no_commit below. The commit guard separately requires
|
|
1264
|
+
// a clean tree for this verdict (scheduler.cjs commitGuardVerdict).
|
|
1265
|
+
let artifactVerified = false;
|
|
1205
1266
|
if (!mergeMainVerified && !priorRunVerified && !alreadyShipped) {
|
|
1267
|
+
let fm = null;
|
|
1268
|
+
try { fm = parsePrdFile(prdFullText).frontmatter; } catch { fm = null; }
|
|
1269
|
+
if (fm && fm.deliverable === 'artifact' && Array.isArray(fm.artifactPaths) && fm.artifactPaths.length > 0) {
|
|
1270
|
+
const check = artifactsCheck({
|
|
1271
|
+
cwd: [queueEntry?.cwd, worktreeDir],
|
|
1272
|
+
paths: fm.artifactPaths,
|
|
1273
|
+
startedAt: queueEntry?.startedAt,
|
|
1274
|
+
finishedAt: queueEntry?.finishedAt,
|
|
1275
|
+
});
|
|
1276
|
+
if (check.ok) {
|
|
1277
|
+
artifactVerified = true;
|
|
1278
|
+
return conclude(
|
|
1279
|
+
'pass_no_commit_artifact_verified',
|
|
1280
|
+
'SCHEDULER_VERDICT: PASS with no commit, but every declared artifact exists, is non-empty, and was '
|
|
1281
|
+
+ `written inside the run window (${check.verified.map((v) => v.path).join(', ')})`,
|
|
1282
|
+
null,
|
|
1283
|
+
{
|
|
1284
|
+
...(annotations.length ? { annotations } : {}),
|
|
1285
|
+
sentinel,
|
|
1286
|
+
verifiedArtifacts: check.verified,
|
|
1287
|
+
},
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (!mergeMainVerified && !priorRunVerified && !alreadyShipped && !artifactVerified) {
|
|
1206
1293
|
issues.push({
|
|
1207
1294
|
verdict: 'pass_no_commit',
|
|
1208
1295
|
reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
|
|
@@ -1294,6 +1381,7 @@ module.exports = {
|
|
|
1294
1381
|
checkMergeablePr,
|
|
1295
1382
|
extractPrdDeliverablePaths,
|
|
1296
1383
|
allDeliverablesAlreadyTracked,
|
|
1384
|
+
artifactsVerifiedOnDisk,
|
|
1297
1385
|
isAncestorCommit,
|
|
1298
1386
|
isGitRepo,
|
|
1299
1387
|
hasAbandonedBackgroundTask,
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -4540,11 +4540,18 @@ async function findSatisfyingCommitOnMain(job) {
|
|
|
4540
4540
|
* newlyDirty is empty: a fix-plan job that left real dirt behind is
|
|
4541
4541
|
* still a genuine finish-protocol violation (incident:
|
|
4542
4542
|
* 523-fix-bounded-fix-plan-retry, 2026-07-12).
|
|
4543
|
-
|
|
4544
|
-
|
|
4543
|
+
* - artifactVerified (zero-edit case only): runVerify.cjs proved a PRD that
|
|
4544
|
+
* DECLARED `deliverable: artifact` wrote its git-excluded artifacts inside
|
|
4545
|
+
* the run window (pass_no_commit_artifact_verified). That verdict is
|
|
4546
|
+
* deliberately NOT blanket-excused via legitimateNoOp (the call site
|
|
4547
|
+
* excludes it) — it stands the guard down only on a clean tree, like the
|
|
4548
|
+
* fix-plan carve-out, so stray uncommitted tracked edits still park as
|
|
4549
|
+
* uncommitted_changes.
|
|
4550
|
+
*/
|
|
4551
|
+
function commitGuardVerdict({ newlyDirty, siblingRunning, ranInWorktree, jobSelfCommitted, legitimateNoOp, isFixPlanJob, artifactVerified, verifyResult, salvagePatch }) {
|
|
4545
4552
|
if ((siblingRunning && !ranInWorktree) || jobSelfCommitted || legitimateNoOp) return null;
|
|
4546
4553
|
const dirty = newlyDirty || [];
|
|
4547
|
-
if (dirty.length === 0 && isFixPlanJob) return null;
|
|
4554
|
+
if (dirty.length === 0 && (isFixPlanJob || artifactVerified)) return null;
|
|
4548
4555
|
|
|
4549
4556
|
const carried = [...(verifyResult?.annotations ?? [])];
|
|
4550
4557
|
if (verifyResult && verifyResult.verdict !== 'clean') {
|
|
@@ -7175,6 +7182,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
|
|
|
7175
7182
|
priorLandedCommit,
|
|
7176
7183
|
jobLandedCommitThisRun,
|
|
7177
7184
|
exitCode: res.exitCode,
|
|
7185
|
+
worktreeDir: worktree.ok ? worktree.dir : null,
|
|
7178
7186
|
}).catch((e) => ({
|
|
7179
7187
|
verdict: 'verify_unavailable',
|
|
7180
7188
|
reason: `verifier threw: ${e?.message ?? String(e)}`,
|
|
@@ -7209,7 +7217,8 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
|
|
|
7209
7217
|
// annotation, so a real "finish protocol incomplete" is distinguishable from
|
|
7210
7218
|
// transcript noise in the queue (feedback 2026-06-10 addendum).
|
|
7211
7219
|
const guardWillRefire = verifyResult && verifyResult.downgradeTo === 'pending';
|
|
7212
|
-
const
|
|
7220
|
+
const guardArtifactVerified = verifyResult?.verdict === 'pass_no_commit_artifact_verified';
|
|
7221
|
+
const guardIsLegitimateNoOp = verifyResult && !guardArtifactVerified && COMPLETED_EQUIVALENT_VERDICTS.has(verifyResult.verdict);
|
|
7213
7222
|
if (res.exitCode === 0 && !res.rateLimited && !guardWillRefire && !guardIsLegitimateNoOp) {
|
|
7214
7223
|
// afterGuardCwd === null means non-git cwd (or git errored) —
|
|
7215
7224
|
// best-effort skip, same as always; only a git-status result (even an
|
|
@@ -7230,6 +7239,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
|
|
|
7230
7239
|
jobSelfCommitted,
|
|
7231
7240
|
legitimateNoOp: guardIsLegitimateNoOp,
|
|
7232
7241
|
isFixPlanJob: resolveIsFixPlan(job.slug, job.isFixPlan),
|
|
7242
|
+
artifactVerified: guardArtifactVerified,
|
|
7233
7243
|
verifyResult,
|
|
7234
7244
|
salvagePatch,
|
|
7235
7245
|
});
|
|
@@ -11056,6 +11066,9 @@ function registerScheduleHandlers() {
|
|
|
11056
11066
|
// configuration tab.
|
|
11057
11067
|
ipcMain.handle('schedule:session-slots', () => sessionSlots.snapshot());
|
|
11058
11068
|
|
|
11069
|
+
// Read-only: where Epic/job git worktrees (and so their CLI transcript encodings) live.
|
|
11070
|
+
ipcMain.handle('schedule:worktree-base', () => require('./lib/schedulerPaths.cjs').worktreeBase());
|
|
11071
|
+
|
|
11059
11072
|
// Home-tab control for the same pool: user-set cap in [0, 10], default 5.
|
|
11060
11073
|
// 0 pauses new claude -p launches machine-wide without killing anything
|
|
11061
11074
|
// already running. SM_SESSION_SLOTS (if set) still overrides this at read
|
|
@@ -141,6 +141,17 @@ estimateMinutes: 60
|
|
|
141
141
|
- `estimateMinutes` is used for ETA display; include a realistic estimate (note: empirical median is ~10 min, p90 ~20 min — avoid wildly inflated estimates that hide real outliers).
|
|
142
142
|
- `parallelGroup` in frontmatter, when present, IS honored by the scheduler (`pickNextBatch` reads `parallelGroup ?? 99` and overrides the filename NN). Use this only for cross-stream interleaving — e.g., the cellar series `122-`, `123-`, `124-` overrides to groups `113`, `114`, `115` so cellar steps fire alongside the parallel etch steps. Do NOT use it to reorder within a single stream; rename the file instead.
|
|
143
143
|
|
|
144
|
+
### Artifact-only PRDs
|
|
145
|
+
|
|
146
|
+
Use `deliverable: artifact` + `artifactPaths: [a, b]` (both passed to `scheduler_create_prd`) ONLY when every deliverable is a file the target repo deliberately git-excludes (e.g. patches/notes under `session-manager-operations/review-records/`, matched by `.git/info/exclude`), so "no commit" is the correct outcome.
|
|
147
|
+
|
|
148
|
+
- Every artifact path must be named in `artifactPaths`; an unlisted file is invisible to the verifier. Each path is relative, never contains `..`.
|
|
149
|
+
- The artifact must be non-empty and written during the run window — the verifier stat-checks it on disk (verdict `pass_no_commit_artifact_verified`; window = start−60s..finish+120s).
|
|
150
|
+
- The run must still leave the working tree clean — declaring artifacts excuses the missing commit, not stray tracked edits. The commit guard enforces this: the `pass_no_commit_artifact_verified` verdict stands the guard down only when nothing tracked was left dirty; any uncommitted tracked change still parks as `uncommitted_changes`.
|
|
151
|
+
- Both fields are required together: the write is refused if only one is given.
|
|
152
|
+
|
|
153
|
+
Incident: sigma PRD `816-prepare-157-copy-citation-extras-patch` (2026-09-20) wrote its patch into a git-excluded folder; with no way to declare that, two runs both parked in `needs_review`.
|
|
154
|
+
|
|
144
155
|
---
|
|
145
156
|
|
|
146
157
|
## §7 Self-containment
|