claude-code-session-manager 0.52.0 → 0.54.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/{TiptapBody-CQtxxn1C.js → TiptapBody-B91BqJWI.js} +1 -1
- package/dist/assets/{index-CKP60_3d.css → index-BEyUC3v9.css} +1 -1
- package/dist/assets/{index-hNso_ykJ.js → index-BYOscSQK.js} +869 -866
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/activeIndexMerge.test.cjs +235 -0
- package/src/main/__tests__/epicMint.test.cjs +113 -3
- package/src/main/__tests__/prdCreate.test.cjs +6 -0
- package/src/main/__tests__/prdMigration.test.cjs +5 -0
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +38 -5
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +89 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +8 -0
- package/src/main/index.cjs +9 -0
- package/src/main/ipcSchemas.cjs +54 -0
- package/src/main/lib/activeIndexMerge.cjs +168 -0
- package/src/main/lib/epicMint.cjs +63 -16
- package/src/main/promptSessionEvents.cjs +7 -24
- package/src/main/scheduler.cjs +41 -12
- package/src/preload/api.d.ts +28 -0
- package/src/preload/index.cjs +8 -0
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -214,6 +214,39 @@ const configWatch = z.array(z.string().min(1).max(4096));
|
|
|
214
214
|
|
|
215
215
|
const configParseImports = z.object({ path: z.string().min(1).max(4096) });
|
|
216
216
|
|
|
217
|
+
// ──────────────────────────────────────────── PromptSessions (Epics) active-index merge
|
|
218
|
+
// lib/activeIndexMerge.cjs's read-merge-write, invoked over IPC by the
|
|
219
|
+
// renderer's persistActiveIndex (state/promptSessions.ts) instead of that
|
|
220
|
+
// module doing its own config:read-json/config:write-json round trip. Payload
|
|
221
|
+
// carries only the CALLING renderer's in-memory contribution for this cwd —
|
|
222
|
+
// disk is main's truth, merged inside the same withPathLock epicMint.cjs uses.
|
|
223
|
+
//
|
|
224
|
+
// Epic ids are keys here (Electron's structured-clone IPC transport, unlike
|
|
225
|
+
// JSON.parse, can produce an own-enumerable "__proto__" key on a cloned
|
|
226
|
+
// object) — the regex excludes the prototype-pollution trio so a malformed/
|
|
227
|
+
// hostile payload fails validation before it ever reaches activeIndexMerge.cjs
|
|
228
|
+
// (which additionally uses null-prototype merge targets as defense in depth).
|
|
229
|
+
// Real epic ids are `${slugify(goalText)}-${uuid8}` (epicMint.cjs) — max 64
|
|
230
|
+
// mirrors slugify's 48-char cap plus the "-" + 8 hex chars suffix, rounded up.
|
|
231
|
+
const PROMPT_SESSION_ID_RE = /^(?!__proto__$|constructor$|prototype$)[A-Za-z0-9_-]{1,64}$/;
|
|
232
|
+
const promptSessionsMergeActiveIndex = z.object({
|
|
233
|
+
cwd: z.string().min(1).max(4096),
|
|
234
|
+
sessions: z.record(z.string().regex(PROMPT_SESSION_ID_RE), z.unknown()).refine(
|
|
235
|
+
(v) => Object.keys(v).length <= 2000,
|
|
236
|
+
{ message: 'too many sessions in one merge payload' },
|
|
237
|
+
),
|
|
238
|
+
events: z.record(z.string().regex(PROMPT_SESSION_ID_RE), z.array(z.unknown()).max(5000)).refine(
|
|
239
|
+
(v) => Object.keys(v).length <= 2000,
|
|
240
|
+
{ message: 'too many event chains in one merge payload' },
|
|
241
|
+
),
|
|
242
|
+
removedIds: z.array(z.string().regex(PROMPT_SESSION_ID_RE)).max(500).optional(),
|
|
243
|
+
// Always the literal 'epics' on the wire — the handler ignores this value
|
|
244
|
+
// and hardcodes the writer id itself (single-writer law,
|
|
245
|
+
// lib/opsOwnership.cjs); requiring the literal here just means a
|
|
246
|
+
// forged/malformed payload fails validation before it reaches the merge.
|
|
247
|
+
source: z.literal('epics'),
|
|
248
|
+
});
|
|
249
|
+
|
|
217
250
|
// ──────────────────────────────────────────── Sessions
|
|
218
251
|
const sessionsPayload = z.object({
|
|
219
252
|
tabs: z.array(z.object({
|
|
@@ -493,6 +526,25 @@ const promptSessionTranscriptRead = z.object({
|
|
|
493
526
|
limit: z.number().int().positive().max(10_000).optional(),
|
|
494
527
|
}).strict();
|
|
495
528
|
|
|
529
|
+
// ──────────────────────────────────────────── Audit log (renderer Epic
|
|
530
|
+
// lifecycle trace, PRD 940). Kind allowlist is enforced HERE, main-side — the
|
|
531
|
+
// renderer cannot write arbitrary kinds through this channel, only the six
|
|
532
|
+
// Epic lifecycle transitions it emits from promptSessions.ts.
|
|
533
|
+
const AUDIT_LOG_KIND = z.enum([
|
|
534
|
+
'epic_create',
|
|
535
|
+
'epic_approve',
|
|
536
|
+
'epic_complete',
|
|
537
|
+
'epic_delete',
|
|
538
|
+
'epic_resume',
|
|
539
|
+
'epic_duplicate',
|
|
540
|
+
]);
|
|
541
|
+
const auditLogAppend = z.object({
|
|
542
|
+
kind: AUDIT_LOG_KIND,
|
|
543
|
+
cwd: z.string().min(1).max(4096),
|
|
544
|
+
epicId: z.string().min(1).max(256),
|
|
545
|
+
source: z.string().min(1).max(256),
|
|
546
|
+
}).strict();
|
|
547
|
+
|
|
496
548
|
// ──────────────────────────────────────────── Per-subagent memory
|
|
497
549
|
// Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
|
|
498
550
|
// subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
|
|
@@ -889,6 +941,8 @@ module.exports = {
|
|
|
889
941
|
projectBriefUpdate,
|
|
890
942
|
promptSessionTranscriptAppend,
|
|
891
943
|
promptSessionTranscriptRead,
|
|
944
|
+
promptSessionsMergeActiveIndex,
|
|
945
|
+
auditLogAppend,
|
|
892
946
|
agentMemoryList,
|
|
893
947
|
agentMemoryGet,
|
|
894
948
|
agentMemorySet,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* activeIndexMerge.cjs — main-process read-merge-write for a cwd's
|
|
5
|
+
* prompt-sessions/active-index.json, invoked over IPC by the renderer's
|
|
6
|
+
* persistActiveIndex (state/promptSessions.ts). Moved here from the renderer
|
|
7
|
+
* (which used to read-merge-write itself over config:read-json/
|
|
8
|
+
* config:write-json, commit 3d12e19) to close two holes a purely-renderer
|
|
9
|
+
* merge can't:
|
|
10
|
+
*
|
|
11
|
+
* 1. Resurrection: a second window that still holds an archived/deleted
|
|
12
|
+
* Epic as 'active'/'proposed' in its stale in-memory snapshot would
|
|
13
|
+
* write that row right back on its next persist — "memory wins on id
|
|
14
|
+
* collision" doesn't know the row was deliberately removed elsewhere.
|
|
15
|
+
* Fixed with a removal tombstone (index.tombstones, id -> removedAt
|
|
16
|
+
* ISO): an id present there is dropped from every future merge's memory
|
|
17
|
+
* contribution, not just from the write that performed the removal.
|
|
18
|
+
* 2. TOCTOU: a renderer's own read (config:read-json) and write
|
|
19
|
+
* (config:write-json), round-tripped over IPC with no lock in between,
|
|
20
|
+
* left a gap where a main-process mint (epicMint.cjs's ensureEpic,
|
|
21
|
+
* itself serialized through its own withPathLock) could land and then
|
|
22
|
+
* be silently dropped by the renderer's subsequent write. Running the
|
|
23
|
+
* merge inside the SAME withPathLock instance epicMint.cjs already
|
|
24
|
+
* serializes ensureEpic/appendPrdCreatedEvent through closes the gap:
|
|
25
|
+
* every read-modify-write of this file, from either caller, is now one
|
|
26
|
+
* lock (epicMint.cjs exports withPathLock; requiring it here shares the
|
|
27
|
+
* same module-level Map via Node's require cache, not a second one).
|
|
28
|
+
*
|
|
29
|
+
* Tombstones are never auto-cleared: resumeArchived() (state/promptSessions.ts)
|
|
30
|
+
* always mints a BRAND NEW Epic id + claudeSessionId — it never reuses the
|
|
31
|
+
* archived id — so a tombstoned id is never legitimately reborn under the
|
|
32
|
+
* current domain model. If that ever changes, this module would need an
|
|
33
|
+
* explicit "un-tombstone" path; today one would be dead code.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
|
|
37
|
+
const config = require('../config.cjs');
|
|
38
|
+
|
|
39
|
+
function hasOwn(obj, key) {
|
|
40
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mirrors state/promptSessions.ts's compareEventsChainAware exactly. Kept as
|
|
44
|
+
// a separate copy (not a shared module) since one side runs in the renderer
|
|
45
|
+
// and the other in Node — small and stable enough that duplicating it here
|
|
46
|
+
// is cheaper than wiring a cross-boundary shared module for one function.
|
|
47
|
+
function compareEventsChainAware(a, b) {
|
|
48
|
+
const ta = Date.parse(a.at);
|
|
49
|
+
const tb = Date.parse(b.at);
|
|
50
|
+
const aValid = !Number.isNaN(ta);
|
|
51
|
+
const bValid = !Number.isNaN(tb);
|
|
52
|
+
if (aValid && bValid && ta !== tb) return ta - tb;
|
|
53
|
+
if (b.causedByEventId === a.id) return -1;
|
|
54
|
+
if (a.causedByEventId === b.id) return 1;
|
|
55
|
+
if (aValid && bValid) return 0;
|
|
56
|
+
if (aValid) return -1;
|
|
57
|
+
if (bValid) return 1;
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* mergeActiveIndex(cwd, { sessions, events, removedIds }) → Promise<{ sessions, events }>
|
|
63
|
+
*
|
|
64
|
+
* `sessions`/`events` are the CALLING renderer's own in-memory contribution
|
|
65
|
+
* for this cwd only (already filtered to 'active'/'proposed' sessions by
|
|
66
|
+
* persistActiveIndex) — never a full merged file; disk remains the source of
|
|
67
|
+
* truth this function reads and reconciles against. Returns the merged
|
|
68
|
+
* { sessions, events } actually written.
|
|
69
|
+
*/
|
|
70
|
+
async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events: rawMemoryEvents = {}, removedIds = [] } = {}) {
|
|
71
|
+
if (!cwd || typeof cwd !== 'string') throw new Error('mergeActiveIndex: cwd is required');
|
|
72
|
+
const filePath = activeIndexPath(cwd);
|
|
73
|
+
// Null-prototype copies of the CALLER'S OWN payload too: an Epic id equal
|
|
74
|
+
// to "__proto__" is a plain string key from the caller's perspective, but
|
|
75
|
+
// `rawMemoryEvents[id]` on a plain object literal resolves through the
|
|
76
|
+
// prototype chain to Object.prototype itself (not undefined) rather than
|
|
77
|
+
// hitting the `?? []` fallback below — same hazard as the disk-side merge
|
|
78
|
+
// targets, just on the read side instead of the write side.
|
|
79
|
+
const memorySessions = Object.assign(Object.create(null), rawMemorySessions);
|
|
80
|
+
const memoryEvents = Object.assign(Object.create(null), rawMemoryEvents);
|
|
81
|
+
return withPathLock(filePath, async () => {
|
|
82
|
+
// Null-prototype targets: `sessions`/`events` are keyed by Epic id, which
|
|
83
|
+
// for an IPC payload delivered via Electron's structured-clone (not
|
|
84
|
+
// JSON.parse) could legitimately carry an own-enumerable "__proto__" key.
|
|
85
|
+
// A plain-object target's bracket assignment (`obj[id] = x`) with
|
|
86
|
+
// id === "__proto__" invokes Object.prototype's accessor and re-links the
|
|
87
|
+
// object's own prototype instead of storing a normal entry — the same
|
|
88
|
+
// hazard epicMint.cjs's hasOwn() helper guards reads against (see its
|
|
89
|
+
// comment ~epicMint.cjs:61). A null-prototype target has no such accessor,
|
|
90
|
+
// so "__proto__"/"constructor"/etc. behave as ordinary keys.
|
|
91
|
+
let diskSessions = Object.create(null);
|
|
92
|
+
let diskEvents = Object.create(null);
|
|
93
|
+
let tombstones = Object.create(null);
|
|
94
|
+
const result = await config.readJson(filePath);
|
|
95
|
+
if (result.exists && result.data) {
|
|
96
|
+
const disk = result.data;
|
|
97
|
+
if (disk.sessions && typeof disk.sessions === 'object') Object.assign(diskSessions, disk.sessions);
|
|
98
|
+
if (disk.events && typeof disk.events === 'object') Object.assign(diskEvents, disk.events);
|
|
99
|
+
if (disk.tombstones && typeof disk.tombstones === 'object') Object.assign(tombstones, disk.tombstones);
|
|
100
|
+
} else if (result.error || result.parseError) {
|
|
101
|
+
// Genuine unreadable/corrupt disk index (fs error, or valid file that
|
|
102
|
+
// failed JSON.parse) rather than "file legitimately doesn't exist yet".
|
|
103
|
+
// config.readJson never throws (it reports failure via exists/error/
|
|
104
|
+
// parseError, not exceptions), so this branch — not a try/catch — is
|
|
105
|
+
// the only way to detect it. Falling back to this caller's own memory
|
|
106
|
+
// contribution is still the best-effort choice (matches the old
|
|
107
|
+
// renderer merge's contract), but silently doing so would recreate the
|
|
108
|
+
// exact "unlogged data loss" incident this module exists to close —
|
|
109
|
+
// any on-disk tombstones/other-window sessions get dropped, so log it.
|
|
110
|
+
console.warn(
|
|
111
|
+
`[activeIndexMerge] disk index unreadable for ${filePath} (error=${result.error ?? 'none'}, `
|
|
112
|
+
+ `parseError=${result.parseError ?? 'none'}) — falling back to this caller's in-memory contribution alone`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
for (const id of removedIds) tombstones[id] = now;
|
|
118
|
+
|
|
119
|
+
const mergedSessions = Object.assign(Object.create(null), diskSessions);
|
|
120
|
+
for (const [id, session] of Object.entries(memorySessions)) {
|
|
121
|
+
// Resurrection guard: a caller whose in-memory snapshot still thinks a
|
|
122
|
+
// tombstoned id is open (it hasn't hydrated the removal yet) must not
|
|
123
|
+
// write it back into the merged file.
|
|
124
|
+
if (hasOwn(tombstones, id)) continue;
|
|
125
|
+
mergedSessions[id] = session;
|
|
126
|
+
}
|
|
127
|
+
for (const id of removedIds) delete mergedSessions[id];
|
|
128
|
+
|
|
129
|
+
// Events are a per-id UNION (same contract the renderer's own merge used
|
|
130
|
+
// — PRD 855): a scheduler job appends events to disk under its narrow
|
|
131
|
+
// delegation, so memory-wins-wholesale would silently drop any event
|
|
132
|
+
// that landed since this caller's last hydrate. Memory order wins for
|
|
133
|
+
// events both sides know; disk-only events are appended and re-sorted
|
|
134
|
+
// chain-aware.
|
|
135
|
+
const mergedEvents = Object.create(null);
|
|
136
|
+
for (const id of Object.keys(mergedSessions)) {
|
|
137
|
+
const memEvts = memoryEvents[id] ?? [];
|
|
138
|
+
const diskEvts = diskEvents[id] ?? [];
|
|
139
|
+
if (memEvts.length === 0) {
|
|
140
|
+
mergedEvents[id] = diskEvts;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const memIds = new Set(memEvts.map((e) => e.id));
|
|
144
|
+
const missing = diskEvts.filter((e) => !memIds.has(e.id));
|
|
145
|
+
mergedEvents[id] = missing.length > 0 ? [...memEvts, ...missing].sort(compareEventsChainAware) : memEvts;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const index = { sessions: mergedSessions, events: mergedEvents, tombstones };
|
|
149
|
+
// Single-writer law: prompt-sessions/ is owned by 'epics'
|
|
150
|
+
// (lib/opsOwnership.cjs) — hardcoded here, never taken from the IPC
|
|
151
|
+
// payload, so a compromised/forged renderer call can't claim a
|
|
152
|
+
// different writer id.
|
|
153
|
+
await config.writeJson(filePath, index, { writer: 'epics' });
|
|
154
|
+
return { sessions: mergedSessions, events: mergedEvents };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function registerActiveIndexMergeHandlers() {
|
|
159
|
+
const { ipcMain } = require('electron');
|
|
160
|
+
const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
|
|
161
|
+
ipcMain.handle(
|
|
162
|
+
'promptSessions:merge-active-index',
|
|
163
|
+
v(s.promptSessionsMergeActiveIndex, ({ cwd, sessions, events, removedIds }) =>
|
|
164
|
+
mergeActiveIndex(cwd, { sessions, events, removedIds: removedIds || [] })),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { mergeActiveIndex, registerActiveIndexMergeHandlers };
|
|
@@ -34,9 +34,16 @@ function readActiveIndex(cwd) {
|
|
|
34
34
|
return {
|
|
35
35
|
sessions: parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {},
|
|
36
36
|
events: parsed && typeof parsed.events === 'object' && parsed.events ? parsed.events : {},
|
|
37
|
+
// Passed through untouched — this module never consults tombstones (it
|
|
38
|
+
// doesn't resurrect completed/deleted Epics on its own), but every
|
|
39
|
+
// caller here does read-modify-write on the object this returns, so
|
|
40
|
+
// dropping the field would silently erase the removal tombstones
|
|
41
|
+
// lib/activeIndexMerge.cjs's mergeActiveIndex records the next time any
|
|
42
|
+
// of ensureEpic/appendPrdCreatedEvent/removeEpic writes this file.
|
|
43
|
+
tombstones: parsed && typeof parsed.tombstones === 'object' && parsed.tombstones ? parsed.tombstones : {},
|
|
37
44
|
};
|
|
38
45
|
} catch {
|
|
39
|
-
return { sessions: {}, events: {} };
|
|
46
|
+
return { sessions: {}, events: {}, tombstones: {} };
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -182,9 +189,13 @@ function withPathLock(lockPath, task) {
|
|
|
182
189
|
*
|
|
183
190
|
* `status` defaults to 'proposed' — a fail-safe default so any caller that
|
|
184
191
|
* forgets to pass it files an Epic that waits for human approval rather than
|
|
185
|
-
* one that starts running immediately.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
192
|
+
* one that starts running immediately. Every Epic is BORN 'proposed' — the
|
|
193
|
+
* mint branch below ignores/rejects any other requested status; it is
|
|
194
|
+
* fail-closed, mirroring opsOwnership.cjs's assertOpsWrite. Activation
|
|
195
|
+
* ('proposed' → 'active') happens exactly once, entirely in the renderer
|
|
196
|
+
* store's `approveProposed` (`state/promptSessions.ts`) — that code path
|
|
197
|
+
* never calls ensureEpic(). Joining an already-'active' Epic (this function's
|
|
198
|
+
* join branches, above the mint branch) remains legal and unchanged.
|
|
188
199
|
*
|
|
189
200
|
* `mintIfMissing` defaults to true for the small set of callers that are
|
|
190
201
|
* themselves the human-intent gate (propose-epic, the RCA hook, the feedback
|
|
@@ -223,6 +234,12 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
223
234
|
return { epicId: explicitEpicId, prdDir, created: false };
|
|
224
235
|
}
|
|
225
236
|
console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through`);
|
|
237
|
+
appendAuditEvent('epic_mint_refused', {
|
|
238
|
+
cwd,
|
|
239
|
+
epicId: explicitEpicId,
|
|
240
|
+
status,
|
|
241
|
+
reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through to mint`,
|
|
242
|
+
});
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
if (reuseByGoal) {
|
|
@@ -251,11 +268,11 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
if (!mintIfMissing) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
+ '
|
|
257
|
-
|
|
258
|
-
);
|
|
271
|
+
const reason = 'no existing Epic found and mintIfMissing is false — a new Epic can only be created by '
|
|
272
|
+
+ 'explicit human intent (New Epic UI, or /propose-epic + Approve & start), never implicitly by a '
|
|
273
|
+
+ 'PRD-authoring path';
|
|
274
|
+
appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
|
|
275
|
+
throw new Error(`ensureEpic: ${reason} (epicId=${explicitEpicId ?? 'none'})`);
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
if (!forceNewEpic) {
|
|
@@ -267,6 +284,20 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
267
284
|
}
|
|
268
285
|
}
|
|
269
286
|
|
|
287
|
+
// BORN-PROPOSED LAW (fail-closed, mirrors opsOwnership.cjs's
|
|
288
|
+
// assertOpsWrite): a mint always writes 'proposed', regardless of what
|
|
289
|
+
// status the caller requested. A caller explicitly asking to mint
|
|
290
|
+
// 'active' is refused outright rather than silently downgraded — that
|
|
291
|
+
// shape (mint + already-active) should never occur, so it is treated as
|
|
292
|
+
// a bug in the caller, not a normal fallback path.
|
|
293
|
+
if (status !== 'proposed') {
|
|
294
|
+
const reason = `ensureEpic: refusing to mint a new Epic with status '${status}' — every Epic is born `
|
|
295
|
+
+ "'proposed' (CLAUDE.md domain model); activation happens only via the renderer store's "
|
|
296
|
+
+ 'approveProposed, never through ensureEpic()';
|
|
297
|
+
appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
|
|
298
|
+
throw new Error(reason);
|
|
299
|
+
}
|
|
300
|
+
|
|
270
301
|
const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
271
302
|
const now = new Date().toISOString();
|
|
272
303
|
const session = {
|
|
@@ -350,12 +381,28 @@ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
|
|
|
350
381
|
*/
|
|
351
382
|
function removeEpic(cwd, epicId) {
|
|
352
383
|
if (!cwd || !epicId) return false;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
384
|
+
return withPathLock(activeIndexPath(cwd), () => {
|
|
385
|
+
const index = readActiveIndex(cwd);
|
|
386
|
+
if (!hasOwn(index.sessions, epicId)) return false;
|
|
387
|
+
delete index.sessions[epicId];
|
|
388
|
+
delete index.events[epicId];
|
|
389
|
+
writeActiveIndex(cwd, index);
|
|
390
|
+
return true;
|
|
391
|
+
});
|
|
359
392
|
}
|
|
360
393
|
|
|
361
|
-
module.exports = {
|
|
394
|
+
module.exports = {
|
|
395
|
+
ensureEpic,
|
|
396
|
+
appendPrdCreatedEvent,
|
|
397
|
+
removeEpic,
|
|
398
|
+
activeIndexPath,
|
|
399
|
+
readActiveIndex,
|
|
400
|
+
findJoinableEpic,
|
|
401
|
+
tokenize,
|
|
402
|
+
// Exported so lib/activeIndexMerge.cjs's renderer-facing merge IPC handler
|
|
403
|
+
// serializes through the SAME lock instance as ensureEpic/
|
|
404
|
+
// appendPrdCreatedEvent (module-level Map, shared via Node's require
|
|
405
|
+
// cache) — one lock per active-index.json path across both callers, not
|
|
406
|
+
// two independent lock maps that could still interleave.
|
|
407
|
+
withPathLock,
|
|
408
|
+
};
|
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const config = require('./config.cjs');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
// activeIndexPath/withPathLock come from epicMint.cjs so this module's
|
|
17
|
+
// read-modify-write of active-index.json (appendResponseEventIfKnown, below)
|
|
18
|
+
// serializes through the EXACT SAME lock instance as ensureEpic/
|
|
19
|
+
// appendPrdCreatedEvent and lib/activeIndexMerge.cjs's mergeActiveIndex — one
|
|
20
|
+
// lock across all three writers of this file, not three independent maps
|
|
21
|
+
// that could still interleave a stale read-modify-write past each other.
|
|
22
|
+
const { activeIndexPath: promptSessionActiveIndexPath, withPathLock } = require('./lib/epicMint.cjs');
|
|
20
23
|
|
|
21
24
|
// IPC channel broadcast whenever an event is appended to a PromptSession's
|
|
22
25
|
// chain from the main process (currently only the scheduler's response-event
|
|
@@ -40,26 +43,6 @@ function mintEventId() {
|
|
|
40
43
|
return `pevt-${Date.now().toString(36)}-${seq}`;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
// Serializes read-modify-write cycles per active-index.json path so two
|
|
44
|
-
// PRDs finishing back-to-back for sessions in the same cwd chain onto the
|
|
45
|
-
// tail in issue order instead of racing two independent read-then-write
|
|
46
|
-
// round trips (mirrors promptSessions.ts's own pendingWritesByPath).
|
|
47
|
-
const pendingWritesByPath = new Map();
|
|
48
|
-
|
|
49
|
-
function withPathLock(path, task) {
|
|
50
|
-
const prior = pendingWritesByPath.get(path) || Promise.resolve();
|
|
51
|
-
const settle = () => task();
|
|
52
|
-
const run = prior.then(settle, settle);
|
|
53
|
-
pendingWritesByPath.set(
|
|
54
|
-
path,
|
|
55
|
-
run.then(
|
|
56
|
-
() => undefined,
|
|
57
|
-
() => undefined,
|
|
58
|
-
),
|
|
59
|
-
);
|
|
60
|
-
return run;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
46
|
/**
|
|
64
47
|
* If `sourcePromptId` resolves to a known, still-active PromptSession under
|
|
65
48
|
* `cwd`'s active-index.json, appends a 'response' event chained to that
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -565,22 +565,33 @@ async function archivedTwinExists(job) {
|
|
|
565
565
|
}
|
|
566
566
|
|
|
567
567
|
/**
|
|
568
|
-
* Build the non-failure result + run meta for a job whose PRD
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
568
|
+
* Build the non-failure result + run meta for a queued job whose PRD source
|
|
569
|
+
* is gone. Shared by both PRD-read failure exits in executeJob so the
|
|
570
|
+
* stale-skip logic isn't duplicated. Two reasons, both exitCode: 0 (no RCA
|
|
571
|
+
* feedback item, since neither is a real work failure):
|
|
572
|
+
* - 'prd-archived': the source was archived — archivedTwinExists found it
|
|
573
|
+
* under a prds-archived/ dir. The work already shipped.
|
|
574
|
+
* - 'prd-missing': the source is gone everywhere (ENOENT on every
|
|
575
|
+
* candidate dir, no archived twin either) — most commonly a PRD that
|
|
576
|
+
* existed at enqueue time and was deleted before dispatch (e.g. a test
|
|
577
|
+
* fixture that leaked into the live queue and was cleaned up by its own
|
|
578
|
+
* `finally` block). Retire the row rather than fail it.
|
|
572
579
|
*/
|
|
573
|
-
function prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath) {
|
|
574
|
-
const
|
|
575
|
-
|
|
580
|
+
function prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, reason = 'prd-archived') {
|
|
581
|
+
const msg = reason === 'prd-missing'
|
|
582
|
+
? 'PRD source no longer exists on disk — retiring stale queue entry'
|
|
583
|
+
: (() => {
|
|
584
|
+
const archivedTwin = archivedPrdPathForJob(job);
|
|
585
|
+
return `PRD already archived (${archivedTwin}) — work shipped; retiring stale queue entry`;
|
|
586
|
+
})();
|
|
576
587
|
safeLog(`[scheduler] ${msg}\n`);
|
|
577
588
|
closeFd();
|
|
578
589
|
const finishedAt = Date.now();
|
|
579
590
|
config.writeJsonSync(metaPath, {
|
|
580
|
-
slug: job.slug, cwd, sessionId, exitCode: 0, skipped:
|
|
591
|
+
slug: job.slug, cwd, sessionId, exitCode: 0, skipped: reason,
|
|
581
592
|
note: msg, startedAt, finishedAt, durationMs: 0,
|
|
582
593
|
});
|
|
583
|
-
return { exitCode: 0, durationMs: 0, skipped:
|
|
594
|
+
return { exitCode: 0, durationMs: 0, skipped: reason, note: msg, sessionId };
|
|
584
595
|
}
|
|
585
596
|
|
|
586
597
|
/**
|
|
@@ -2032,16 +2043,34 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2032
2043
|
prompt = parsed.body + FINISH_PROTOCOL;
|
|
2033
2044
|
prdPath = fallbackPath;
|
|
2034
2045
|
} catch (e2) {
|
|
2046
|
+
// Found the dir a moment ago but the read still failed. Case A: the
|
|
2047
|
+
// source has since been archived — stale-skip as usual. Case B: it
|
|
2048
|
+
// was deleted out from under us in the window between findPrdDir and
|
|
2049
|
+
// parsePrd (ENOENT) with no archived twin — also a stale row, not a
|
|
2050
|
+
// real failure. Anything else (malformed/unreadable PRD) is a real
|
|
2051
|
+
// failure and keeps exitCode: -1.
|
|
2035
2052
|
if (await archivedTwinExists(job)) {
|
|
2036
|
-
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath);
|
|
2053
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
|
|
2054
|
+
}
|
|
2055
|
+
if (e2 && e2.code === 'ENOENT') {
|
|
2056
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
|
|
2037
2057
|
}
|
|
2038
2058
|
safeLog(`[scheduler] failed to read PRD: ${e2?.message}\n`);
|
|
2039
2059
|
closeFd();
|
|
2040
2060
|
return { exitCode: -1, durationMs: 0, error: e2?.message };
|
|
2041
2061
|
}
|
|
2042
2062
|
} else {
|
|
2063
|
+
// No candidate dir has the slug at all. Case A: it was archived —
|
|
2064
|
+
// stale-skip. Case B: the read error is ENOENT (the common case: a
|
|
2065
|
+
// source that existed at enqueue time and is gone by dispatch, e.g. a
|
|
2066
|
+
// leaked test fixture) with no archived twin — retire as stale rather
|
|
2067
|
+
// than a hard failure. Anything else (a real read/parse error) still
|
|
2068
|
+
// fails the job.
|
|
2043
2069
|
if (await archivedTwinExists(job)) {
|
|
2044
|
-
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath);
|
|
2070
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
|
|
2071
|
+
}
|
|
2072
|
+
if (e && e.code === 'ENOENT') {
|
|
2073
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
|
|
2045
2074
|
}
|
|
2046
2075
|
safeLog(`[scheduler] failed to read PRD: ${e?.message}\n`);
|
|
2047
2076
|
closeFd();
|
|
@@ -4480,4 +4509,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
4480
4509
|
});
|
|
4481
4510
|
}
|
|
4482
4511
|
|
|
4483
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
|
|
4512
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
|
package/src/preload/api.d.ts
CHANGED
|
@@ -1556,6 +1556,15 @@ export interface SessionManagerAPI {
|
|
|
1556
1556
|
/** Read back an Epic's full-text turns (optionally capped to the last `limit`). Skips corrupt lines rather than throwing. */
|
|
1557
1557
|
read: (cwd: string, epicId: string, limit?: number) => Promise<{ turns: PromptSessionTranscriptTurn[] }>;
|
|
1558
1558
|
};
|
|
1559
|
+
auditLog: {
|
|
1560
|
+
/** Append one Epic-lifecycle audit event to ~/.claude/session-manager/audit-log.jsonl.
|
|
1561
|
+
* Kind allowlist is enforced main-side (ipcSchemas.cjs) — this channel is
|
|
1562
|
+
* append-only, no read/list IPC exists. */
|
|
1563
|
+
append: (
|
|
1564
|
+
kind: 'epic_create' | 'epic_approve' | 'epic_complete' | 'epic_delete' | 'epic_resume' | 'epic_duplicate',
|
|
1565
|
+
fields: { cwd: string; epicId: string; source: string },
|
|
1566
|
+
) => Promise<{ ok: boolean }>;
|
|
1567
|
+
};
|
|
1559
1568
|
agentMemory: {
|
|
1560
1569
|
/** List all memory entries for one subagent. Sorted newest first. */
|
|
1561
1570
|
list: (agentId: string) => Promise<AgentMemoryListResult>;
|
|
@@ -1644,9 +1653,28 @@ export interface SessionManagerAPI {
|
|
|
1644
1653
|
* from notifyOriginatingTab) — lets an already-hydrated Epic pick it up
|
|
1645
1654
|
* live instead of waiting for a restart. */
|
|
1646
1655
|
onEventAppended: (handler: (e: PromptSessionEventAppendedPayload) => void) => () => void;
|
|
1656
|
+
/** Main-side read-merge-write of a cwd's active-index.json
|
|
1657
|
+
* (lib/activeIndexMerge.cjs) — persistActiveIndex sends only its own
|
|
1658
|
+
* in-memory contribution for the cwd; disk stays main's truth, merged
|
|
1659
|
+
* inside the same lock epicMint.cjs's ensureEpic serializes through. */
|
|
1660
|
+
mergeActiveIndex: (payload: PromptSessionsMergeActiveIndexPayload) => Promise<PromptSessionsMergeActiveIndexResult>;
|
|
1647
1661
|
};
|
|
1648
1662
|
}
|
|
1649
1663
|
|
|
1664
|
+
// ─────────────────────────────────── PromptSessions active-index merge
|
|
1665
|
+
export interface PromptSessionsMergeActiveIndexPayload {
|
|
1666
|
+
cwd: string;
|
|
1667
|
+
sessions: Record<string, unknown>;
|
|
1668
|
+
events: Record<string, unknown[]>;
|
|
1669
|
+
removedIds?: string[];
|
|
1670
|
+
source: 'epics';
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
export interface PromptSessionsMergeActiveIndexResult {
|
|
1674
|
+
sessions: Record<string, unknown>;
|
|
1675
|
+
events: Record<string, unknown[]>;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1650
1678
|
// ─────────────────────────────────── PromptSession event-appended broadcast
|
|
1651
1679
|
|
|
1652
1680
|
export interface PromptSessionEventAppendedPayload {
|
package/src/preload/index.cjs
CHANGED
|
@@ -339,6 +339,9 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
339
339
|
read: (cwd, epicId, limit) =>
|
|
340
340
|
ipcRenderer.invoke('promptSessionTranscript:read', { cwd, epicId, ...(limit ? { limit } : {}) }),
|
|
341
341
|
},
|
|
342
|
+
auditLog: {
|
|
343
|
+
append: (kind, fields) => ipcRenderer.invoke('auditLog:append', { kind, ...fields }),
|
|
344
|
+
},
|
|
342
345
|
agentMemory: {
|
|
343
346
|
list: (agentId) => ipcRenderer.invoke('agent-memory:list', { agentId }),
|
|
344
347
|
get: (agentId, entryId) => ipcRenderer.invoke('agent-memory:get', { agentId, entryId }),
|
|
@@ -468,5 +471,10 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
468
471
|
ipcRenderer.on('promptSession:event-appended', listener);
|
|
469
472
|
return () => ipcRenderer.removeListener('promptSession:event-appended', listener);
|
|
470
473
|
},
|
|
474
|
+
/** Main-side read-merge-write of a cwd's active-index.json
|
|
475
|
+
* (lib/activeIndexMerge.cjs) — replaces persistActiveIndex's old
|
|
476
|
+
* renderer-side read-merge-write. Payload carries only this renderer's
|
|
477
|
+
* own in-memory contribution for the cwd; disk stays main's truth. */
|
|
478
|
+
mergeActiveIndex: (payload) => ipcRenderer.invoke('promptSessions:merge-active-index', payload),
|
|
471
479
|
},
|
|
472
480
|
});
|