claude-code-session-manager 0.117.0 → 0.119.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-BLRc_YvR.js → AgentLibrary-CdRrehRI.js} +1 -1
- package/dist/assets/{Button-DHEnLfDi.js → Button-BTANn6A_.js} +1 -1
- package/dist/assets/{DataModel-C2ytUxJm.js → DataModel-D4DhKYDV.js} +1 -1
- package/dist/assets/{History-eoVSG_DX.js → History-Dd1SM-AR.js} +1 -1
- package/dist/assets/{Hooks-rS9UvMcI.js → Hooks-BaSxmXkk.js} +1 -1
- package/dist/assets/{Library-CdUqaZIj.js → Library-DiRx14Qs.js} +1 -1
- package/dist/assets/{ListDetail-DhtJPQvm.js → ListDetail-DD12P0m2.js} +1 -1
- package/dist/assets/{McpServers-DoOcEGFd.js → McpServers-B5Zhq3uc.js} +1 -1
- package/dist/assets/{Memory-BbYwtTBZ.js → Memory-D8Uibw65.js} +1 -1
- package/dist/assets/{Panel-M4ouFOKI.js → Panel-cUxN--SY.js} +1 -1
- package/dist/assets/{Permissions-DB0DjkgF.js → Permissions-CgocoJzX.js} +1 -1
- package/dist/assets/{Plugins-DnBwH9PN.js → Plugins-BJPK6dlg.js} +2 -2
- package/dist/assets/{ProvenanceBadge-1MQmqxxO.js → ProvenanceBadge-Cw0cwJ1w.js} +1 -1
- package/dist/assets/{RemoteMachines-DnT08vg8.js → RemoteMachines-BIaTLP0C.js} +1 -1
- package/dist/assets/{SaveBar-DGBVV6ro.js → SaveBar-DlGYYdsI.js} +1 -1
- package/dist/assets/{Scheduler-CGDLYpdc.js → Scheduler-I4qOJsyy.js} +1 -1
- package/dist/assets/{ScopeSwitcher-jgpoKdvH.js → ScopeSwitcher-hTMYmy6R.js} +1 -1
- package/dist/assets/{Settings-Ch0zc2cZ.js → Settings-C8AGOoqw.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Cd04i1nZ.js → SkillReferenceGraph-D9a_UFm6.js} +1 -1
- package/dist/assets/{Skills-DLJLLulV.js → Skills-Bzn5Wasd.js} +1 -1
- package/dist/assets/{SystemPrompt-E4VOiMJN.js → SystemPrompt-D0LpRn5u.js} +1 -1
- package/dist/assets/{TagLibrary-2sCAw54h.js → TagLibrary-CgqAdpwI.js} +1 -1
- package/dist/assets/{TiptapBody-CzqJev6v.js → TiptapBody-u-CUpM2Q.js} +1 -1
- package/dist/assets/{Toggle-D5znlR_5.js → Toggle-Bb6BJMNB.js} +1 -1
- package/dist/assets/{ViewTabs-BENAWvOs.js → ViewTabs-Ck2NIjLp.js} +1 -1
- package/dist/assets/{historyTotals-DsE82z-I.js → historyTotals-vNPWEsnZ.js} +1 -1
- package/dist/assets/{index-B_3CV5Po.js → index-Cq-Ppqar.js} +3 -3
- package/dist/assets/{settingsSchema-CmuwD4OX.js → settingsSchema-D2dkl1uu.js} +1 -1
- package/dist/assets/{skillFrontmatter-CdlHJV9x.js → skillFrontmatter-CW0PqdDX.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-RVq9wMFY.js → useAgentsDirWatch-C9ya0GpQ.js} +1 -1
- package/dist/assets/{useRemotePeers-Cpt47RWb.js → useRemotePeers-4evTc_IE.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +13 -10
- package/src/main/build-info.json +4 -4
- package/src/main/lib/epic/epicSettle.cjs +105 -0
- package/src/main/lib/epic/epicSweepValidator.cjs +74 -0
- package/src/main/lib/epic/epicValidationHook.cjs +171 -141
- package/src/main/lib/prd/prdCreate.cjs +23 -0
- package/src/main/lib/prd/prdWorktreeReach.cjs +77 -0
- package/src/main/lib/scheduler/rcaReport.cjs +14 -2
- package/src/main/promptSessionEvents.cjs +5 -1
- package/src/main/scheduler.cjs +38 -61
- package/src/main/templates/PRD_AUTHORING.md +5 -1
- package/src/main/webRemote.cjs +27 -1
|
@@ -1,58 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* epicValidationHook.cjs —
|
|
5
|
-
*
|
|
4
|
+
* epicValidationHook.cjs — the Epic settle runner: once an Epic has no
|
|
5
|
+
* pending/running PRD it either queues ONE headless sweep validator for
|
|
6
|
+
* unchecked completed PRDs, or sends ONE plan digest into the Epic's chat.
|
|
6
7
|
*
|
|
7
|
-
* WHY: PRD 972 ran 34 s, made zero edits, exited 0,
|
|
8
|
-
* `completed
|
|
9
|
-
* The party with the context to judge whether the work is right is the Epic
|
|
10
|
-
* that WROTE the PRD — so a check-in is inverted from "assertion of done"
|
|
11
|
-
* into a REQUEST TO VALIDATE: when the scheduler appends a check-in response
|
|
12
|
-
* event to the authoring Epic's chain, this hook enqueues ONE validation
|
|
13
|
-
* prompt into that Epic's own chat session instructing it to independently
|
|
14
|
-
* verify each acceptance criterion against the real working tree and answer
|
|
15
|
-
* VERIFIED or REFUTED with evidence. The job's self-reported status is an
|
|
16
|
-
* input to that check, never a substitute for it.
|
|
8
|
+
* WHY validation exists (PRD 986): PRD 972 ran 34 s, made zero edits, exited 0,
|
|
9
|
+
* and the queue recorded `completed` — a job's self-report is never proof.
|
|
17
10
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* Kill-switch: SM_EPIC_VALIDATION_DISABLE=1 turns the whole runner off.
|
|
12
|
+
* SM_EPIC_SETTLE_SECONDS sets the debounce before a settle run (default 120).
|
|
20
13
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* SESSION SLOT POOL: this module spawns NOTHING. The prompt is enqueued via
|
|
25
|
-
* chatRunner.cjs's enqueueExternalPrompt → `chat:external-send` → the
|
|
26
|
-
* renderer's chat queue → chatRunner's pump, which acquires a slot from the
|
|
27
|
-
* machine-wide lib/scheduler/sessionSlots.cjs pool (chatRunner.cjs pump()) before any
|
|
28
|
-
* `claude -p` process starts. So the validation session cannot start outside
|
|
29
|
-
* the pool — if the pool is exhausted the prompt simply waits in the chat
|
|
30
|
-
* lane's FIFO, it never fans out into an extra parallel process (the
|
|
31
|
-
* 2026-06-10 OOM shape this AC exists to prevent).
|
|
32
|
-
*
|
|
33
|
-
* Cost note: this spends tokens per PRD check-in — intended trade. The
|
|
34
|
-
* once-per-(epicId, prdSlug) guard and the kill-switch keep it bounded.
|
|
14
|
+
* This module spawns NO process itself: the sweep validator is a PRD queued
|
|
15
|
+
* through the scheduler (which acquires a lib/scheduler/sessionSlots.cjs slot),
|
|
16
|
+
* and the digest goes through the chat queue's pump, which does the same.
|
|
35
17
|
*
|
|
36
18
|
* Join-only: nothing here can create an Epic (epicMint.cjs's SINGLE-CREATOR
|
|
37
|
-
* LAW). If no active authoring Epic exists,
|
|
19
|
+
* LAW). If no active authoring Epic exists, do nothing.
|
|
38
20
|
*/
|
|
39
21
|
|
|
40
22
|
const fs = require('node:fs');
|
|
41
23
|
const path = require('node:path');
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
* `_fired` records every (epicId, prdSlug) pair this process has already
|
|
47
|
-
* enqueued a validation prompt for — the fast in-memory half of the
|
|
48
|
-
* once-per-pair guard (the durable half re-reads the Epic's own event chain,
|
|
49
|
-
* see maybeEnqueueValidationPrompt below).
|
|
50
|
-
*/
|
|
51
|
-
const _fired = new Set();
|
|
52
|
-
|
|
53
|
-
function pairKey(epicId, prdSlug) {
|
|
54
|
-
return `${epicId}::${prdSlug}`;
|
|
55
|
-
}
|
|
24
|
+
const { evaluateEpicSettle } = require('./epicSettle.cjs');
|
|
25
|
+
const { buildSweepValidatorInput, isSweepValidatorSlug } = require('./epicSweepValidator.cjs');
|
|
26
|
+
const { bareSlug } = require('../prd/depSlugResolve.cjs');
|
|
56
27
|
|
|
57
28
|
/**
|
|
58
29
|
* Default active-index reader: the same on-disk file
|
|
@@ -71,123 +42,182 @@ function defaultReadActiveIndex(cwd) {
|
|
|
71
42
|
}
|
|
72
43
|
|
|
73
44
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* job's self-reported outcome explicitly labelled as an UNVERIFIED CLAIM,
|
|
78
|
-
* the instruction to check every Acceptance Criterion against the actual
|
|
79
|
-
* working tree, and the VERIFIED/REFUTED reply contract with per-criterion
|
|
80
|
-
* evidence. It also warns against the exact failure mode that produced
|
|
81
|
-
* PRD 986: exit 0 / a green queue row / a confident report are not evidence.
|
|
45
|
+
* buildSettledDigest — pure builder for the ONE plan-digest prompt.
|
|
46
|
+
* Empty sections are omitted. `notValidatedReason` explains why the
|
|
47
|
+
* settle.uncovered slugs were not sent to a sweep validator.
|
|
82
48
|
*/
|
|
83
|
-
function
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'',
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
'
|
|
100
|
-
|
|
49
|
+
function buildSettledDigest(settle, { epicId, notValidatedReason } = {}) {
|
|
50
|
+
const s = settle || {};
|
|
51
|
+
const verified = s.verified || [];
|
|
52
|
+
const refuted = s.refuted || [];
|
|
53
|
+
const failed = s.failed || [];
|
|
54
|
+
const parked = s.parked || [];
|
|
55
|
+
const notValidated = (s.uncovered || []).map((u) => (typeof u === 'string' ? u : u.slug));
|
|
56
|
+
const lines = [
|
|
57
|
+
`PLAN SETTLED — no PRD of this session is pending or running. This is ONE digest, not a per-PRD request.`,
|
|
58
|
+
`Validation record folder: session-manager-operations/reviews/validation/${epicId}/`,
|
|
59
|
+
'Do NOT re-verify VERIFIED PRDs — a validator already checked them.',
|
|
60
|
+
];
|
|
61
|
+
if (verified.length) {
|
|
62
|
+
lines.push('', `Verified (${verified.length}):`, ...verified.map((v) => `- ${v}`));
|
|
63
|
+
}
|
|
64
|
+
if (refuted.length) {
|
|
65
|
+
lines.push('', `REFUTED (${refuted.length}):`, ...refuted.map((r) => `- ${r.slug}${r.reason ? ` — ${r.reason}` : ''}`));
|
|
66
|
+
}
|
|
67
|
+
if (failed.length) {
|
|
68
|
+
lines.push('', `Failed (${failed.length}):`, ...failed.map((f) => `- ${f}`));
|
|
69
|
+
}
|
|
70
|
+
if (parked.length) {
|
|
71
|
+
lines.push('', `Parked (needs_review) (${parked.length}):`, ...parked.map((p) => `- ${p}`));
|
|
72
|
+
}
|
|
73
|
+
if (notValidated.length) {
|
|
74
|
+
lines.push(
|
|
75
|
+
'', `Not validated (${notValidated.length}) — ${notValidatedReason || 'no validator checked them'}:`,
|
|
76
|
+
...notValidated.map((n) => `- ${n}`),
|
|
77
|
+
`Validate these here: read each PRD file under session-manager-operations/scheduler/epics/${epicId}/prds-archived/ by slug, check every acceptance criterion against the working tree, and reply VERIFIED or REFUTED per slug.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return lines.join('\n');
|
|
101
81
|
}
|
|
102
82
|
|
|
103
|
-
|
|
104
|
-
* maybeEnqueueValidationPrompt(args, deps) → { enqueued: boolean, reason?: string }
|
|
105
|
-
*
|
|
106
|
-
* Called by scheduler.cjs's notifyOriginatingTab immediately after a
|
|
107
|
-
* SUCCESSFUL appendResponseEventIfKnown for a terminal (completed/failed)
|
|
108
|
-
* PRD outcome. Fire-and-forget: never throws; every refusal returns a
|
|
109
|
-
* reason so tests (and log lines) can tell the gates apart.
|
|
110
|
-
*
|
|
111
|
-
* Guards, in order (all four AC gates):
|
|
112
|
-
* 1. SM_EPIC_VALIDATION_DISABLE=1 kill-switch → skip.
|
|
113
|
-
* 2. LOOP GUARD — how the guard distinguishes a check-in from a
|
|
114
|
-
* validation result: a scheduler check-in event is born with
|
|
115
|
-
* `validation: 'unvalidated'` (stamped by appendResponseEventIfKnown's
|
|
116
|
-
* meta), while a validation RESULT event carries 'validating' /
|
|
117
|
-
* 'verified' / 'refuted' (and a plain chat response carries no
|
|
118
|
-
* validation field at all). Only `eventValidation === 'unvalidated'`
|
|
119
|
-
* may trigger a prompt, so an appended validation result can never
|
|
120
|
-
* enqueue a further prompt — no loop.
|
|
121
|
-
* 3. Epic must exist AND have status 'active' in the on-disk
|
|
122
|
-
* active-index.json (re-checked here even though the append already
|
|
123
|
-
* enforced it, so the gate holds for any future call site too).
|
|
124
|
-
* 4. Once per (epicId, prdSlug): in-memory `_fired` Set for the common
|
|
125
|
-
* path, plus a durable re-check of the Epic's own event chain — the
|
|
126
|
-
* check-in event just appended for this pair accounts for ONE
|
|
127
|
-
* validation-stamped response event with this prdSlug; two or more
|
|
128
|
-
* means an earlier check-in already requested validation (e.g. a
|
|
129
|
-
* re-notify after an app restart emptied `_fired`), so skip.
|
|
130
|
-
*
|
|
131
|
-
* (Gate: slot pool — see the module doc comment; no spawn happens here.)
|
|
132
|
-
*
|
|
133
|
-
* Complexity: O(n) over the Epic's event chain for the durable dedup scan.
|
|
134
|
-
*/
|
|
135
|
-
function maybeEnqueueValidationPrompt(
|
|
136
|
-
{ cwd, epicId, prdSlug, prdPath = null, outcome, eventValidation },
|
|
137
|
-
{ sendPrompt, readActiveIndex = defaultReadActiveIndex, log = console } = {},
|
|
138
|
-
) {
|
|
139
|
-
try {
|
|
140
|
-
// Gate 1: kill-switch (SM_EPIC_VALIDATION_DISABLE, per SM_DOD_DISABLE precedent).
|
|
141
|
-
if (process.env.SM_EPIC_VALIDATION_DISABLE === '1') return { enqueued: false, reason: 'disabled' };
|
|
83
|
+
const DEFAULT_SETTLE_SECONDS = 120;
|
|
142
84
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
85
|
+
function settleDelayMs() {
|
|
86
|
+
const raw = process.env.SM_EPIC_SETTLE_SECONDS;
|
|
87
|
+
const n = /^\d+$/.test(String(raw || '').trim()) ? parseInt(String(raw).trim(), 10) : 0;
|
|
88
|
+
return (n > 0 ? n : DEFAULT_SETTLE_SECONDS) * 1000;
|
|
89
|
+
}
|
|
146
90
|
|
|
147
|
-
|
|
148
|
-
return { enqueued: false, reason: 'missing-args' };
|
|
149
|
-
}
|
|
91
|
+
const _rowInEpic = (r, epicId) => r && (r.epicId === epicId || r.sourcePromptId === epicId);
|
|
150
92
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
93
|
+
/**
|
|
94
|
+
* runEpicSettle({ cwd, epicId }, deps) → { action, reason?, slug? }
|
|
95
|
+
* Never throws. Actions: 'none' | 'sweep-queued' | 'digest-sent'.
|
|
96
|
+
*/
|
|
97
|
+
async function runEpicSettle({ cwd, epicId } = {}, deps = {}) {
|
|
98
|
+
const {
|
|
99
|
+
readActiveIndex = defaultReadActiveIndex,
|
|
100
|
+
loadJobs = async () => [],
|
|
101
|
+
loadHistory = async () => [],
|
|
102
|
+
createPrd,
|
|
103
|
+
resolveBaseSha = async () => null,
|
|
104
|
+
sendPrompt,
|
|
105
|
+
appendResponseEvent,
|
|
106
|
+
now = Date.now,
|
|
107
|
+
log = console,
|
|
108
|
+
} = deps;
|
|
109
|
+
try {
|
|
110
|
+
if (process.env.SM_EPIC_VALIDATION_DISABLE === '1') return { action: 'none', reason: 'disabled' };
|
|
111
|
+
if (!cwd || !epicId) return { action: 'none', reason: 'missing-args' };
|
|
154
112
|
|
|
155
|
-
// Gate 3: authoring Epic must be a known, still-active session.
|
|
156
113
|
const index = readActiveIndex(cwd);
|
|
157
114
|
const session = index && index.sessions && index.sessions[epicId];
|
|
158
|
-
if (!session || session.status !== 'active') {
|
|
159
|
-
return { enqueued: false, reason: 'epic-not-active' };
|
|
160
|
-
}
|
|
115
|
+
if (!session || session.status !== 'active') return { action: 'none', reason: 'epic-not-active' };
|
|
161
116
|
|
|
162
|
-
// Gate 4b: durable once-per-pair — the just-appended check-in accounts
|
|
163
|
-
// for one validation-stamped response event for this prdSlug; a second
|
|
164
|
-
// one means a previous check-in already asked.
|
|
165
117
|
const events = (index.events && index.events[epicId]) || [];
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
);
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
118
|
+
const jobs = (await loadJobs()) || [];
|
|
119
|
+
const history = (await loadHistory()) || [];
|
|
120
|
+
const settle = evaluateEpicSettle({ epicId, cwd, events, jobs, now: now() });
|
|
121
|
+
if (!settle.settled) return { action: 'none', reason: 'not-settled' };
|
|
122
|
+
if (!settle.hasContent) return { action: 'none', reason: 'no-content' };
|
|
123
|
+
|
|
124
|
+
let newestDigest = null;
|
|
125
|
+
for (const e of events) if (e && e.outcome === 'digest') newestDigest = e;
|
|
126
|
+
if (newestDigest && newestDigest.digestKey === settle.digestKey) {
|
|
127
|
+
return { action: 'none', reason: 'already-digested' };
|
|
172
128
|
}
|
|
173
129
|
|
|
174
|
-
|
|
130
|
+
let notValidatedReason = null;
|
|
131
|
+
let digestSettle = settle;
|
|
132
|
+
if (settle.uncovered.length) {
|
|
133
|
+
const swept = new Set();
|
|
134
|
+
for (const r of [...jobs, ...history]) {
|
|
135
|
+
if (!_rowInEpic(r, epicId) || !isSweepValidatorSlug(r.slug) || !Array.isArray(r.dependsOn)) continue;
|
|
136
|
+
for (const d of r.dependsOn) swept.add(bareSlug(d));
|
|
137
|
+
}
|
|
138
|
+
const candidates = settle.uncovered.filter((u) => !swept.has(u.bareSlug || bareSlug(u.slug)));
|
|
139
|
+
if (candidates.length) {
|
|
140
|
+
const byBare = new Map();
|
|
141
|
+
for (const h of history) {
|
|
142
|
+
if (!h || !h.landedCommit || (h.cwd != null && h.cwd !== cwd)) continue;
|
|
143
|
+
const b = bareSlug(h.slug);
|
|
144
|
+
const prev = byBare.get(b);
|
|
145
|
+
if (!prev || String(h.finishedAt) < String(prev.finishedAt)) byBare.set(b, h);
|
|
146
|
+
}
|
|
147
|
+
let earliest = null;
|
|
148
|
+
for (const c of candidates) {
|
|
149
|
+
const h = byBare.get(c.bareSlug || bareSlug(c.slug));
|
|
150
|
+
if (h && (!earliest || String(h.finishedAt) < String(earliest.finishedAt))) earliest = h;
|
|
151
|
+
}
|
|
152
|
+
let baseSha = null;
|
|
153
|
+
try { baseSha = earliest ? await resolveBaseSha(cwd, earliest.landedCommit) : null; } catch { baseSha = null; }
|
|
154
|
+
const { input } = buildSweepValidatorInput({ epicId, cwd, uncovered: candidates, baseSha, now: now() });
|
|
155
|
+
let res;
|
|
156
|
+
try {
|
|
157
|
+
res = await createPrd(input);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
res = { ok: false, error: e && e.message ? e.message : String(e) };
|
|
160
|
+
}
|
|
161
|
+
if (res && res.ok) return { action: 'sweep-queued', slug: input.slug };
|
|
162
|
+
notValidatedReason = `the validator persona could not be queued: ${(res && res.error) || 'unknown error'}`;
|
|
163
|
+
} else {
|
|
164
|
+
notValidatedReason = 'a sweep validator already ran and did not check them';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const prompt = buildSettledDigest(digestSettle, { epicId, notValidatedReason });
|
|
175
169
|
sendPrompt(epicId, prompt);
|
|
176
|
-
|
|
177
|
-
|
|
170
|
+
await appendResponseEvent(
|
|
171
|
+
cwd, epicId,
|
|
172
|
+
`Plan digest sent: ${settle.verified.length} verified, ${settle.refuted.length} refuted, ${settle.uncovered.length} not validated`,
|
|
173
|
+
{ outcome: 'digest', digestKey: settle.digestKey },
|
|
174
|
+
);
|
|
175
|
+
return { action: 'digest-sent' };
|
|
178
176
|
} catch (e) {
|
|
179
|
-
try { (log || console).error('[epicValidationHook]
|
|
180
|
-
return {
|
|
177
|
+
try { (log || console).error('[epicValidationHook] settle error', epicId, e); } catch { /* noop */ }
|
|
178
|
+
return { action: 'none', reason: 'error' };
|
|
181
179
|
}
|
|
182
180
|
}
|
|
183
181
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
const _settle = new Map(); // `${cwd}::${epicId}` -> { timer, running }
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* scheduleEpicSettle({ cwd, epicId }, run, { delayMs }) — debounce per
|
|
186
|
+
* (cwd, epicId): a new call resets the timer; timers are unref'd; a run in
|
|
187
|
+
* flight is never started twice.
|
|
188
|
+
*/
|
|
189
|
+
function scheduleEpicSettle({ cwd, epicId } = {}, run, { delayMs } = {}) {
|
|
190
|
+
if (!cwd || !epicId || typeof run !== 'function') return;
|
|
191
|
+
const key = `${cwd}::${epicId}`;
|
|
192
|
+
const entry = _settle.get(key) || { timer: null, running: false };
|
|
193
|
+
_settle.set(key, entry);
|
|
194
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
195
|
+
const delay = Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : settleDelayMs();
|
|
196
|
+
entry.timer = setTimeout(async () => {
|
|
197
|
+
entry.timer = null;
|
|
198
|
+
if (entry.running) return;
|
|
199
|
+
entry.running = true;
|
|
200
|
+
try {
|
|
201
|
+
await run({ cwd, epicId });
|
|
202
|
+
} catch (e) {
|
|
203
|
+
try { console.error('[epicValidationHook] settle run error', epicId, e); } catch { /* noop */ }
|
|
204
|
+
} finally {
|
|
205
|
+
entry.running = false;
|
|
206
|
+
if (!entry.timer) _settle.delete(key);
|
|
207
|
+
}
|
|
208
|
+
}, delay);
|
|
209
|
+
if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Test hook: clear pending settle timers. */
|
|
213
|
+
function __resetSettleForTests() {
|
|
214
|
+
for (const e of _settle.values()) if (e.timer) clearTimeout(e.timer);
|
|
215
|
+
_settle.clear();
|
|
187
216
|
}
|
|
188
217
|
|
|
189
218
|
module.exports = {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
219
|
+
buildSettledDigest,
|
|
220
|
+
runEpicSettle,
|
|
221
|
+
scheduleEpicSettle,
|
|
222
|
+
__resetSettleForTests,
|
|
193
223
|
};
|
|
@@ -32,6 +32,8 @@ const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
|
|
|
32
32
|
const { isFixPlanSlug } = require('../scheduler/fixPlanSlug.cjs');
|
|
33
33
|
const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
|
|
34
34
|
const { sizingWarnings } = require('./prdSizing.cjs');
|
|
35
|
+
const { execGit } = require('../git/gitExec.cjs');
|
|
36
|
+
const { extractRepoPathCandidates, worktreeReachWarnings } = require('./prdWorktreeReach.cjs');
|
|
35
37
|
const { validateGate, validateFiles, renderFilesSection, renderGateSection } = require('./prdGateFiles.cjs');
|
|
36
38
|
const { resolveGate, parseChain, isNoneGate } = require('../scheduler/definitionOfDone.cjs');
|
|
37
39
|
|
|
@@ -186,6 +188,26 @@ function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
|
|
|
186
188
|
return null;
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Advisory: which relative paths the PRD names are gitignored in `cwd` (so absent from the job
|
|
194
|
+
* worktree). execGit has no stdin, so candidates go as argv after `--` (still one call). Exit 1 =
|
|
195
|
+
* nothing ignored; any other failure (not a repo, timeout) is swallowed — never blocks the write.
|
|
196
|
+
*/
|
|
197
|
+
async function worktreeReachAdvisory(input, cwd) {
|
|
198
|
+
try {
|
|
199
|
+
const text = [input.goal, ...(Array.isArray(input.acceptanceCriteria) ? input.acceptanceCriteria : []),
|
|
200
|
+
input.implementationNotes].filter((s) => typeof s === 'string').join('\n');
|
|
201
|
+
const candidates = extractRepoPathCandidates(text);
|
|
202
|
+
if (candidates.length === 0) return [];
|
|
203
|
+
const out = await execGit(cwd, ['check-ignore', '--', ...candidates], { timeout: 10000 });
|
|
204
|
+
const ignored = out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
205
|
+
return worktreeReachWarnings(input, ignored);
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
189
211
|
/**
|
|
190
212
|
* Core create-PRD orchestration: cwd validation, slug derivation, NN
|
|
191
213
|
* allocation, existing-file collision check, body build, write. Shared by
|
|
@@ -419,6 +441,7 @@ async function createPrd(input, remote) {
|
|
|
419
441
|
} else {
|
|
420
442
|
extraWarnings.push('No files list: the executor is not told which files it may change.');
|
|
421
443
|
}
|
|
444
|
+
extraWarnings.push(...await worktreeReachAdvisory(input, cwd));
|
|
422
445
|
|
|
423
446
|
const nn = await remote.allocateParallelGroup(input.cwd);
|
|
424
447
|
const filenameSlug = `${nn}-${slug}`;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdWorktreeReach.cjs — pure PRD worktree-reach advisory rule.
|
|
3
|
+
*
|
|
4
|
+
* Incident: burrow PRD 1516-fb-bilko-first-pumpkin named a gitignored runtime path by RELATIVE
|
|
5
|
+
* path (downloads/fb-tracker.db) and an install-cron.sh that baked a worktree path. Scheduled
|
|
6
|
+
* PRDs run in an isolated git job worktree holding only tracked files plus symlinked dependency
|
|
7
|
+
* dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
|
|
8
|
+
* parked needs_human.
|
|
9
|
+
*
|
|
10
|
+
* Two exports: `extractRepoPathCandidates(text)` pulls repo-relative path-looking tokens out of
|
|
11
|
+
* PRD prose; `worktreeReachWarnings(input, ignoredPaths)` turns the ones the caller found
|
|
12
|
+
* gitignored (via `git check-ignore` — not done here) into one advisory warning. No I/O.
|
|
13
|
+
* Warnings never block a write.
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { DEFAULT_DEP_DIRS } = require('../git/worktreeDeps.cjs');
|
|
18
|
+
|
|
19
|
+
const MAX_CANDIDATES = 200;
|
|
20
|
+
const MAX_NAMED_PATHS = 5;
|
|
21
|
+
|
|
22
|
+
const LEADING_JUNK_RE = /^[`'"([<]+/;
|
|
23
|
+
const TRAILING_JUNK_RE = /[`'")\]>.,:;]+$/;
|
|
24
|
+
|
|
25
|
+
function isCandidate(token) {
|
|
26
|
+
if (!token.includes('/')) return false;
|
|
27
|
+
if (/^[/~$-]/.test(token)) return false;
|
|
28
|
+
if (token.includes('://')) return false;
|
|
29
|
+
if (token.split('/').includes('..')) return false;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Pure. Deduped repo-relative tokens containing a '/', capped at 200. */
|
|
34
|
+
function extractRepoPathCandidates(text) {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const raw of String(text == null ? '' : text).split(/\s+/)) {
|
|
37
|
+
if (!raw) continue;
|
|
38
|
+
const token = raw.replace(LEADING_JUNK_RE, '').replace(TRAILING_JUNK_RE, '');
|
|
39
|
+
if (!token || !isCandidate(token)) continue;
|
|
40
|
+
seen.add(token);
|
|
41
|
+
if (seen.size >= MAX_CANDIDATES) break;
|
|
42
|
+
}
|
|
43
|
+
return [...seen];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalize(p) {
|
|
47
|
+
return String(p).replace(/^(\.\/)+/, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pure. `input` mirrors createPrd()'s shape; missing fields are treated as empty.
|
|
52
|
+
* `ignoredPaths` is the caller's list of gitignored repo-relative paths. Returns `[]` when none
|
|
53
|
+
* remain after exempting symlinked dependency dirs, else exactly one warning string.
|
|
54
|
+
*/
|
|
55
|
+
function worktreeReachWarnings(input, ignoredPaths) {
|
|
56
|
+
const { goal, acceptanceCriteria, implementationNotes } = input || {};
|
|
57
|
+
const ac = Array.isArray(acceptanceCriteria) ? acceptanceCriteria.filter((l) => typeof l === 'string') : [];
|
|
58
|
+
const ignored = (Array.isArray(ignoredPaths) ? ignoredPaths : [])
|
|
59
|
+
.filter((p) => typeof p === 'string' && p)
|
|
60
|
+
.map(normalize)
|
|
61
|
+
.filter((p) => !DEFAULT_DEP_DIRS.includes(p.split('/')[0]));
|
|
62
|
+
if (ignored.length === 0) return [];
|
|
63
|
+
|
|
64
|
+
const text = [goal, ...ac, implementationNotes].filter((s) => typeof s === 'string').join('\n');
|
|
65
|
+
const mentioned = new Set(extractRepoPathCandidates(text).map(normalize));
|
|
66
|
+
const offending = [...new Set(ignored)].filter((p) => mentioned.has(p) || mentioned.has(p.replace(/\/+$/, '')));
|
|
67
|
+
const named = (offending.length ? offending : [...new Set(ignored)]).slice(0, MAX_NAMED_PATHS);
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
`This PRD references gitignored path(s) by relative path: ${named.join(', ')}. `
|
|
71
|
+
+ 'The job runs in an isolated worktree where gitignored files do not exist, so those paths will '
|
|
72
|
+
+ 'be missing (or a baked-in worktree path will dangle). Address the main checkout by absolute path '
|
|
73
|
+
+ 'in an artifact-only PRD (or split the PRD) — see PRD_AUTHORING.md section 7.',
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { extractRepoPathCandidates, worktreeReachWarnings };
|
|
@@ -73,6 +73,7 @@ const FAILURE_CLASSES = {
|
|
|
73
73
|
TRANSCRIPT_ERRORS: 'transcript-errors',
|
|
74
74
|
ABANDONED_BACKGROUND_TASK: 'abandoned-background-task',
|
|
75
75
|
BLOCKED_BY_FOREIGN_WIP: 'blocked-by-foreign-wip',
|
|
76
|
+
NEEDS_HUMAN: 'needs-human',
|
|
76
77
|
UNKNOWN: 'unknown',
|
|
77
78
|
};
|
|
78
79
|
|
|
@@ -82,8 +83,11 @@ const FAILURE_CLASSES = {
|
|
|
82
83
|
// nothing to investigate, and re-queuing is already owned by
|
|
83
84
|
// reconcile()'s requeueForeignWipBlockedJobs, not this action), 'resume-and-commit'
|
|
84
85
|
// (PRD 1111's --resume dispatch owns this), 'verify-and-close' (the work
|
|
85
|
-
// likely landed, just missing its sentinel), '
|
|
86
|
-
//
|
|
86
|
+
// likely landed, just missing its sentinel), 'ask-human' (the executor
|
|
87
|
+
// correctly stopped on a step only a person can do — answer the job's
|
|
88
|
+
// humanQuestion, then update + reset the PRD; never a fix-plan, and the review
|
|
89
|
+
// ladder never auto-fixes it), 'investigate' (the only class that still buys a
|
|
90
|
+
// fix-plan investigation).
|
|
87
91
|
const RECOVERY_ACTIONS = {
|
|
88
92
|
[FAILURE_CLASSES.ALREADY_SHIPPED]: 'archive',
|
|
89
93
|
[FAILURE_CLASSES.SELF_QUEUE]: 'investigate',
|
|
@@ -104,6 +108,7 @@ const RECOVERY_ACTIONS = {
|
|
|
104
108
|
// guidance into that cold-read investigation.
|
|
105
109
|
[FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]: 'investigate',
|
|
106
110
|
[FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]: 'archive',
|
|
111
|
+
[FAILURE_CLASSES.NEEDS_HUMAN]: 'ask-human',
|
|
107
112
|
[FAILURE_CLASSES.UNKNOWN]: 'investigate',
|
|
108
113
|
};
|
|
109
114
|
|
|
@@ -131,6 +136,8 @@ const PREVENTION_HINTS = {
|
|
|
131
136
|
'A headless run cannot receive a background-task completion notification — never let a long Bash command auto-background past its foreground timeout and then wait for it. Run long commands with an explicit bound (`timeout <N> <cmd>`) so they finish in the foreground, or poll their output file directly instead of waiting on Monitor/notification.',
|
|
132
137
|
[FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]:
|
|
133
138
|
'This job correctly reported `SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP` against a sibling job\'s in-flight, uncommitted files three times in a row — auto-requeue is exhausted (reconcile() only clears this once the blocked paths go clean). This is not a defect in this PRD\'s own work: check on the sibling job/human that owns the still-dirty path(s) named on the job row, and either wait for them to commit or manually reset this job to `pending` once the tree is clear. Do not author a fix-plan PRD against it.',
|
|
139
|
+
[FAILURE_CLASSES.NEEDS_HUMAN]:
|
|
140
|
+
'The run stopped on a step that needs a person or live runtime state — the executor was right to stop, so this is not a defect to fix-plan. Answer the job\'s humanQuestion, then update and reset the PRD. If the reason is that the job worktree lacks gitignored runtime state (databases, browser profile, downloads/, crontab paths), re-author the step as an artifact-only PRD that addresses the main checkout by absolute path, per PRD_AUTHORING.md section 7.',
|
|
134
141
|
[FAILURE_CLASSES.UNKNOWN]:
|
|
135
142
|
'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
|
|
136
143
|
};
|
|
@@ -192,6 +199,11 @@ function classifyFailure({ verdict, logTail: rawLogTail }) {
|
|
|
192
199
|
const logTail = stripHarnessInitEvents(rawLogTail);
|
|
193
200
|
const lines = logTail.split('\n');
|
|
194
201
|
|
|
202
|
+
// Checked before every other rule, log-tail heuristics included: the
|
|
203
|
+
// executor deliberately stopped on a human-only step, so words like
|
|
204
|
+
// "sleep"/"until" in its tail say nothing about the cause.
|
|
205
|
+
if (verdict === 'needs_human') return FAILURE_CLASSES.NEEDS_HUMAN;
|
|
206
|
+
|
|
195
207
|
// Checked before every other rule: an unambiguous, materially-checked
|
|
196
208
|
// verdict (the scheduler itself validated the executor's claimed paths
|
|
197
209
|
// against the job's disclosed foreign-WIP manifest before ever landing
|
|
@@ -69,7 +69,9 @@ function mintEventId() {
|
|
|
69
69
|
* meta arg (not a fifth positional parameter) with `validation`: a
|
|
70
70
|
* scheduler check-in is born `'unvalidated'` — never `'verified'` —
|
|
71
71
|
* regardless of the job's self-reported outcome; the authoring Epic's own
|
|
72
|
-
* validation pass is what may later move it to 'verified'/'refuted'.
|
|
72
|
+
* validation pass is what may later move it to 'verified'/'refuted'. `agentType`
|
|
73
|
+
* (the job's agent) and `digestKey` (the settle digest's stable key) ride the
|
|
74
|
+
* same meta arg and are copied the same way. All
|
|
73
75
|
* keys are added to the event object only when present in `meta`, never as
|
|
74
76
|
* `undefined`-valued keys, so events appended without them serialize
|
|
75
77
|
* identically to before.
|
|
@@ -99,6 +101,8 @@ async function appendResponseEventWithReason(cwd, sourcePromptId, text, meta = {
|
|
|
99
101
|
if (meta && meta.prdSlug) event.prdSlug = meta.prdSlug;
|
|
100
102
|
if (meta && meta.outcome) event.outcome = meta.outcome;
|
|
101
103
|
if (meta && meta.validation) event.validation = meta.validation;
|
|
104
|
+
if (meta && meta.agentType) event.agentType = meta.agentType;
|
|
105
|
+
if (meta && meta.digestKey) event.digestKey = meta.digestKey;
|
|
102
106
|
data.events[sourcePromptId] = [...events, event];
|
|
103
107
|
await config.writeJson(path, data, { writer: 'scheduler' });
|
|
104
108
|
broadcast(EVENT_APPENDED_CHANNEL, { cwd, promptSessionId: sourcePromptId, event });
|