claude-code-session-manager 0.117.0 → 0.118.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-CEI6vsmu.js} +1 -1
- package/dist/assets/{Button-DHEnLfDi.js → Button-BuyzGzO8.js} +1 -1
- package/dist/assets/{DataModel-C2ytUxJm.js → DataModel-CbXI_rEf.js} +1 -1
- package/dist/assets/{History-eoVSG_DX.js → History-axy4nJyY.js} +1 -1
- package/dist/assets/{Hooks-rS9UvMcI.js → Hooks-D5ghOppL.js} +1 -1
- package/dist/assets/{Library-CdUqaZIj.js → Library-wBI4Fwh3.js} +1 -1
- package/dist/assets/{ListDetail-DhtJPQvm.js → ListDetail-CY5hhwn9.js} +1 -1
- package/dist/assets/{McpServers-DoOcEGFd.js → McpServers-CwEVZqfu.js} +1 -1
- package/dist/assets/{Memory-BbYwtTBZ.js → Memory-Beh2r4Xk.js} +1 -1
- package/dist/assets/{Panel-M4ouFOKI.js → Panel-yOYf80ax.js} +1 -1
- package/dist/assets/{Permissions-DB0DjkgF.js → Permissions-DA-SMXO3.js} +1 -1
- package/dist/assets/{Plugins-DnBwH9PN.js → Plugins-qfKt6BxH.js} +2 -2
- package/dist/assets/{ProvenanceBadge-1MQmqxxO.js → ProvenanceBadge-DFyBe7Po.js} +1 -1
- package/dist/assets/{RemoteMachines-DnT08vg8.js → RemoteMachines-AG0K7kqS.js} +1 -1
- package/dist/assets/{SaveBar-DGBVV6ro.js → SaveBar-35Nw2YsZ.js} +1 -1
- package/dist/assets/{Scheduler-CGDLYpdc.js → Scheduler-BPBAU0TO.js} +1 -1
- package/dist/assets/{ScopeSwitcher-jgpoKdvH.js → ScopeSwitcher-BCXNrrrs.js} +1 -1
- package/dist/assets/{Settings-Ch0zc2cZ.js → Settings-C4yhzfZI.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Cd04i1nZ.js → SkillReferenceGraph-Ca5z2avC.js} +1 -1
- package/dist/assets/{Skills-DLJLLulV.js → Skills-5WWusmKG.js} +1 -1
- package/dist/assets/{SystemPrompt-E4VOiMJN.js → SystemPrompt-ofVCdHAW.js} +1 -1
- package/dist/assets/{TagLibrary-2sCAw54h.js → TagLibrary-rSrpD-Bz.js} +1 -1
- package/dist/assets/{TiptapBody-CzqJev6v.js → TiptapBody-DAYcdcVt.js} +1 -1
- package/dist/assets/{Toggle-D5znlR_5.js → Toggle-BzHdpXZI.js} +1 -1
- package/dist/assets/{ViewTabs-BENAWvOs.js → ViewTabs-D82jJ4VS.js} +1 -1
- package/dist/assets/{historyTotals-DsE82z-I.js → historyTotals-DMGFqK5h.js} +1 -1
- package/dist/assets/{index-B_3CV5Po.js → index-DZ5TJKNI.js} +3 -3
- package/dist/assets/{settingsSchema-CmuwD4OX.js → settingsSchema-BfnNE6ne.js} +1 -1
- package/dist/assets/{skillFrontmatter-CdlHJV9x.js → skillFrontmatter-ED1DjPE1.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-RVq9wMFY.js → useAgentsDirWatch-CbpQIu3P.js} +1 -1
- package/dist/assets/{useRemotePeers-Cpt47RWb.js → useRemotePeers-Dy_o0WlY.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +11 -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/promptSessionEvents.cjs +5 -1
- package/src/main/scheduler.cjs +38 -61
|
@@ -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
|
};
|
|
@@ -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 });
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -99,7 +99,8 @@ const sessionsStore = require('./sessionsStore.cjs');
|
|
|
99
99
|
const { enqueueExternalPrompt } = require('./chatRunner.cjs');
|
|
100
100
|
const { appendResponseEventIfKnown, appendResponseEventWithReason } = require('./promptSessionEvents.cjs');
|
|
101
101
|
const { buildReviewNotice, selectDueReviewNotices, formatReviewNotice, holdMsFromEnv } = require('./lib/scheduler/reviewNotice.cjs');
|
|
102
|
-
const
|
|
102
|
+
const epicValidationHook = require('./lib/epic/epicValidationHook.cjs');
|
|
103
|
+
const prdCreate = require('./lib/prd/prdCreate.cjs');
|
|
103
104
|
const { parseValidationSentinels } = require('./lib/scheduler/validationSentinels.cjs');
|
|
104
105
|
const { hasDownstreamValidator } = require('./lib/prd/planValidator.cjs');
|
|
105
106
|
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
@@ -3018,6 +3019,29 @@ async function resolveFixPlanPath(failedJob) {
|
|
|
3018
3019
|
return { fixPath, fixSlug, group, livePrdDir };
|
|
3019
3020
|
}
|
|
3020
3021
|
|
|
3022
|
+
/**
|
|
3023
|
+
* defaultScheduleSettle — production wiring for notifyOriginatingTab's
|
|
3024
|
+
* scheduleSettle option: arms the debounced Epic settle runner with live deps.
|
|
3025
|
+
* `remote` is defined later in the module but only read at call time.
|
|
3026
|
+
*/
|
|
3027
|
+
function defaultScheduleSettle({ cwd, epicId }) {
|
|
3028
|
+
const deps = {
|
|
3029
|
+
loadJobs: async () => (await readQueue()).jobs || [],
|
|
3030
|
+
loadHistory: () => queueHistory.readHistory({ limit: 500 }),
|
|
3031
|
+
createPrd: (input) => prdCreate.createPrd(input, remote),
|
|
3032
|
+
resolveBaseSha: async (c, commit) => {
|
|
3033
|
+
try {
|
|
3034
|
+
return String(await execGitAt(c, ['rev-parse', `${commit}^`])).trim() || null;
|
|
3035
|
+
} catch {
|
|
3036
|
+
return null;
|
|
3037
|
+
}
|
|
3038
|
+
},
|
|
3039
|
+
sendPrompt: enqueueExternalPrompt,
|
|
3040
|
+
appendResponseEvent: appendResponseEventIfKnown,
|
|
3041
|
+
};
|
|
3042
|
+
epicValidationHook.scheduleEpicSettle({ cwd, epicId }, (args) => epicValidationHook.runEpicSettle(args, deps));
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3021
3045
|
/**
|
|
3022
3046
|
* notifyOriginatingTab(job) → void
|
|
3023
3047
|
*
|
|
@@ -3056,12 +3080,7 @@ async function notifyOriginatingTab(job, {
|
|
|
3056
3080
|
appendResponseEvent = appendResponseEventIfKnown,
|
|
3057
3081
|
appendTranscriptTurn = promptSessionTranscript.appendTurn,
|
|
3058
3082
|
readResultFromLog = extractResultTextFromLog,
|
|
3059
|
-
|
|
3060
|
-
// PRD 1408: same queue rows spawnJob/tickQueue already use — read fresh
|
|
3061
|
-
// here (not threaded through the terminal-transition snapshot) since this
|
|
3062
|
-
// fires well after dispatch, when the plan may have gained/dropped a
|
|
3063
|
-
// trailing validator row since this job was spawned.
|
|
3064
|
-
loadJobs = async () => (await readQueue()).jobs || [],
|
|
3083
|
+
scheduleSettle = defaultScheduleSettle,
|
|
3065
3084
|
} = {}) {
|
|
3066
3085
|
try {
|
|
3067
3086
|
const prd = await resolveNotifyPrd(job, parsePrdRaw);
|
|
@@ -3174,55 +3193,22 @@ async function notifyOriginatingTab(job, {
|
|
|
3174
3193
|
prdSlug: job.slug,
|
|
3175
3194
|
outcome: job.status,
|
|
3176
3195
|
validation: 'unvalidated',
|
|
3196
|
+
...(typeof job.agentType === 'string' ? { agentType: job.agentType } : {}),
|
|
3177
3197
|
}).catch((e) => {
|
|
3178
3198
|
console.error('[scheduler] notifyOriginatingTab appendResponseEvent error', job?.slug, e);
|
|
3179
3199
|
return false;
|
|
3180
3200
|
});
|
|
3181
3201
|
if (routed) {
|
|
3182
|
-
//
|
|
3183
|
-
//
|
|
3184
|
-
//
|
|
3185
|
-
//
|
|
3186
|
-
//
|
|
3187
|
-
//
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
//
|
|
3193
|
-
// PRD 1407: a validator job's own check-in never enqueues a
|
|
3194
|
-
// validation prompt for itself — it IS the plan's validation pass,
|
|
3195
|
-
// not a work-item awaiting one.
|
|
3196
|
-
//
|
|
3197
|
-
// PRD 1408: nor does an ordinary work-item whose plan already ends in
|
|
3198
|
-
// a trailing validator job depending on it — that validator's own
|
|
3199
|
-
// check-in (above) is the single validation pass for the whole plan,
|
|
3200
|
-
// so a per-PRD prompt here would be a second, redundant ask into the
|
|
3201
|
-
// same (Opus/Fable-tier) authoring session.
|
|
3202
|
-
if (job.agentType !== 'validator') {
|
|
3203
|
-
let skipForDownstreamValidator = false;
|
|
3204
|
-
try {
|
|
3205
|
-
skipForDownstreamValidator = hasDownstreamValidator(job, await loadJobs());
|
|
3206
|
-
} catch (e) {
|
|
3207
|
-
console.error('[scheduler] notifyOriginatingTab loadJobs error', job?.slug, e);
|
|
3208
|
-
}
|
|
3209
|
-
if (!skipForDownstreamValidator) {
|
|
3210
|
-
try {
|
|
3211
|
-
enqueueValidation(
|
|
3212
|
-
{
|
|
3213
|
-
cwd: job.cwd || null,
|
|
3214
|
-
epicId,
|
|
3215
|
-
prdSlug: job.slug,
|
|
3216
|
-
prdPath: prd?.path || prdPaths.archivedPrdPathForJob(job) || null,
|
|
3217
|
-
outcome: job.status,
|
|
3218
|
-
eventValidation: 'unvalidated',
|
|
3219
|
-
},
|
|
3220
|
-
{ sendPrompt },
|
|
3221
|
-
);
|
|
3222
|
-
} catch (e) {
|
|
3223
|
-
console.error('[scheduler] notifyOriginatingTab enqueueValidation error', job?.slug, e);
|
|
3224
|
-
}
|
|
3225
|
-
}
|
|
3202
|
+
// The debounced Epic settle runner (lib/epic/epicValidationHook.cjs)
|
|
3203
|
+
// owns all validation messaging now (replaces the per-PRD prompts of
|
|
3204
|
+
// PRDs 986/1407/1408): every successful terminal check-in, a
|
|
3205
|
+
// validator's own included (its finish usually settles the Epic),
|
|
3206
|
+
// just re-arms the per-Epic debounce. A finished PRD never sends a
|
|
3207
|
+
// chat prompt into its authoring Epic by itself.
|
|
3208
|
+
try {
|
|
3209
|
+
scheduleSettle({ cwd: job.cwd || null, epicId });
|
|
3210
|
+
} catch (e) {
|
|
3211
|
+
console.error('[scheduler] notifyOriginatingTab scheduleSettle error', job?.slug, e);
|
|
3226
3212
|
}
|
|
3227
3213
|
return;
|
|
3228
3214
|
}
|
|
@@ -6456,17 +6442,10 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
|
|
|
6456
6442
|
let mechanicalRecoveryTarget = null;
|
|
6457
6443
|
let terminalNotifySnapshot = null;
|
|
6458
6444
|
const newlyCompletedPrds = [];
|
|
6459
|
-
// PRD 1408: captured inside the mutate() below (the one in-memory read of
|
|
6460
|
-
// `s.jobs` this finalize pass already does) so notifyOriginatingTab's own
|
|
6461
|
-
// hasDownstreamValidator check can reuse it instead of a second, redundant
|
|
6462
|
-
// readQueue() — that call federates queue.json shards across every known
|
|
6463
|
-
// project cwd on the machine, not just this job's own project.
|
|
6464
|
-
let jobsSnapshotForNotify = null;
|
|
6465
6445
|
// Base HEAD for the mechanical-recovery futility check — read here, never inside mutate() (no I/O).
|
|
6466
6446
|
const finalizeHeadSha = worktreeIntegrationDetail && worktreeIntegrationDetail.failureKind === 'content_conflict'
|
|
6467
6447
|
? await gitTreeGuard.gitHead(guardCwd) : null;
|
|
6468
6448
|
await mutate((s) => {
|
|
6469
|
-
jobsSnapshotForNotify = s.jobs.slice();
|
|
6470
6449
|
const i2 = s.jobs.findIndex((x) => x.slug === job.slug);
|
|
6471
6450
|
// A job already moved off 'running' by someone else (namely
|
|
6472
6451
|
// remote.cancelJob, PRD 1024 — it SIGTERMs the process then finalizes
|
|
@@ -6883,9 +6862,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
|
|
|
6883
6862
|
});
|
|
6884
6863
|
|
|
6885
6864
|
if (terminalNotifySnapshot) {
|
|
6886
|
-
notifyOriginatingTab(terminalNotifySnapshot
|
|
6887
|
-
loadJobs: async () => jobsSnapshotForNotify || [],
|
|
6888
|
-
}).catch((e) => {
|
|
6865
|
+
notifyOriginatingTab(terminalNotifySnapshot).catch((e) => {
|
|
6889
6866
|
console.error('[scheduler] notifyOriginatingTab error', job.slug, e);
|
|
6890
6867
|
});
|
|
6891
6868
|
}
|