claude-code-session-manager 0.75.3 → 0.76.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-CzQqcObq.js → AgentLibrary-CBx9l4zN.js} +1 -1
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-Bf0EIE_t.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CpdtWhC8.js} +1 -1
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-DyUbMDmg.js} +1 -1
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-By-wIpry.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-CQmo4QVC.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-BQMd6NOm.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-DEp43FXX.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-CLarzwqA.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-B0sCdIy1.js} +1 -1
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-BhWPVOCD.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-Ddlq8T_O.js} +1 -1
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-D2oA_2Jl.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-DgAgavUM.js} +1 -1
- package/dist/assets/{SaveBar-D-gCUx4n.js → SaveBar-Qvc4Ek-H.js} +1 -1
- package/dist/assets/{Scheduler-Bpd4OGju.js → Scheduler-BmYJvNzK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-2Vx3X5SI.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-Cmrz_LeN.js} +1 -1
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-DVA1eYDP.js} +1 -1
- package/dist/assets/{TagLibrary-E5CLeuVk.js → TagLibrary-DYJGAKZu.js} +1 -1
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-DmPc3amD.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-zfd5LJkK.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-B_4PNh9T.js} +676 -676
- package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-B9es6fdA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/scheduler-mcp-server.cjs +154 -95
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdCreate.test.cjs +109 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +22 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +56 -4
- package/src/main/ipcSchemas.cjs +18 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +33 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/delegationReadiness.cjs +408 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/gitWorktree.cjs +56 -2
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +285 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +94 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdCreate.cjs +46 -14
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueStore.cjs +31 -5
- package/src/main/lib/rcaReport.cjs +1 -1
- package/src/main/lib/reaperHelpers.cjs +47 -1
- package/src/main/lib/schedulerBatch.cjs +171 -29
- package/src/main/lib/schedulerConfig.cjs +80 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +2 -1
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/scheduler.cjs +346 -49
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +2 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schedulerMcpServerHelp.test.cjs — session_manager_help argument handling
|
|
3
|
+
* (none/tool/topic/unknown/both) and the pointer-suffix contract every
|
|
4
|
+
* isError return in scheduler-mcp-server.cjs now carries. Also covers the
|
|
5
|
+
* "app not running" degradation: the static catalog must still answer even
|
|
6
|
+
* when the admin API is unreachable — only the readiness section may report
|
|
7
|
+
* unavailable.
|
|
8
|
+
*
|
|
9
|
+
* TOKEN_PATH (scripts/scheduler-mcp-server.cjs) is computed once, at
|
|
10
|
+
* require-time, from os.homedir(). Since os.homedir() reads process.env.HOME
|
|
11
|
+
* on POSIX, each test temporarily repoints HOME at a fresh tmp dir, clears
|
|
12
|
+
* the require cache, and re-requires the module so TOKEN_PATH resolves under
|
|
13
|
+
* that tmp dir — never touching the real ~/.claude/session-manager/admin-api.json.
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
import { test, expect, afterEach } from 'vitest';
|
|
20
|
+
const fsp = require('node:fs/promises');
|
|
21
|
+
const http = require('node:http');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const SERVER_PATH = path.join(__dirname, '../../../../scripts/scheduler-mcp-server.cjs');
|
|
26
|
+
|
|
27
|
+
const tmpDirs = [];
|
|
28
|
+
const servers = [];
|
|
29
|
+
let originalHome;
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
if (originalHome !== undefined) {
|
|
33
|
+
process.env.HOME = originalHome;
|
|
34
|
+
originalHome = undefined;
|
|
35
|
+
}
|
|
36
|
+
delete require.cache[require.resolve(SERVER_PATH)];
|
|
37
|
+
while (servers.length) {
|
|
38
|
+
const s = servers.pop();
|
|
39
|
+
await new Promise((resolve) => s.close(resolve));
|
|
40
|
+
}
|
|
41
|
+
while (tmpDirs.length) {
|
|
42
|
+
const d = tmpDirs.pop();
|
|
43
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function mkTmp() {
|
|
48
|
+
const d = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mcp-help-'));
|
|
49
|
+
tmpDirs.push(d);
|
|
50
|
+
return d;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Starts a fake admin HTTP server answering GET /admin/mcp/readiness. */
|
|
54
|
+
function startFakeAdminServer(token, readinessBody) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const server = http.createServer((req, res) => {
|
|
57
|
+
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
58
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
59
|
+
res.end(JSON.stringify({ ok: false, error: 'bad token' }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
63
|
+
res.end(JSON.stringify(readinessBody));
|
|
64
|
+
});
|
|
65
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Requires a fresh scheduler-mcp-server.cjs with TOKEN_PATH under `homeDir`. */
|
|
70
|
+
function requireServerWithHome(homeDir) {
|
|
71
|
+
originalHome = process.env.HOME;
|
|
72
|
+
process.env.HOME = homeDir;
|
|
73
|
+
delete require.cache[require.resolve(SERVER_PATH)];
|
|
74
|
+
const mod = require(SERVER_PATH);
|
|
75
|
+
process.env.HOME = originalHome;
|
|
76
|
+
originalHome = undefined;
|
|
77
|
+
return mod;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function writeAdminConfig(homeDir, { port, token }) {
|
|
81
|
+
const tokenPath = path.join(homeDir, '.claude', 'session-manager', 'admin-api.json');
|
|
82
|
+
await fsp.mkdir(path.dirname(tokenPath), { recursive: true });
|
|
83
|
+
await fsp.writeFile(tokenPath, JSON.stringify({ port, token }), 'utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function callTool(handleCallTool, name, args) {
|
|
87
|
+
return handleCallTool({ params: { name, arguments: args } });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('session_manager_help with no arguments returns grouped tool list + recipe titles + readiness', async () => {
|
|
91
|
+
const homeDir = await mkTmp();
|
|
92
|
+
const token = 'test-token';
|
|
93
|
+
const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [{ id: 'scheduler-mcp', ok: true }] });
|
|
94
|
+
servers.push(server);
|
|
95
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
96
|
+
|
|
97
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
98
|
+
const result = await callTool(handleCallTool, 'session_manager_help', {});
|
|
99
|
+
expect(result.isError).toBeFalsy();
|
|
100
|
+
const body = JSON.parse(result.content[0].text);
|
|
101
|
+
expect(Array.isArray(body.tools)).toBe(true);
|
|
102
|
+
expect(body.tools.some((t) => t.name === 'session_manager_help')).toBe(true);
|
|
103
|
+
expect(Array.isArray(body.recipes)).toBe(true);
|
|
104
|
+
expect(body.recipes.some((r) => r.id === 'queue-work-via-develop')).toBe(true);
|
|
105
|
+
expect(body.tool).toBeUndefined();
|
|
106
|
+
expect(body.recipe).toBeUndefined();
|
|
107
|
+
expect(body.readiness.available).toBe(true);
|
|
108
|
+
expect(body.readiness.ok).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('session_manager_help with a valid tool returns that entry including exampleArgs', async () => {
|
|
112
|
+
const homeDir = await mkTmp();
|
|
113
|
+
const token = 'test-token';
|
|
114
|
+
const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [] });
|
|
115
|
+
servers.push(server);
|
|
116
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
117
|
+
|
|
118
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
119
|
+
const result = await callTool(handleCallTool, 'session_manager_help', { tool: 'scheduler_create_prd' });
|
|
120
|
+
expect(result.isError).toBeFalsy();
|
|
121
|
+
const body = JSON.parse(result.content[0].text);
|
|
122
|
+
expect(body.tool.name).toBe('scheduler_create_prd');
|
|
123
|
+
expect(body.tool.exampleArgs).toBeTruthy();
|
|
124
|
+
expect(body.tools).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('session_manager_help with a valid topic returns that recipe\'s steps', async () => {
|
|
128
|
+
const homeDir = await mkTmp();
|
|
129
|
+
const token = 'test-token';
|
|
130
|
+
const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [] });
|
|
131
|
+
servers.push(server);
|
|
132
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
133
|
+
|
|
134
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
135
|
+
const result = await callTool(handleCallTool, 'session_manager_help', { topic: 'unstick-needs-review-job' });
|
|
136
|
+
expect(result.isError).toBeFalsy();
|
|
137
|
+
const body = JSON.parse(result.content[0].text);
|
|
138
|
+
expect(body.recipe.id).toBe('unstick-needs-review-job');
|
|
139
|
+
expect(Array.isArray(body.recipe.steps)).toBe(true);
|
|
140
|
+
expect(body.recipe.steps.length).toBeGreaterThan(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('session_manager_help accepts both tool and topic together, returning both sections', async () => {
|
|
144
|
+
const homeDir = await mkTmp();
|
|
145
|
+
const token = 'test-token';
|
|
146
|
+
const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [] });
|
|
147
|
+
servers.push(server);
|
|
148
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
149
|
+
|
|
150
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
151
|
+
const result = await callTool(handleCallTool, 'session_manager_help', {
|
|
152
|
+
tool: 'scheduler_list_jobs',
|
|
153
|
+
topic: 'queue-work-via-develop',
|
|
154
|
+
});
|
|
155
|
+
expect(result.isError).toBeFalsy();
|
|
156
|
+
const body = JSON.parse(result.content[0].text);
|
|
157
|
+
expect(body.tool.name).toBe('scheduler_list_jobs');
|
|
158
|
+
expect(body.recipe.id).toBe('queue-work-via-develop');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('session_manager_help with an unknown tool returns a helpful error listing valid names', async () => {
|
|
162
|
+
const homeDir = await mkTmp();
|
|
163
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
164
|
+
const result = await callTool(handleCallTool, 'session_manager_help', { tool: 'not_a_real_tool' });
|
|
165
|
+
expect(result.isError).toBe(true);
|
|
166
|
+
expect(result.content[0].text).toContain('not_a_real_tool');
|
|
167
|
+
expect(result.content[0].text).toContain('scheduler_create_prd');
|
|
168
|
+
expect(result.content[0].text).toContain('call session_manager_help for the correct usage');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('session_manager_help with an unknown topic returns a helpful error listing valid ids', async () => {
|
|
172
|
+
const homeDir = await mkTmp();
|
|
173
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
174
|
+
const result = await callTool(handleCallTool, 'session_manager_help', { topic: 'not-a-real-recipe' });
|
|
175
|
+
expect(result.isError).toBe(true);
|
|
176
|
+
expect(result.content[0].text).toContain('not-a-real-recipe');
|
|
177
|
+
expect(result.content[0].text).toContain('queue-work-via-develop');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('session_manager_help still returns the static catalog when the app is not running, with readiness unavailable', async () => {
|
|
181
|
+
// No admin-api.json written under this tmp HOME at all — simulates the app
|
|
182
|
+
// being down. The catalog is bundled in-process, so this must NOT fail
|
|
183
|
+
// wholesale; only the readiness half degrades.
|
|
184
|
+
const homeDir = await mkTmp();
|
|
185
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
186
|
+
const result = await callTool(handleCallTool, 'session_manager_help', {});
|
|
187
|
+
expect(result.isError).toBeFalsy();
|
|
188
|
+
const body = JSON.parse(result.content[0].text);
|
|
189
|
+
expect(Array.isArray(body.tools)).toBe(true);
|
|
190
|
+
expect(body.tools.length).toBeGreaterThan(0);
|
|
191
|
+
expect(body.readiness.available).toBe(false);
|
|
192
|
+
expect(typeof body.readiness.reason).toBe('string');
|
|
193
|
+
expect(body.readiness.reason.length).toBeGreaterThan(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('every isError return from the tool handler carries the session_manager_help pointer', async () => {
|
|
197
|
+
const homeDir = await mkTmp();
|
|
198
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
199
|
+
|
|
200
|
+
const missingSlug = await callTool(handleCallTool, 'scheduler_reset_job', {});
|
|
201
|
+
expect(missingSlug.isError).toBe(true);
|
|
202
|
+
expect(missingSlug.content[0].text).toContain('call session_manager_help for the correct usage');
|
|
203
|
+
|
|
204
|
+
const unknownTool = await callTool(handleCallTool, 'not_a_tool_at_all', {});
|
|
205
|
+
expect(unknownTool.isError).toBe(true);
|
|
206
|
+
expect(unknownTool.content[0].text).toContain('call session_manager_help for the correct usage');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('NOT_RUNNING_ERROR (app not running) carries the pointer and is surfaced via isError', async () => {
|
|
210
|
+
const homeDir = await mkTmp();
|
|
211
|
+
const { handleCallTool, NOT_RUNNING_ERROR } = requireServerWithHome(homeDir);
|
|
212
|
+
expect(NOT_RUNNING_ERROR).toContain('call session_manager_help for the correct usage');
|
|
213
|
+
|
|
214
|
+
const result = await callTool(handleCallTool, 'scheduler_list_jobs', {});
|
|
215
|
+
expect(result.isError).toBe(true);
|
|
216
|
+
expect(result.content[0].text).toBe(NOT_RUNNING_ERROR);
|
|
217
|
+
});
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
const path = require('path');
|
|
37
37
|
const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
|
|
38
38
|
const config = require('../config.cjs');
|
|
39
|
+
const { mirrorEpicStatus } = require('./epicStatusMirror.cjs');
|
|
39
40
|
|
|
40
41
|
function hasOwn(obj, key) {
|
|
41
42
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
@@ -160,6 +161,20 @@ async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events:
|
|
|
160
161
|
// payload, so a compromised/forged renderer call can't claim a
|
|
161
162
|
// different writer id.
|
|
162
163
|
await config.writeJson(filePath, index, { writer: 'epics' });
|
|
164
|
+
// Status mirror (epicStatusMirror.cjs) — this IPC handler is the
|
|
165
|
+
// renderer-driven persist path (persistActiveIndex), the write site for
|
|
166
|
+
// both the 'proposed'->'active' start transition (approveProposed) and
|
|
167
|
+
// every other in-place session mutation. Mirrors every surviving row so
|
|
168
|
+
// a lost/clobbered active-index.json can be reconstructed from these
|
|
169
|
+
// files (activeIndexRebuild.cjs). Best-effort per row: one bad write must
|
|
170
|
+
// not undo the index write that already landed above.
|
|
171
|
+
for (const [id, sess] of Object.entries(mergedSessions)) {
|
|
172
|
+
try {
|
|
173
|
+
mirrorEpicStatus(cwd, id, { session: sess, status: sess.status });
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.warn(`[activeIndexMerge] mirrorEpicStatus failed for ${id}`, e);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
163
178
|
return { sessions: mergedSessions, events: mergedEvents };
|
|
164
179
|
});
|
|
165
180
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* activeIndexRebuild.cjs — reconstructs `active-index.json` from the
|
|
5
|
+
* per-Epic status mirrors `epicStatusMirror.cjs` writes onto `prompt-
|
|
6
|
+
* sessions/<epicId>.json`. This is the recovery path for the incident the
|
|
7
|
+
* mirror exists to survive: a lost, clobbered, git-reverted, or
|
|
8
|
+
* worktree-snapshotted index that would otherwise silently erase every open
|
|
9
|
+
* Epic it held with no way to get them back.
|
|
10
|
+
*
|
|
11
|
+
* Rebuild NEVER mints — SINGLE-CREATOR LAW (epicMint.cjs) is untouched
|
|
12
|
+
* because no new Epic id is ever created here, only rows for ids that
|
|
13
|
+
* already have a mirrored file re-materialized. It also never resurrects a
|
|
14
|
+
* tombstoned or archived Epic (see the two "never resurrect" guards below) —
|
|
15
|
+
* a human's delete/complete decision always wins.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { activeIndexPath, readActiveIndex, writeActiveIndex, withPathLock } = require('./epicMint.cjs');
|
|
21
|
+
|
|
22
|
+
function promptSessionsDir(cwd) {
|
|
23
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
24
|
+
return opsPath(cwd, 'prompt-sessions');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hasOwn(obj, key) {
|
|
28
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function listMirrorFiles(dir) {
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(dir);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return entries.filter((f) => f.endsWith('.json') && f !== 'active-index.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* rebuildActiveIndex(cwd, { dryRun? }) → { rows, skipped, reasons }
|
|
43
|
+
*
|
|
44
|
+
* `rows` — reconstructed PromptSession objects, one per mirrored file that
|
|
45
|
+
* is a live (proposed/active), non-tombstoned, non-archived Epic. These
|
|
46
|
+
* become the new `sessions` map (dryRun: false is the default — pass
|
|
47
|
+
* `{ dryRun: true }` to preview without writing).
|
|
48
|
+
* `skipped` — file names not turned into a row.
|
|
49
|
+
* `reasons` — `{ [fileName]: humanReadableReason }`, one entry per skipped
|
|
50
|
+
* file. A file is never guessed at: no mirrored `status` is a skip, not a
|
|
51
|
+
* fabricated 'unknown' row (see scripts/mirror-epic-status.cjs for the
|
|
52
|
+
* only place that vocabulary is deliberately introduced, for the
|
|
53
|
+
* migration's own back-fill bookkeeping).
|
|
54
|
+
*
|
|
55
|
+
* `events` and `tombstones` from the existing on-disk index (if any) are
|
|
56
|
+
* carried through completely untouched — this function only ever
|
|
57
|
+
* reconstructs `sessions`.
|
|
58
|
+
*/
|
|
59
|
+
function rebuildActiveIndex(cwd, { dryRun = false } = {}) {
|
|
60
|
+
if (!cwd || typeof cwd !== 'string') throw new Error('rebuildActiveIndex: cwd is required');
|
|
61
|
+
cwd = path.resolve(cwd);
|
|
62
|
+
return withPathLock(activeIndexPath(cwd), () => {
|
|
63
|
+
const dir = promptSessionsDir(cwd);
|
|
64
|
+
const existing = readActiveIndex(cwd);
|
|
65
|
+
const tombstones = existing.tombstones || {};
|
|
66
|
+
const files = listMirrorFiles(dir);
|
|
67
|
+
|
|
68
|
+
const rows = [];
|
|
69
|
+
const skipped = [];
|
|
70
|
+
const reasons = {};
|
|
71
|
+
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const id = file.slice(0, -'.json'.length);
|
|
74
|
+
const full = path.join(dir, file);
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
skipped.push(file);
|
|
80
|
+
reasons[file] = `unreadable: ${e.message}`;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
84
|
+
skipped.push(file);
|
|
85
|
+
reasons[file] = 'unreadable: not a JSON object';
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Never resurrected — a tombstoned id stays out even if its file says
|
|
89
|
+
// 'active' (see activeIndexMerge.cjs's header on why tombstones are
|
|
90
|
+
// never auto-cleared).
|
|
91
|
+
if (hasOwn(tombstones, id)) {
|
|
92
|
+
skipped.push(file);
|
|
93
|
+
reasons[file] = 'tombstoned — never resurrected by a rebuild';
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!parsed.status) {
|
|
97
|
+
skipped.push(file);
|
|
98
|
+
reasons[file] = 'no status mirror on this file — never guessed at';
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// A completed archive (markCompleted's PromptSessionArchive, or any
|
|
102
|
+
// mirror written after archival) is never restored as a live row.
|
|
103
|
+
if (parsed.archivedAt) {
|
|
104
|
+
skipped.push(file);
|
|
105
|
+
reasons[file] = 'archivedAt is set — completed Epic, not a live row';
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (parsed.status !== 'proposed' && parsed.status !== 'active') {
|
|
109
|
+
skipped.push(file);
|
|
110
|
+
reasons[file] = `status '${parsed.status}' is not a live row`;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Drop mirror-only bookkeeping (`indexedAt`) — it is not part of the
|
|
114
|
+
// PromptSession shape active-index.json's `sessions` map holds.
|
|
115
|
+
const { indexedAt, archivedAt, ...row } = parsed;
|
|
116
|
+
rows.push({ ...row, id, cwd: parsed.cwd || cwd });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!dryRun) {
|
|
120
|
+
const sessions = {};
|
|
121
|
+
for (const row of rows) sessions[row.id] = row;
|
|
122
|
+
writeActiveIndex(cwd, {
|
|
123
|
+
sessions,
|
|
124
|
+
events: existing.events || {},
|
|
125
|
+
tombstones,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { rows, skipped, reasons };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { rebuildActiveIndex };
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
const fs = require('node:fs');
|
|
31
31
|
const path = require('node:path');
|
|
32
32
|
|
|
33
|
-
const BUILD_TARGET_SUBPATH = ['
|
|
33
|
+
const BUILD_TARGET_SUBPATH = ['architecture', 'build-target.json']; // joined under opsOwnership.opsPath
|
|
34
34
|
|
|
35
35
|
function readJson(filePath) {
|
|
36
36
|
try {
|
|
@@ -48,7 +48,8 @@ function readJson(filePath) {
|
|
|
48
48
|
function resolveBuildTarget(cwd) {
|
|
49
49
|
if (!cwd || typeof cwd !== 'string') return null;
|
|
50
50
|
|
|
51
|
-
const
|
|
51
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
52
|
+
const configPath = opsPath(cwd, ...BUILD_TARGET_SUBPATH);
|
|
52
53
|
const explicit = readJson(configPath);
|
|
53
54
|
if (explicit && typeof explicit === 'object' && !Array.isArray(explicit) && typeof explicit.packageName === 'string') {
|
|
54
55
|
return explicit;
|
|
@@ -49,6 +49,10 @@
|
|
|
49
49
|
const fs = require('node:fs');
|
|
50
50
|
const { spawn } = require('node:child_process');
|
|
51
51
|
|
|
52
|
+
// Grace period between the post-exit SIGTERM sweep and the follow-up SIGKILL
|
|
53
|
+
// sweep of a detached child's process group. See sweepChildProcessGroup below.
|
|
54
|
+
const POST_EXIT_GROUP_SWEEP_GRACE_MS = 5000;
|
|
55
|
+
|
|
52
56
|
// ---------- Phase 1 ----------
|
|
53
57
|
|
|
54
58
|
/**
|
|
@@ -119,6 +123,33 @@ function openLog(logPath) {
|
|
|
119
123
|
* }} opts
|
|
120
124
|
* @returns {{ child: import('child_process').ChildProcess | null, cancel: () => void }}
|
|
121
125
|
*/
|
|
126
|
+
// Post-exit process-group sweep.
|
|
127
|
+
//
|
|
128
|
+
// Incident (2026-08-31, ~/Projects/starry-night-ships): scheduler jobs spawn
|
|
129
|
+
// detached: true, and every watchdog path (result-tail, deadman, idle-tail)
|
|
130
|
+
// kills the whole process group via ctx.killTree — but only on an *overrun*
|
|
131
|
+
// exit. When the executor child exits normally, nothing swept the group, so
|
|
132
|
+
// anything the agent had backgrounded to work around the foreground call cap
|
|
133
|
+
// (the `bash -c '<gate> > /tmp/x.log; echo $? > /tmp/x.done'` shape) reparented
|
|
134
|
+
// to init and kept running for nobody: three orphaned test batteries at PPID 1
|
|
135
|
+
// were found, one burning ~5 cores for 56 minutes. The invariant this restores:
|
|
136
|
+
// a job's descendants must not outlive the job. Killing the job's own process
|
|
137
|
+
// group at the job's own exit is ownership-based and needs no /proc heuristics
|
|
138
|
+
// to distinguish an abandoned descendant from a live one.
|
|
139
|
+
function sweepChildProcessGroup(child, detached) {
|
|
140
|
+
if (!detached) return;
|
|
141
|
+
if (!child || !child.pid || child.pid <= 1) return;
|
|
142
|
+
// Defensive: never target the Session Manager process's own group.
|
|
143
|
+
if (child.pid === process.pid) return;
|
|
144
|
+
|
|
145
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* ESRCH: empty group, the normal case */ }
|
|
146
|
+
|
|
147
|
+
const t = setTimeout(() => {
|
|
148
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* ESRCH: empty group, the normal case */ }
|
|
149
|
+
}, POST_EXIT_GROUP_SWEEP_GRACE_MS);
|
|
150
|
+
if (t.unref) t.unref();
|
|
151
|
+
}
|
|
152
|
+
|
|
122
153
|
function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
|
|
123
154
|
const startedAt = Date.now();
|
|
124
155
|
|
|
@@ -183,6 +214,7 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
|
|
|
183
214
|
safeLog,
|
|
184
215
|
});
|
|
185
216
|
}
|
|
217
|
+
sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached);
|
|
186
218
|
closeFd();
|
|
187
219
|
};
|
|
188
220
|
|
|
@@ -215,4 +247,4 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
|
|
|
215
247
|
return { child, cancel };
|
|
216
248
|
}
|
|
217
249
|
|
|
218
|
-
module.exports = { openLog, withChildAndLog };
|
|
250
|
+
module.exports = { openLog, withChildAndLog, POST_EXIT_GROUP_SWEEP_GRACE_MS };
|
|
@@ -66,6 +66,7 @@ const {
|
|
|
66
66
|
} = require('./epicMint.cjs');
|
|
67
67
|
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
68
68
|
const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
69
|
+
const { resolveProjectContext } = require('./projectRootResolve.cjs');
|
|
69
70
|
|
|
70
71
|
/** Only these three Epic tags make sense for an inbound report — they are
|
|
71
72
|
* exactly the tags that route through /develop (tagLibrary.ts). 'build',
|
|
@@ -251,8 +252,14 @@ function validateFeedbackInput(input = {}) {
|
|
|
251
252
|
} catch (e) {
|
|
252
253
|
return bad(400, `toCwd rejected: ${e.message}`);
|
|
253
254
|
}
|
|
255
|
+
// Normalize fromCwd (worktree -> main tree, ops-internal path -> project
|
|
256
|
+
// root) BEFORE validatePath/the same-project check below — an agent
|
|
257
|
+
// sending feedback from inside its own Epic's worktree pwd must be
|
|
258
|
+
// compared/chained against its REAL project, not the ephemeral checkout.
|
|
259
|
+
// See projectRootResolve.cjs's header.
|
|
260
|
+
const normalizedFromCwd = resolveProjectContext({ cwd: input.fromCwd }).cwd || input.fromCwd;
|
|
254
261
|
try {
|
|
255
|
-
fromCwd = config.validatePath(path.resolve(
|
|
262
|
+
fromCwd = config.validatePath(path.resolve(normalizedFromCwd));
|
|
256
263
|
} catch (e) {
|
|
257
264
|
return bad(400, `fromCwd rejected: ${e.message}`);
|
|
258
265
|
}
|