claude-code-session-manager 0.58.0 → 0.59.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/README.md +2 -2
- package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/{index-DEQzGYa6.js → index-BPnfPdLW.js} +1076 -1081
- package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +26 -3
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicMint.cjs +6 -1
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +16 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/scheduler.cjs +226 -47
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +75 -9
- package/src/preload/index.cjs +3 -0
|
@@ -18,6 +18,7 @@ const fsp = require('node:fs/promises');
|
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
20
20
|
const { resolvePrdWriteDir } = require('./prdLocations.cjs');
|
|
21
|
+
const { projectQueuePath } = require('./queueStore.cjs');
|
|
21
22
|
const { expandHome } = require('./expandHome.cjs');
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -95,22 +96,92 @@ async function migratePrds(legacyPrdsDir) {
|
|
|
95
96
|
* Name collisions in prds-archived/ get a `-legacy-<n>` suffix, never an
|
|
96
97
|
* overwrite. Idempotent: an emptied flat dir is a no-op readdir.
|
|
97
98
|
*
|
|
98
|
-
*
|
|
99
|
+
* LIVE JOBS ARE NEVER ARCHIVED (PRD 992). The scheduler still scans this flat
|
|
100
|
+
* dir as a PRD *source* (prdLocations.cjs's resolvePrdsDirs, "scan sources
|
|
101
|
+
* alongside the legacy flat dir"), so a file sitting here can legitimately
|
|
102
|
+
* have a pending/running job. Archiving it out from under that job strands the
|
|
103
|
+
* queue row with no resolvable source. Observed live 2026-08-02:
|
|
104
|
+
* `980-fix-chat-typed-event-renderers.md` sat in this dir with status
|
|
105
|
+
* `running` — a restart in that window would have moved its source mid-run.
|
|
106
|
+
* Such files are left in place and reported as `skipped`, mirroring how the
|
|
107
|
+
* sibling migratePrds() reports `unresolved` rather than dropping anything.
|
|
108
|
+
*
|
|
109
|
+
* Returns { moved, failed: [{ file, reason }], skipped: [{ file, reason }] }.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/** Job statuses that mean "this PRD's source must survive". `needs_review` is
|
|
113
|
+
* live on purpose: it is awaiting human action and will be re-read. */
|
|
114
|
+
const LIVE_JOB_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Slugs with a live job in this project's own queue shard.
|
|
118
|
+
*
|
|
119
|
+
* Returns null when liveness cannot be determined (unreadable/unparseable
|
|
120
|
+
* queue.json) — the caller then FAILS CLOSED and archives nothing, since it
|
|
121
|
+
* cannot prove a file is safe to move. A merely ABSENT queue.json is not an
|
|
122
|
+
* error: a project with no scheduler state has no jobs, so an empty set is
|
|
123
|
+
* the correct answer and consolidation proceeds normally.
|
|
99
124
|
*/
|
|
100
|
-
async function
|
|
125
|
+
async function liveSlugsForCwd(cwd) {
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = await fsp.readFile(projectQueuePath(cwd), 'utf8');
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e?.code === 'ENOENT') return new Set(); // fresh project — nothing queued
|
|
131
|
+
return null; // unreadable — caller fails closed
|
|
132
|
+
}
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
} catch {
|
|
137
|
+
return null; // corrupt — caller fails closed
|
|
138
|
+
}
|
|
139
|
+
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
140
|
+
const live = new Set();
|
|
141
|
+
for (const job of jobs) {
|
|
142
|
+
if (job && typeof job.slug === 'string' && LIVE_JOB_STATUSES.has(job.status)) {
|
|
143
|
+
live.add(job.slug);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return live;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A queue job's slug is its PRD filename minus the `.md` suffix. */
|
|
150
|
+
function slugForPrdFile(name) {
|
|
151
|
+
return name.slice(0, -3);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function consolidateFlatPrds(cwd, opts = {}) {
|
|
101
155
|
const flatDir = resolvePrdWriteDir(cwd);
|
|
102
156
|
const archiveDir = path.join(path.dirname(flatDir), 'prds-archived');
|
|
103
157
|
let entries;
|
|
104
158
|
try {
|
|
105
159
|
entries = await fsp.readdir(flatDir);
|
|
106
160
|
} catch {
|
|
107
|
-
return { moved: 0, failed: [] };
|
|
161
|
+
return { moved: 0, failed: [], skipped: [] };
|
|
108
162
|
}
|
|
109
163
|
|
|
164
|
+
const liveSlugs = opts.liveSlugs !== undefined ? opts.liveSlugs : await liveSlugsForCwd(cwd);
|
|
165
|
+
|
|
110
166
|
let moved = 0;
|
|
111
167
|
const failed = [];
|
|
168
|
+
const skipped = [];
|
|
169
|
+
|
|
170
|
+
// Fail closed: liveness unknown means every file might belong to a live job.
|
|
171
|
+
if (liveSlugs === null) {
|
|
172
|
+
for (const name of entries) {
|
|
173
|
+
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
174
|
+
skipped.push({ file: name, reason: 'queue state unreadable — cannot prove no live job' });
|
|
175
|
+
}
|
|
176
|
+
return { moved: 0, failed, skipped };
|
|
177
|
+
}
|
|
178
|
+
|
|
112
179
|
for (const name of entries) {
|
|
113
180
|
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
181
|
+
if (liveSlugs.has(slugForPrdFile(name))) {
|
|
182
|
+
skipped.push({ file: name, reason: 'live queue job — source must survive' });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
114
185
|
const src = path.join(flatDir, name);
|
|
115
186
|
try {
|
|
116
187
|
await fsp.mkdir(archiveDir, { recursive: true });
|
|
@@ -125,7 +196,7 @@ async function consolidateFlatPrds(cwd) {
|
|
|
125
196
|
failed.push({ file: name, reason: e?.message ?? 'move failed' });
|
|
126
197
|
}
|
|
127
198
|
}
|
|
128
|
-
return { moved, failed };
|
|
199
|
+
return { moved, failed, skipped };
|
|
129
200
|
}
|
|
130
201
|
|
|
131
|
-
module.exports = { migratePrds, consolidateFlatPrds };
|
|
202
|
+
module.exports = { migratePrds, consolidateFlatPrds, LIVE_JOB_STATUSES };
|
|
@@ -38,6 +38,17 @@ const EpicSourceSchema = z.object({
|
|
|
38
38
|
sourceTabId: z.string().optional(),
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
// Mirrors EpicIntakeSection (src/renderer/lib/epicIntake.ts) — the labeled
|
|
42
|
+
// slices composeEpicIntake emits alongside the flat `openingPrompt`, kept as
|
|
43
|
+
// data so the Epic's first turn can render a structured AIM briefing card
|
|
44
|
+
// instead of regex-parsing the flat string back apart.
|
|
45
|
+
const EpicIntakeSectionSchema = z.object({
|
|
46
|
+
kind: z.enum(['actor', 'injection', 'input', 'mission', 'goal', 'reference']),
|
|
47
|
+
label: z.string(),
|
|
48
|
+
text: z.string(),
|
|
49
|
+
source: z.string().optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
41
52
|
// Mirrors PromptSession (src/renderer/state/promptSessions.ts:29-76).
|
|
42
53
|
const PromptSessionSchema = z.object({
|
|
43
54
|
id: z.string(),
|
|
@@ -52,6 +63,10 @@ const PromptSessionSchema = z.object({
|
|
|
52
63
|
openingPrompt: z.string().nullable().optional(),
|
|
53
64
|
source: EpicSourceSchema.optional(),
|
|
54
65
|
agentType: z.string().optional(),
|
|
66
|
+
// Absent on Epics minted before this field existed — those fall back to
|
|
67
|
+
// rendering the flat `openingPrompt` as a single block (see
|
|
68
|
+
// ChatTranscriptTurn.tsx's EpicIntakeCard).
|
|
69
|
+
sections: z.array(EpicIntakeSectionSchema).optional(),
|
|
55
70
|
});
|
|
56
71
|
|
|
57
72
|
/**
|
|
@@ -75,5 +90,6 @@ module.exports = {
|
|
|
75
90
|
PromptSessionSchema,
|
|
76
91
|
EpicSourceSchema,
|
|
77
92
|
EpicTagSchema,
|
|
93
|
+
EpicIntakeSectionSchema,
|
|
78
94
|
assertValidPromptSession,
|
|
79
95
|
};
|
|
@@ -41,13 +41,15 @@ const { validatePath } = require('../config.cjs');
|
|
|
41
41
|
* IPC (config:read-json/write-json, lib/activeIndexMerge.cjs's own merge) is
|
|
42
42
|
* already required to pass through.
|
|
43
43
|
*/
|
|
44
|
-
async function createEpicViaIpc(cwd, { goalText, tag, agentType, source } = {}) {
|
|
44
|
+
async function createEpicViaIpc(cwd, { goalText, tag, agentType, source, openingPrompt, sections } = {}) {
|
|
45
45
|
validatePath(cwd);
|
|
46
46
|
const { epicId } = await ensureEpic(cwd, {
|
|
47
47
|
goalText,
|
|
48
48
|
tag,
|
|
49
49
|
agentType,
|
|
50
50
|
source,
|
|
51
|
+
openingPrompt,
|
|
52
|
+
sections,
|
|
51
53
|
mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI,
|
|
52
54
|
status: 'proposed',
|
|
53
55
|
});
|
|
@@ -60,8 +62,8 @@ function registerPromptSessionsCreateEpicHandlers() {
|
|
|
60
62
|
const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
|
|
61
63
|
ipcMain.handle(
|
|
62
64
|
'promptSessions:create-epic',
|
|
63
|
-
v(s.promptSessionsCreateEpic, ({ cwd, goalText, tag, agentType, source }) =>
|
|
64
|
-
createEpicViaIpc(cwd, { goalText, tag, agentType, source })),
|
|
65
|
+
v(s.promptSessionsCreateEpic, ({ cwd, goalText, tag, agentType, source, openingPrompt, sections }) =>
|
|
66
|
+
createEpicViaIpc(cwd, { goalText, tag, agentType, source, openingPrompt, sections })),
|
|
65
67
|
);
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -18,15 +18,29 @@ const os = require('node:os');
|
|
|
18
18
|
const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Per-project batch picker.
|
|
22
|
-
* project (all jobs sharing one cwd).
|
|
21
|
+
* Per-project batch picker. `dependsOn` is the ONLY ordering primitive.
|
|
23
22
|
*
|
|
24
|
-
* Rules
|
|
25
|
-
* 1.
|
|
26
|
-
* 2.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
23
|
+
* Rules:
|
|
24
|
+
* 1. Eligible = pending, not already running, and no blocking dependency.
|
|
25
|
+
* 2. A dep is blocking while a queue row for it exists non-completed; a
|
|
26
|
+
* FAILED dep holds its dependents with an explicit reason. Transitive
|
|
27
|
+
* holds fall out of this for free (a held job stays `pending`, which is
|
|
28
|
+
* itself a blocking state for anything depending on IT).
|
|
29
|
+
* 3. Everything eligible fires, up to `slots`, ordered by parallelGroup
|
|
30
|
+
* ascending as a PRIORITY HINT only — never as a barrier.
|
|
31
|
+
*
|
|
32
|
+
* `parallelGroup` used to be a WAVE number and this function used to fire at
|
|
33
|
+
* most one wave per tick, holding every higher wave while a lower one was in
|
|
34
|
+
* flight. PRD 832 made the number strictly UNIQUE per PRD and moved ordering
|
|
35
|
+
* to `dependsOn` — `prdCreate.cjs` now warns that an explicit parallelGroup is
|
|
36
|
+
* "deprecated and ignored" — but this picker was never updated to match. With
|
|
37
|
+
* unique numbers every wave is a singleton, so the batch was always exactly
|
|
38
|
+
* ONE job, and because numbers are allocated monotonically upward, each newly
|
|
39
|
+
* queued PRD always sorted ABOVE the in-flight one and was held behind it.
|
|
40
|
+
* Measured effect: max concurrency 1 across 25 recorded runs, against a
|
|
41
|
+
* 5-slot pool. The group logic is gone; the real limits are the machine-wide
|
|
42
|
+
* `sessionSlots` pool and the memory gate, both enforced by the caller in
|
|
43
|
+
* scheduler.cjs's tickQueue.
|
|
30
44
|
*
|
|
31
45
|
* @param {object[]} projectJobs - All jobs for this project (all statuses).
|
|
32
46
|
* @param {Set<string>} runningSlugsInProject - Slugs from the global
|
|
@@ -79,14 +93,24 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
79
93
|
const allPending = projectJobs.filter(
|
|
80
94
|
(j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
|
|
81
95
|
);
|
|
82
|
-
if (allPending.length === 0) return { batch: [], reason: null };
|
|
96
|
+
if (allPending.length === 0) return { batch: [], reason: null, holds: [] };
|
|
83
97
|
|
|
84
98
|
const pending = [];
|
|
85
99
|
const heldByFailedDep = [];
|
|
100
|
+
// Per-job hold records, surfaced to the UI so a `pending` row can say WHICH
|
|
101
|
+
// dep is holding it instead of leaving the reason in console.log.
|
|
102
|
+
const holds = [];
|
|
86
103
|
for (const j of allPending) {
|
|
87
104
|
const dep = blockingDep(j);
|
|
88
105
|
if (!dep) { pending.push(j); continue; }
|
|
89
|
-
|
|
106
|
+
const depRows = rowsForDep(dep);
|
|
107
|
+
const failed = depRows.some((d) => d.status === 'failed');
|
|
108
|
+
if (failed) heldByFailedDep.push({ job: j, dep });
|
|
109
|
+
holds.push({
|
|
110
|
+
slug: j.slug,
|
|
111
|
+
dep,
|
|
112
|
+
depStatus: depRows.find((d) => d.status !== 'completed')?.status ?? 'unknown',
|
|
113
|
+
});
|
|
90
114
|
// running/pending/needs_review dep — simply not eligible this tick.
|
|
91
115
|
}
|
|
92
116
|
if (pending.length === 0) {
|
|
@@ -94,136 +118,69 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
94
118
|
const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
|
|
95
119
|
const reason = `[scheduler] depends-gate [${projectCwd}]: holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`;
|
|
96
120
|
console.log(reason);
|
|
97
|
-
return { batch: [], reason };
|
|
121
|
+
return { batch: [], reason, holds };
|
|
98
122
|
}
|
|
99
|
-
return { batch: [], reason: null };
|
|
123
|
+
return { batch: [], reason: null, holds };
|
|
100
124
|
}
|
|
101
125
|
|
|
102
|
-
// Lowest pending group (computed up-front for the failure-gate check).
|
|
103
|
-
const lowestPendingGroup = pending.reduce(
|
|
104
|
-
(min, j) => Math.min(min, j.parallelGroup ?? 99),
|
|
105
|
-
Infinity,
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
// Cross-group failure gate: refuse to advance past a group with failed jobs.
|
|
109
|
-
// A failed foundation PRD should not allow later groups to run and
|
|
110
|
-
// silently corrupt project state. needs_review is NOT a blocker.
|
|
111
|
-
const blockingFailures = projectJobs.filter(
|
|
112
|
-
(j) => j.status === 'failed' && (j.parallelGroup ?? 99) < lowestPendingGroup,
|
|
113
|
-
);
|
|
114
|
-
if (blockingFailures.length > 0) {
|
|
115
|
-
const slugs = blockingFailures.map((j) => j.slug).join(', ');
|
|
116
|
-
const reason = `[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
|
|
117
|
-
`${blockingFailures.length} failed job(s) in earlier groups [${slugs}]. ` +
|
|
118
|
-
`Reset to pending or archive to unblock.`;
|
|
119
|
-
console.log(reason);
|
|
120
|
-
return { batch: [], reason };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Groups with at least one job in flight: either tracked in runningSlugsInProject
|
|
124
|
-
// (this process spawned it) or still marked 'running' in queue.json
|
|
125
|
-
// (persisted from a previous session that hasn't been orphan-reset yet).
|
|
126
|
-
const jobBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
|
|
127
|
-
const activeGroups = new Set();
|
|
128
|
-
for (const slug of runningSlugsInProject) {
|
|
129
|
-
const job = jobBySlug.get(slug);
|
|
130
|
-
if (job) activeGroups.add(job.parallelGroup ?? 99);
|
|
131
|
-
}
|
|
132
|
-
for (const j of projectJobs) {
|
|
133
|
-
if (j.status === 'running' && !runningSlugsInProject.has(j.slug)) {
|
|
134
|
-
activeGroups.add(j.parallelGroup ?? 99);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (activeGroups.size > 0) {
|
|
139
|
-
const lowestActive = Math.min(...activeGroups);
|
|
140
|
-
if (lowestPendingGroup > lowestActive) {
|
|
141
|
-
// Earlier group still running — wait for it to drain before advancing.
|
|
142
|
-
const reason = `[scheduler] concurrency [${projectCwd}]: g${lowestActive} in flight, holding g${lowestPendingGroup}`;
|
|
143
|
-
console.log(reason);
|
|
144
|
-
return { batch: [], reason };
|
|
145
|
-
}
|
|
146
|
-
if (lowestPendingGroup < lowestActive) {
|
|
147
|
-
// Late-arrival: a lower-numbered (higher-priority) PRD reconciled AFTER
|
|
148
|
-
// a higher-numbered group was already picked. Fire it now in parallel
|
|
149
|
-
// with the active group rather than starving it until drain.
|
|
150
|
-
if (slots <= 0) {
|
|
151
|
-
const reason = `[scheduler] concurrency [${projectCwd}]: no slots for late-arrival g${lowestPendingGroup}`;
|
|
152
|
-
console.log(reason);
|
|
153
|
-
return { batch: [], reason };
|
|
154
|
-
}
|
|
155
|
-
const batch = pending
|
|
156
|
-
.filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
|
|
157
|
-
.slice(0, slots);
|
|
158
|
-
console.log(
|
|
159
|
-
`[scheduler] concurrency [${projectCwd}]: firing late-arrival g${lowestPendingGroup} ` +
|
|
160
|
-
`(${batch.length} job(s)) alongside active g${lowestActive}`,
|
|
161
|
-
);
|
|
162
|
-
return { batch, reason: null };
|
|
163
|
-
}
|
|
164
|
-
// Backfill slots remaining in the current group.
|
|
165
|
-
if (slots <= 0) {
|
|
166
|
-
const reason = `[scheduler] concurrency [${projectCwd}]: cap reached, no slots`;
|
|
167
|
-
console.log(reason);
|
|
168
|
-
return { batch: [], reason };
|
|
169
|
-
}
|
|
170
|
-
const batch = pending
|
|
171
|
-
.filter((j) => (j.parallelGroup ?? 99) === lowestActive)
|
|
172
|
-
.slice(0, slots);
|
|
173
|
-
if (batch.length > 0) {
|
|
174
|
-
console.log(
|
|
175
|
-
`[scheduler] concurrency [${projectCwd}]: backfilling ${batch.length} into g${lowestActive}`,
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
return { batch, reason: null };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// No active group — start the next group fresh.
|
|
182
126
|
if (slots <= 0) {
|
|
183
|
-
const reason = `[scheduler] concurrency [${projectCwd}]:
|
|
127
|
+
const reason = `[scheduler] concurrency [${projectCwd}]: no slots free, holding ${pending.length} eligible job(s)`;
|
|
184
128
|
console.log(reason);
|
|
185
|
-
return { batch: [], reason };
|
|
129
|
+
return { batch: [], reason, holds };
|
|
186
130
|
}
|
|
131
|
+
|
|
132
|
+
// parallelGroup is a PRIORITY HINT, not a barrier: when there are more
|
|
133
|
+
// eligible jobs than slots, the lower (earlier-authored) PRD numbers go
|
|
134
|
+
// first. Everything else about the number is display-only.
|
|
187
135
|
const batch = pending
|
|
188
|
-
.
|
|
136
|
+
.slice()
|
|
137
|
+
.sort((a, b) => (a.parallelGroup ?? 99) - (b.parallelGroup ?? 99))
|
|
189
138
|
.slice(0, slots);
|
|
190
139
|
console.log(
|
|
191
|
-
`[scheduler] concurrency [${projectCwd}]:
|
|
140
|
+
`[scheduler] concurrency [${projectCwd}]: firing ${batch.length} of ${pending.length} ` +
|
|
141
|
+
`eligible job(s) [${batch.map((j) => j.slug).join(', ')}]`,
|
|
192
142
|
);
|
|
193
|
-
return { batch, reason: null };
|
|
143
|
+
return { batch, reason: null, holds };
|
|
194
144
|
}
|
|
195
145
|
|
|
196
146
|
/**
|
|
197
147
|
* Pick the next batch of jobs to spawn this tick.
|
|
198
148
|
*
|
|
199
|
-
*
|
|
200
|
-
* different projects
|
|
201
|
-
* single project, the existing sequential-group semantics are fully preserved.
|
|
149
|
+
* Dependency gates are evaluated PER PROJECT (keyed by cwd), so jobs in
|
|
150
|
+
* different projects never serialize each other.
|
|
202
151
|
*
|
|
203
152
|
* O(N) where N = allJobs.length.
|
|
204
153
|
*
|
|
205
154
|
* @param {object[]} allJobs - Full queue.json job list.
|
|
206
155
|
* @param {Set<string>} running - In-process running slugs (runningSet).
|
|
207
|
-
* @param {number}
|
|
156
|
+
* @param {number} freeSlots - Slots free RIGHT NOW in the machine-wide
|
|
157
|
+
* `sessionSlots` pool (`sessionSlots.available()`). This is already
|
|
158
|
+
* net of every held slot — scheduler jobs AND chat runs — so it must not
|
|
159
|
+
* have the running count subtracted from it again. The scheduler no longer
|
|
160
|
+
* keeps a private concurrency cap of its own: per sessionSlots.cjs's own
|
|
161
|
+
* charter, caps belong to Session-Manager's one pool, not to each consumer.
|
|
208
162
|
* @returns {{ batch: object[], reason: string | null }} Jobs to spawn this
|
|
209
163
|
* tick, plus (when batch is empty because a gate held it) the human-readable
|
|
210
164
|
* hold reason that would otherwise only reach console.log.
|
|
211
165
|
*/
|
|
212
|
-
function pickNextBatch(allJobs, running,
|
|
166
|
+
function pickNextBatch(allJobs, running, freeSlots) {
|
|
213
167
|
if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) {
|
|
214
|
-
return { batch: [], reason: null };
|
|
168
|
+
return { batch: [], reason: null, holds: [] };
|
|
215
169
|
}
|
|
216
170
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// session
|
|
171
|
+
// Orphan correction: a queue.json row still marked `running` that this
|
|
172
|
+
// process did NOT spawn holds no pool slot (it is a leftover from a crashed
|
|
173
|
+
// prior session, pending boot reconciliation). The pool cannot see it, so
|
|
174
|
+
// discount it here rather than starting a job into a slot a soon-to-be-
|
|
175
|
+
// reaped process may still be occupying.
|
|
220
176
|
const queueRunningCount = allJobs.filter((j) => j.status === 'running').length;
|
|
221
|
-
const
|
|
222
|
-
let slots =
|
|
177
|
+
const untrackedRunning = Math.max(0, queueRunningCount - running.size);
|
|
178
|
+
let slots = freeSlots - untrackedRunning;
|
|
223
179
|
if (slots <= 0) {
|
|
224
|
-
const reason = `[scheduler] concurrency:
|
|
180
|
+
const reason = `[scheduler] concurrency: no session slots free ` +
|
|
181
|
+
`(${freeSlots} free in pool, ${untrackedRunning} untracked running row(s))`;
|
|
225
182
|
console.log(reason);
|
|
226
|
-
return { batch: [], reason };
|
|
183
|
+
return { batch: [], reason, holds: [] };
|
|
227
184
|
}
|
|
228
185
|
|
|
229
186
|
// Group all jobs by project cwd.
|
|
@@ -259,15 +216,17 @@ function pickNextBatch(allJobs, running, cap) {
|
|
|
259
216
|
// Aggregate batch across projects, consuming global slots as we go. Track
|
|
260
217
|
// the first hold reason seen so callers can explain an empty overall batch.
|
|
261
218
|
const batch = [];
|
|
219
|
+
const holds = [];
|
|
262
220
|
let heldReason = null;
|
|
263
221
|
for (const { projectJobs, runningSlugsInProject } of projectCandidates) {
|
|
264
222
|
if (slots <= 0) break;
|
|
265
223
|
const projectResult = pickForProject(projectJobs, runningSlugsInProject, slots);
|
|
266
224
|
batch.push(...projectResult.batch);
|
|
225
|
+
holds.push(...(projectResult.holds ?? []));
|
|
267
226
|
slots -= projectResult.batch.length;
|
|
268
227
|
if (heldReason === null && projectResult.reason) heldReason = projectResult.reason;
|
|
269
228
|
}
|
|
270
|
-
return { batch, reason: batch.length === 0 ? heldReason : null };
|
|
229
|
+
return { batch, reason: batch.length === 0 ? heldReason : null, holds };
|
|
271
230
|
}
|
|
272
231
|
|
|
273
232
|
module.exports = { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD };
|
package/src/main/otel.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* otel — mirrors transcripts.cjs classified events as OpenTelemetry spans.
|
|
3
3
|
*
|
|
4
|
-
* Spans are 0-duration "events": one span per classified transcript
|
|
4
|
+
* Spans are 0-duration "events": one span per classified transcript EVENT
|
|
5
|
+
* (a single JSONL line can classify to several events — e.g. a usage rollup
|
|
6
|
+
* alongside real content, or several tool calls in one assistant turn),
|
|
5
7
|
* named `transcript.<kind>`, with structural attributes only. Tool inputs,
|
|
6
8
|
* plan text, and agent prompts are excluded unless `includeContent` is on
|
|
7
9
|
* (matches upstream OTEL_LOG_USER_PROMPTS opt-in).
|
|
@@ -11,9 +11,19 @@
|
|
|
11
11
|
* session-manager-operations/project-pages/README.md. Structure mirrors
|
|
12
12
|
* projectBrief.cjs's `get()`: validatePath first, config.cjs read helpers,
|
|
13
13
|
* one ipcMain.handle registered from index.cjs's registerXHandlers() pattern.
|
|
14
|
+
*
|
|
15
|
+
* Shipped default (PRD "project-home-hosted-html-spec"): when a project has
|
|
16
|
+
* never generated its own output, `get()` no longer returns the bare
|
|
17
|
+
* `{ output: null }` empty-state signal — it falls back to a build-time
|
|
18
|
+
* default `home.html` shipped with the app (`templates/`), returned with
|
|
19
|
+
* `isDefault: true` and `generatedAt: null` so the renderer can label
|
|
20
|
+
* provenance. That default is read from a fixed app-relative path, never
|
|
21
|
+
* written into any project's own `session-manager-operations/` tree — this
|
|
22
|
+
* module stays read-only.
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
const { ipcMain } = require('electron');
|
|
26
|
+
const fs = require('node:fs');
|
|
17
27
|
const path = require('node:path');
|
|
18
28
|
const config = require('./config.cjs');
|
|
19
29
|
|
|
@@ -21,38 +31,72 @@ function outputDir(cwd) {
|
|
|
21
31
|
return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
// Shipped build-time asset (see PRD "project-home-hosted-html-spec"'s
|
|
35
|
+
// default-document requirement) — a fixed app-relative path with no
|
|
36
|
+
// user-controlled segment, so it deliberately does NOT go through
|
|
37
|
+
// config.cjs's validatePath (whose allowedRoots is the user's home dir and
|
|
38
|
+
// would reject/mis-resolve a path under the app's own install directory,
|
|
39
|
+
// e.g. when installed via npx/global npm outside $HOME). Read once and
|
|
40
|
+
// cached in-process; this file never changes at runtime.
|
|
41
|
+
const DEFAULT_HOME_PATH = path.join(__dirname, 'templates', 'project-pages-default-home.html');
|
|
42
|
+
let cachedDefaultHomeHtml = null;
|
|
43
|
+
function readDefaultHomeHtml() {
|
|
44
|
+
if (cachedDefaultHomeHtml === null) {
|
|
45
|
+
cachedDefaultHomeHtml = fs.readFileSync(DEFAULT_HOME_PATH, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
return cachedDefaultHomeHtml;
|
|
48
|
+
}
|
|
49
|
+
function defaultOutput() {
|
|
50
|
+
return { home: readDefaultHomeHtml(), generatedAt: null, isDefault: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The original 4 lenses stay required — their absence still means "no
|
|
54
|
+
// Project Pages generated yet" (now the shipped-default fallback rather
|
|
55
|
+
// than a bare null). 'brief' was added later (5th lens): a project that
|
|
56
|
+
// generated its output BEFORE 'brief'
|
|
57
|
+
// existed has home/marketing/feature/architecture.html but no brief.html, and
|
|
58
|
+
// that must keep reading as "has output" rather than suddenly regressing to
|
|
59
|
+
// the empty state for every pre-existing project. So brief.html is read
|
|
60
|
+
// tolerantly — present, its text is returned; absent, the field is simply
|
|
61
|
+
// omitted from the payload rather than failing the whole read.
|
|
62
|
+
const REQUIRED_LENSES = ['home', 'marketing', 'feature', 'architecture'];
|
|
63
|
+
const OPTIONAL_LENSES = ['brief'];
|
|
64
|
+
const LENSES = [...REQUIRED_LENSES, ...OPTIONAL_LENSES];
|
|
25
65
|
|
|
26
66
|
async function get({ cwd }) {
|
|
27
67
|
const realCwd = config.validatePath(cwd);
|
|
28
68
|
const dir = outputDir(realCwd);
|
|
29
69
|
const manifestResult = await config.readJson(path.join(dir, 'manifest.json'));
|
|
30
70
|
if (!manifestResult.exists || !manifestResult.data || manifestResult.parseError) {
|
|
31
|
-
return { output:
|
|
71
|
+
return { output: defaultOutput() };
|
|
32
72
|
}
|
|
33
73
|
|
|
34
74
|
const htmlResults = await Promise.all(
|
|
35
75
|
LENSES.map((lens) => config.readText(path.join(dir, `${lens}.html`))),
|
|
36
76
|
);
|
|
37
|
-
|
|
38
|
-
|
|
77
|
+
const byLens = Object.fromEntries(LENSES.map((lens, i) => [lens, htmlResults[i]]));
|
|
78
|
+
if (REQUIRED_LENSES.some((lens) => !byLens[lens].exists)) {
|
|
79
|
+
return { output: defaultOutput() };
|
|
39
80
|
}
|
|
40
81
|
|
|
41
|
-
const [home, marketing, feature, architecture] = htmlResults;
|
|
42
82
|
const generatedAt = typeof manifestResult.data.generatedAt === 'string' ? manifestResult.data.generatedAt : null;
|
|
43
83
|
if (!generatedAt) {
|
|
44
|
-
return { output:
|
|
84
|
+
return { output: defaultOutput() };
|
|
45
85
|
}
|
|
46
86
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
},
|
|
87
|
+
const output = {
|
|
88
|
+
home: byLens.home.text,
|
|
89
|
+
marketing: byLens.marketing.text,
|
|
90
|
+
feature: byLens.feature.text,
|
|
91
|
+
architecture: byLens.architecture.text,
|
|
92
|
+
generatedAt,
|
|
93
|
+
isDefault: false,
|
|
55
94
|
};
|
|
95
|
+
if (byLens.brief.exists) {
|
|
96
|
+
output.brief = byLens.brief.text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { output };
|
|
56
100
|
}
|
|
57
101
|
|
|
58
102
|
function registerProjectPagesIpc() {
|
|
@@ -63,4 +107,6 @@ function registerProjectPagesIpc() {
|
|
|
63
107
|
module.exports = {
|
|
64
108
|
registerProjectPagesIpc,
|
|
65
109
|
get,
|
|
110
|
+
DEFAULT_HOME_PATH,
|
|
111
|
+
readDefaultHomeHtml,
|
|
66
112
|
};
|
|
@@ -51,8 +51,20 @@ function mintEventId() {
|
|
|
51
51
|
* unknown id, a completed session, an empty event chain, or any I/O error —
|
|
52
52
|
* so notifyOriginatingTab's existing tab-external-ticket path stays the
|
|
53
53
|
* safety net, not a special case.
|
|
54
|
+
*
|
|
55
|
+
* The optional 4th argument stamps `prdSlug`/`outcome` onto the appended
|
|
56
|
+
* event (PRD 976) — which PRD checked in and whether it completed, failed,
|
|
57
|
+
* or needs review — so the Epic's own event chain keeps that signal even
|
|
58
|
+
* after the job is archived out of queue.json. PRD 986 extends the same
|
|
59
|
+
* meta arg (not a fifth positional parameter) with `validation`: a
|
|
60
|
+
* scheduler check-in is born `'unvalidated'` — never `'verified'` —
|
|
61
|
+
* regardless of the job's self-reported outcome; the authoring Epic's own
|
|
62
|
+
* validation pass is what may later move it to 'verified'/'refuted'. All
|
|
63
|
+
* keys are added to the event object only when present in `meta`, never as
|
|
64
|
+
* `undefined`-valued keys, so events appended without them serialize
|
|
65
|
+
* identically to before.
|
|
54
66
|
*/
|
|
55
|
-
async function appendResponseEventIfKnown(cwd, sourcePromptId, text) {
|
|
67
|
+
async function appendResponseEventIfKnown(cwd, sourcePromptId, text, meta = {}) {
|
|
56
68
|
if (!cwd || !sourcePromptId) return false;
|
|
57
69
|
const path = promptSessionActiveIndexPath(cwd);
|
|
58
70
|
try {
|
|
@@ -73,6 +85,9 @@ async function appendResponseEventIfKnown(cwd, sourcePromptId, text) {
|
|
|
73
85
|
at: new Date().toISOString(),
|
|
74
86
|
text,
|
|
75
87
|
};
|
|
88
|
+
if (meta && meta.prdSlug) event.prdSlug = meta.prdSlug;
|
|
89
|
+
if (meta && meta.outcome) event.outcome = meta.outcome;
|
|
90
|
+
if (meta && meta.validation) event.validation = meta.validation;
|
|
76
91
|
data.events[sourcePromptId] = [...events, event];
|
|
77
92
|
await config.writeJson(path, data, { writer: 'scheduler' });
|
|
78
93
|
broadcast(EVENT_APPENDED_CHANNEL, { cwd, promptSessionId: sourcePromptId, event });
|