claude-code-session-manager 0.79.0 → 0.81.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-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/branchSweep.cjs +127 -0
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +117 -12
- package/src/main/lib/landedSinceRun.cjs +41 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +28 -5
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1554 -71
- package/src/preload/api.d.ts +8 -0
|
@@ -19,7 +19,12 @@ const assert = require('node:assert/strict');
|
|
|
19
19
|
const fs = require('node:fs');
|
|
20
20
|
const os = require('node:os');
|
|
21
21
|
const path = require('node:path');
|
|
22
|
-
const {
|
|
22
|
+
const { spawn } = require('node:child_process');
|
|
23
|
+
const {
|
|
24
|
+
selectReapableJobs, mapOutcomeToGateOutcome, classifyRunOutcome,
|
|
25
|
+
findLiveProcessForJob, logHasOutput, resolvePidlessGateOutcome,
|
|
26
|
+
isAlreadySatisfiedOnMain,
|
|
27
|
+
} = require('../reaperHelpers.cjs');
|
|
23
28
|
const { detectRateLimitInLog } = require('../rateLimitDetect.cjs');
|
|
24
29
|
|
|
25
30
|
const NOW = Date.parse('2026-09-01T12:00:00.000Z');
|
|
@@ -101,6 +106,33 @@ test('non-running rows are never considered, pidless or not', () => {
|
|
|
101
106
|
assert.deepStrictEqual(warnings, []);
|
|
102
107
|
});
|
|
103
108
|
|
|
109
|
+
test('pidless + past grace + findLiveProcess finds a pid → recovered, never reaped', () => {
|
|
110
|
+
const jobs = [{ slug: 'zombie-alive', status: 'running', startedAt: agoMin(464) }];
|
|
111
|
+
const findLiveProcess = () => 2174739;
|
|
112
|
+
const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE, findLiveProcess });
|
|
113
|
+
assert.deepStrictEqual(reapable, []);
|
|
114
|
+
assert.deepStrictEqual(warnings, []);
|
|
115
|
+
assert.strictEqual(recovered.length, 1);
|
|
116
|
+
assert.strictEqual(recovered[0].slug, 'zombie-alive');
|
|
117
|
+
assert.strictEqual(recovered[0].pid, 2174739);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('pidless + past grace + findLiveProcess finds nothing → reaped exactly as today', () => {
|
|
121
|
+
const jobs = [{ slug: 'zombie-dead', status: 'running', startedAt: agoMin(464) }];
|
|
122
|
+
const findLiveProcess = () => null;
|
|
123
|
+
const { reapable, recovered } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE, findLiveProcess });
|
|
124
|
+
assert.deepStrictEqual(recovered, []);
|
|
125
|
+
assert.strictEqual(reapable.length, 1);
|
|
126
|
+
assert.strictEqual(reapable[0].pidless, true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('pidless + past grace + no findLiveProcess supplied → reaped exactly as before this PRD', () => {
|
|
130
|
+
const jobs = [{ slug: 'zombie', status: 'running', startedAt: agoMin(464) }];
|
|
131
|
+
const { reapable, recovered } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
|
|
132
|
+
assert.strictEqual(reapable.length, 1);
|
|
133
|
+
assert.deepStrictEqual(recovered, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
104
136
|
test('mixed batch: each row classified independently', () => {
|
|
105
137
|
const jobs = [
|
|
106
138
|
{ slug: 'zombie', status: 'running', startedAt: agoMin(464) },
|
|
@@ -220,3 +252,90 @@ test('classifyRunOutcome: a clean success log still classifies as success', () =
|
|
|
220
252
|
const p = writeTmpLog('{"type":"result","subtype":"success","is_error":false,"result":"done"}\n');
|
|
221
253
|
assert.strictEqual(classifyRunOutcome(p), 'success');
|
|
222
254
|
});
|
|
255
|
+
|
|
256
|
+
// findLiveProcessForJob — the /proc positive liveness scan behind the
|
|
257
|
+
// 2026-09-06 stranded-branch incident (234-uranus-eight-tails-ox: marked
|
|
258
|
+
// failed/never_ran while its process was alive and still writing to its
|
|
259
|
+
// worktree). Linux-only, and a safe no-op everywhere else per its own header.
|
|
260
|
+
|
|
261
|
+
test('findLiveProcessForJob: finds a live process via /proc/<pid>/cwd match', async () => {
|
|
262
|
+
if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
|
|
263
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reaper-liveness-'));
|
|
264
|
+
const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
|
|
265
|
+
try {
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
267
|
+
const pid = findLiveProcessForJob({ slug: 'irrelevant-slug' }, { worktreeDir: dir });
|
|
268
|
+
assert.strictEqual(pid, child.pid);
|
|
269
|
+
} finally {
|
|
270
|
+
child.kill('SIGKILL');
|
|
271
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('findLiveProcessForJob: no live process anywhere → null', () => {
|
|
276
|
+
if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
|
|
277
|
+
const pid = findLiveProcessForJob(
|
|
278
|
+
{ slug: 'definitely-not-a-real-running-slug-xyz123' },
|
|
279
|
+
{ worktreeDir: '/nonexistent/worktree/dir/xyz123' },
|
|
280
|
+
);
|
|
281
|
+
assert.strictEqual(pid, null);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// logHasOutput — the literal "did the run dir produce any log output" check
|
|
285
|
+
// that gates whether a pidless reap may assert gateOutcome: 'never_ran'.
|
|
286
|
+
|
|
287
|
+
test('logHasOutput: missing file → false', () => {
|
|
288
|
+
assert.strictEqual(logHasOutput('/nonexistent/path/does-not-exist.log'), false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('logHasOutput: empty file → false', () => {
|
|
292
|
+
const p = writeTmpLog('');
|
|
293
|
+
assert.strictEqual(logHasOutput(p), false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('logHasOutput: file with content → true', () => {
|
|
297
|
+
const p = writeTmpLog('some real log output\n');
|
|
298
|
+
assert.strictEqual(logHasOutput(p), true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('logHasOutput: null path → false', () => {
|
|
302
|
+
assert.strictEqual(logHasOutput(null), false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// resolvePidlessGateOutcome — never_ran is asserted ONLY when the run dir has
|
|
306
|
+
// no log output; a run dir WITH output must not claim never_ran even if no
|
|
307
|
+
// clean result event was found in it.
|
|
308
|
+
|
|
309
|
+
test('resolvePidlessGateOutcome: no output → never_ran, regardless of raw outcome (terminalizes as today)', () => {
|
|
310
|
+
assert.strictEqual(resolvePidlessGateOutcome('no_result', false), 'never_ran');
|
|
311
|
+
assert.strictEqual(resolvePidlessGateOutcome('success', false), 'never_ran');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('resolvePidlessGateOutcome: output present + no_result → failed, never never_ran', () => {
|
|
315
|
+
assert.strictEqual(resolvePidlessGateOutcome('no_result', true), 'failed');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('resolvePidlessGateOutcome: output present + success → passed', () => {
|
|
319
|
+
assert.strictEqual(resolvePidlessGateOutcome('success', true), 'passed');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('resolvePidlessGateOutcome: output present + failed → failed', () => {
|
|
323
|
+
assert.strictEqual(resolvePidlessGateOutcome('failed', true), 'failed');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// isAlreadySatisfiedOnMain — PRD 1136. Pure decision layer: given the
|
|
327
|
+
// already-git-queried list of satisfying commits, decide whether the
|
|
328
|
+
// commit-guard's 'silent_no_op' verdict should be overridden to 'completed'.
|
|
329
|
+
|
|
330
|
+
test('isAlreadySatisfiedOnMain: a satisfying commit → completed-shaped verdict naming the sha', () => {
|
|
331
|
+
const verdict = isAlreadySatisfiedOnMain(['5de4134abc123', 'a96de98def456']);
|
|
332
|
+
assert.strictEqual(verdict.sha, '5de4134abc123');
|
|
333
|
+
assert.strictEqual(verdict.verdict, 'already_satisfied_on_main');
|
|
334
|
+
assert.match(verdict.reason, /5de4134abc123/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('isAlreadySatisfiedOnMain: no satisfying commits → null — the gate must not be weakened into always-passing', () => {
|
|
338
|
+
assert.strictEqual(isAlreadySatisfiedOnMain([]), null);
|
|
339
|
+
assert.strictEqual(isAlreadySatisfiedOnMain(null), null);
|
|
340
|
+
assert.strictEqual(isAlreadySatisfiedOnMain(undefined), null);
|
|
341
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
// vitest globals (test) — same convention as the other .cjs tests.
|
|
13
13
|
const assert = require('node:assert/strict');
|
|
14
|
-
const { pickForProject, DEFAULT_PROJECT_CWD } = require('../schedulerBatch.cjs');
|
|
14
|
+
const { pickForProject, findBlockingDep, DEFAULT_PROJECT_CWD, DEP_HISTORY_FAIL_OPEN } = require('../schedulerBatch.cjs');
|
|
15
15
|
|
|
16
16
|
const CWD = DEFAULT_PROJECT_CWD;
|
|
17
17
|
|
|
@@ -74,14 +74,66 @@ test('still resolves an exact-slug dep (dep written WITH the NN- prefix)', () =>
|
|
|
74
74
|
assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
// PRD 1122 — a dep slug with no live row is no longer auto-satisfied by
|
|
78
|
+
// absence alone. It must show up in the caller's precomputed
|
|
79
|
+
// history/archive Set (5th arg, built once per tick by
|
|
80
|
+
// scheduler.cjs's computeDepHistorySatisfaction) or it HOLDS as
|
|
81
|
+
// 'unresolved'. Observed live on starry-night-ships: a typo'd/format-
|
|
82
|
+
// mismatched dep slug used to be silently indistinguishable from one
|
|
83
|
+
// genuinely retired to history.
|
|
84
|
+
test('a dep with no row IS treated as done when the caller supplies it as history/archive-satisfied', () => {
|
|
78
85
|
const jobs = [
|
|
79
86
|
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-long-archived-prd'] }),
|
|
80
87
|
];
|
|
81
|
-
const { batch } =
|
|
88
|
+
const { batch } = pickForProject(jobs, new Set(), 3, new Map(), new Set(['some-long-archived-prd']));
|
|
89
|
+
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a dep with no row and NOT in the history/archive set HOLDS as unresolved, with a named actionable reason', () => {
|
|
93
|
+
const jobs = [
|
|
94
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-typo-d-prd'] }),
|
|
95
|
+
];
|
|
96
|
+
const { batch, reason, holds } = pick(jobs);
|
|
97
|
+
assert.deepEqual(batch, []);
|
|
98
|
+
assert.match(reason, /depends-gate/);
|
|
99
|
+
assert.match(reason, /unresolvable dependencies/);
|
|
100
|
+
assert.match(reason, /874-nav-face-project-home <- some-typo-d-prd/);
|
|
101
|
+
assert.match(reason, /fix the dependsOn entry or archive the dependent/);
|
|
102
|
+
const hold = holds.find((h) => h.slug === '874-nav-face-project-home');
|
|
103
|
+
assert.equal(hold.depStatus, 'unresolved');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the double-prefixed-row regression: a row named 253-253-x referenced as bare x holds as unresolved', () => {
|
|
107
|
+
// bareSlug strips exactly ONE leading `NN-` run, so '253-253-x' bares to
|
|
108
|
+
// '253-x' — it does NOT bare-match dependsOn's 'x'. The double-prefixed row
|
|
109
|
+
// is therefore genuinely invisible to both exact and bare matching, the
|
|
110
|
+
// same as a typo — this is the exact 2026-09-06 starry-night-ships report.
|
|
111
|
+
const jobs = [
|
|
112
|
+
job('253-253-x', 'running'),
|
|
113
|
+
job('254-next', 'pending', { dependsOn: ['x'] }),
|
|
114
|
+
];
|
|
115
|
+
const { batch, holds, reason } = pick(jobs);
|
|
116
|
+
assert.deepEqual(batch.map((j) => j.slug), []);
|
|
117
|
+
assert.match(reason, /unresolvable dependencies/);
|
|
118
|
+
const hold = holds.find((h) => h.slug === '254-next');
|
|
119
|
+
assert.equal(hold.dep, 'x');
|
|
120
|
+
assert.equal(hold.depStatus, 'unresolved');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('DEP_HISTORY_FAIL_OPEN preserves the pre-1122 fail-open behaviour for a read failure', () => {
|
|
124
|
+
const jobs = [
|
|
125
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-long-archived-prd'] }),
|
|
126
|
+
];
|
|
127
|
+
const { batch } = pickForProject(jobs, new Set(), 3, new Map(), DEP_HISTORY_FAIL_OPEN);
|
|
82
128
|
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
83
129
|
});
|
|
84
130
|
|
|
131
|
+
test('findBlockingDep: bare-name resolution against satisfiedSlugs matches the live-row bare-name rule', () => {
|
|
132
|
+
const dependent = job('254-next', 'pending', { dependsOn: ['x'] });
|
|
133
|
+
assert.equal(findBlockingDep(dependent, [dependent], new Set(['253-x'])), undefined);
|
|
134
|
+
assert.equal(findBlockingDep(dependent, [dependent], new Set()), 'x');
|
|
135
|
+
});
|
|
136
|
+
|
|
85
137
|
test('a FAILED bare-named dep holds the dependent and reports an explicit reason', () => {
|
|
86
138
|
const jobs = [
|
|
87
139
|
job('873-leftnav-two-face-framework', 'failed'),
|
|
@@ -203,7 +255,7 @@ test('reports a per-job hold record naming the blocking dep and its status', ()
|
|
|
203
255
|
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
204
256
|
assert.deepEqual(batch.map((j) => j.slug), ['985-foundation']);
|
|
205
257
|
assert.deepEqual(holds, [
|
|
206
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'pending' },
|
|
258
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'pending', rootSlug: '985-foundation', rootStatus: 'pending' },
|
|
207
259
|
]);
|
|
208
260
|
});
|
|
209
261
|
|
|
@@ -214,7 +266,7 @@ test('hold record carries a running dep status', () => {
|
|
|
214
266
|
];
|
|
215
267
|
const { holds } = pick(jobs, new Set(['985-foundation']), 5);
|
|
216
268
|
assert.deepEqual(holds, [
|
|
217
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'running' },
|
|
269
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'running', rootSlug: '985-foundation', rootStatus: 'running' },
|
|
218
270
|
]);
|
|
219
271
|
});
|
|
220
272
|
|
|
@@ -227,7 +279,7 @@ test('hold record carries a failed dep status alongside the depends-gate reason'
|
|
|
227
279
|
assert.deepEqual(batch, []);
|
|
228
280
|
assert.match(reason, /depends-gate/);
|
|
229
281
|
assert.deepEqual(holds, [
|
|
230
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'failed' },
|
|
282
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'failed', rootSlug: '985-foundation', rootStatus: 'failed' },
|
|
231
283
|
]);
|
|
232
284
|
});
|
|
233
285
|
|
|
@@ -241,7 +293,7 @@ test('hold record carries a skipped dep status alongside the depends-gate reason
|
|
|
241
293
|
assert.match(reason, /depends-gate/);
|
|
242
294
|
assert.match(reason, /never-ran dependencies/);
|
|
243
295
|
assert.deepEqual(holds, [
|
|
244
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'skipped' },
|
|
296
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'skipped', rootSlug: '985-foundation', rootStatus: 'skipped' },
|
|
245
297
|
]);
|
|
246
298
|
});
|
|
247
299
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branchSweep.cjs — periodic recovery for `sm-job/<slug>` branches whose
|
|
3
|
+
* owning job row was terminalized (or lost entirely) without the branch ever
|
|
4
|
+
* being merged back into its project's HEAD (PRD 1135).
|
|
5
|
+
*
|
|
6
|
+
* The reaper's own liveness check (reaperHelpers.cjs findLiveProcessForJob)
|
|
7
|
+
* stops NEW stranding at the source; this module recovers anything already
|
|
8
|
+
* stranded before that fix landed, or by a crash the liveness check can't
|
|
9
|
+
* cover (app killed between a job's commit and its own integrateJobBranch
|
|
10
|
+
* call). Two halves of one invariant — see the PRD body.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately never deletes a branch (`git branch -D` or otherwise) —
|
|
13
|
+
* reclaiming disk is out of scope; this module only ever merges forward.
|
|
14
|
+
*
|
|
15
|
+
* Kept free of scheduler.cjs / electron / queueStore coupling (same reason
|
|
16
|
+
* reaperHelpers.cjs is separate — importable and unit-testable without
|
|
17
|
+
* requiring electron) — callers own persisting a `conflict` result onto a
|
|
18
|
+
* job row.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { execFile } = require('node:child_process');
|
|
23
|
+
const gitWorktree = require('./gitWorktree.cjs');
|
|
24
|
+
|
|
25
|
+
function execGit(cwd, args, timeout = 20_000) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
execFile('git', ['-C', cwd, ...args], { timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
28
|
+
if (err) {
|
|
29
|
+
err.stderrText = stderr;
|
|
30
|
+
reject(err);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve(stdout || '');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** List every local branch under `refs/heads/sm-job/*`. Never throws. */
|
|
39
|
+
async function listSmJobBranches(cwd) {
|
|
40
|
+
try {
|
|
41
|
+
const out = await execGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/sm-job/*']);
|
|
42
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Branch names currently checked out in any linked worktree (incl. main). Never throws. */
|
|
49
|
+
async function listCheckedOutBranches(cwd) {
|
|
50
|
+
try {
|
|
51
|
+
const out = await execGit(cwd, ['worktree', 'list', '--porcelain']);
|
|
52
|
+
return gitWorktree.parseWorktreeListPorcelain(out)
|
|
53
|
+
.map((e) => e.branch)
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'skipped']);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* sweepStrandedJobBranches({ cwd, jobs, attemptedBranches }) → { results }
|
|
64
|
+
*
|
|
65
|
+
* `jobs` is the full in-memory queue snapshot (any project's rows may be
|
|
66
|
+
* present; matched by slug only, since a slug is globally unique). `attemptedBranches`
|
|
67
|
+
* is a caller-owned `Set` of `"<cwd>::<branch>"` keys — persisted across calls
|
|
68
|
+
* (in-memory is enough: the goal is "not retried every cycle forever" while
|
|
69
|
+
* the process is up, not durability across a restart) so a branch that
|
|
70
|
+
* conflicts once is never retried again in the same process lifetime.
|
|
71
|
+
*
|
|
72
|
+
* Each result is `{ branch, slug, action }` where action is one of:
|
|
73
|
+
* 'skip-checked-out' — branch is checked out in a live worktree, left alone
|
|
74
|
+
* 'no-op-merged' — branch has no commits beyond HEAD, nothing to do
|
|
75
|
+
* 'skip-active-row' — job row exists and is NOT terminal, left alone
|
|
76
|
+
* 'skip-already-attempted' — a prior sweep already tried and failed this branch
|
|
77
|
+
* 'integrated' — merged into HEAD this pass (`integration` attached)
|
|
78
|
+
* 'conflict' — one bounded integrateBranch attempt failed (`integration` attached)
|
|
79
|
+
*
|
|
80
|
+
* SM_BRANCH_SWEEP_DISABLE=1 restores today's behaviour exactly (no-op),
|
|
81
|
+
* mirroring SM_RESUME_RECOVERY_DISABLE.
|
|
82
|
+
*/
|
|
83
|
+
async function sweepStrandedJobBranches({ cwd, jobs, attemptedBranches }) {
|
|
84
|
+
if (process.env.SM_BRANCH_SWEEP_DISABLE === '1') return { results: [] };
|
|
85
|
+
if (!cwd || !(await gitWorktree.isGitRepo(cwd))) return { results: [] };
|
|
86
|
+
|
|
87
|
+
const attempted = attemptedBranches instanceof Set ? attemptedBranches : new Set();
|
|
88
|
+
const branches = await listSmJobBranches(cwd);
|
|
89
|
+
if (!branches.length) return { results: [] };
|
|
90
|
+
const checkedOut = new Set(await listCheckedOutBranches(cwd));
|
|
91
|
+
const jobsBySlug = new Map((Array.isArray(jobs) ? jobs : []).map((j) => [j.slug, j]));
|
|
92
|
+
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const branch of branches) {
|
|
95
|
+
const slug = gitWorktree.keyFromBranch('job', branch);
|
|
96
|
+
if (checkedOut.has(branch)) {
|
|
97
|
+
results.push({ branch, slug, action: 'skip-checked-out' });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const merged = await gitWorktree.isBranchMergedIntoHead(cwd, branch);
|
|
101
|
+
if (merged) {
|
|
102
|
+
results.push({ branch, slug, action: 'no-op-merged' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const row = slug ? jobsBySlug.get(slug) : null;
|
|
106
|
+
const terminalOrMissing = !row || TERMINAL_JOB_STATUSES.has(row.status);
|
|
107
|
+
if (!terminalOrMissing) {
|
|
108
|
+
results.push({ branch, slug, action: 'skip-active-row' });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const attemptKey = `${cwd}::${branch}`;
|
|
112
|
+
if (attempted.has(attemptKey)) {
|
|
113
|
+
results.push({ branch, slug, action: 'skip-already-attempted' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
attempted.add(attemptKey);
|
|
117
|
+
const integration = await gitWorktree.integrateJobBranch({ cwd, branch, slug: slug || branch });
|
|
118
|
+
results.push({ branch, slug, action: integration.ok ? 'integrated' : 'conflict', integration });
|
|
119
|
+
}
|
|
120
|
+
return { results };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
listSmJobBranches,
|
|
125
|
+
listCheckedOutBranches,
|
|
126
|
+
sweepStrandedJobBranches,
|
|
127
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* depSlugResolve.cjs — the single `dependsOn` slug-resolution rule (PRD 832),
|
|
5
|
+
* shared by schedulerBatch.cjs's findBlockingDep (run-time gating: does a
|
|
6
|
+
* queue row exist for this dep) and prdCreate.cjs's write-time validation
|
|
7
|
+
* (does a PRD exist for this dep at all). Both must agree on what a
|
|
8
|
+
* `dependsOn` entry resolves to, or a dep that validates at PRD-create time
|
|
9
|
+
* could still fail to block at run time (or vice versa) — so the matching
|
|
10
|
+
* rule lives here once, not copied into each caller.
|
|
11
|
+
*
|
|
12
|
+
* Resolution rule: exact slug match first; else bare-name match after
|
|
13
|
+
* stripping one leading `NN-` prefix — a human-authored `dependsOn:` can't
|
|
14
|
+
* know the `NN-` prefix the allocator will hand a sibling PRD.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Strip one leading `NN-` prefix, if present. */
|
|
18
|
+
function bareSlug(slug) {
|
|
19
|
+
return String(slug ?? '').replace(/^\d+-/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve `depSlug` against `candidateSlugs`: exact match wins outright;
|
|
24
|
+
* otherwise every candidate whose bare name matches. Returns a (possibly
|
|
25
|
+
* empty) array of matching candidate slugs.
|
|
26
|
+
*/
|
|
27
|
+
function resolveDepSlug(depSlug, candidateSlugs) {
|
|
28
|
+
if (candidateSlugs.includes(depSlug)) return [depSlug];
|
|
29
|
+
const bare = bareSlug(depSlug);
|
|
30
|
+
return candidateSlugs.filter((slug) => bareSlug(slug) === bare);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Levenshtein edit distance. O(n*m) in the two strings' lengths — fine here
|
|
35
|
+
* since it only ever runs over short slug strings (<= 60 chars, PRD_CREATE_SLUG_RE's
|
|
36
|
+
* cap) at PRD-create time, never in a hot/per-tick path.
|
|
37
|
+
*/
|
|
38
|
+
function levenshtein(a, b) {
|
|
39
|
+
const m = a.length;
|
|
40
|
+
const n = b.length;
|
|
41
|
+
if (m === 0) return n;
|
|
42
|
+
if (n === 0) return m;
|
|
43
|
+
const prevRow = new Array(n + 1);
|
|
44
|
+
const curRow = new Array(n + 1);
|
|
45
|
+
for (let j = 0; j <= n; j++) prevRow[j] = j;
|
|
46
|
+
for (let i = 1; i <= m; i++) {
|
|
47
|
+
curRow[0] = i;
|
|
48
|
+
for (let j = 1; j <= n; j++) {
|
|
49
|
+
curRow[j] = a[i - 1] === b[j - 1]
|
|
50
|
+
? prevRow[j - 1]
|
|
51
|
+
: 1 + Math.min(prevRow[j - 1], prevRow[j], curRow[j - 1]);
|
|
52
|
+
}
|
|
53
|
+
for (let j = 0; j <= n; j++) prevRow[j] = curRow[j];
|
|
54
|
+
}
|
|
55
|
+
return prevRow[n];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Nearest existing slugs to `depSlug` by bare-name edit distance, closest
|
|
60
|
+
* first, capped at `limit` — used to build an actionable "did you mean"
|
|
61
|
+
* error when a dependsOn entry resolves to nothing.
|
|
62
|
+
*/
|
|
63
|
+
function findNearMatches(depSlug, candidateSlugs, limit = 3) {
|
|
64
|
+
const depBare = bareSlug(depSlug);
|
|
65
|
+
return candidateSlugs
|
|
66
|
+
.map((slug) => ({ slug, dist: levenshtein(depBare, bareSlug(slug)) }))
|
|
67
|
+
.sort((a, b) => a.dist - b.dist)
|
|
68
|
+
.slice(0, limit)
|
|
69
|
+
.map((entry) => entry.slug);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { bareSlug, resolveDepSlug, findNearMatches };
|
|
@@ -37,9 +37,9 @@ const { validatePath } = require('../config.cjs');
|
|
|
37
37
|
* real conflict — never throws (integrateEpicBranch/cleanupEpicWorktree
|
|
38
38
|
* never do either).
|
|
39
39
|
*/
|
|
40
|
-
async function mergeEpicToMainViaIpc(cwd, epicId, branch, dir) {
|
|
40
|
+
async function mergeEpicToMainViaIpc(cwd, epicId, branch, dir, carriedPaths) {
|
|
41
41
|
validatePath(cwd);
|
|
42
|
-
const outcome = await integrateEpicBranch({ cwd, branch, epicId });
|
|
42
|
+
const outcome = await integrateEpicBranch({ cwd, branch, epicId, carriedPaths });
|
|
43
43
|
if (!outcome.ok) {
|
|
44
44
|
console.log(`[epicWorktreeMerge] ${epicId}: needs manual resolution (${outcome.reason})`);
|
|
45
45
|
return { ok: false, status: 'needs_merge_resolution', reason: outcome.reason };
|
|
@@ -56,7 +56,7 @@ function registerEpicWorktreeMergeHandlers() {
|
|
|
56
56
|
const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
|
|
57
57
|
ipcMain.handle(
|
|
58
58
|
'promptSessions:merge-to-main',
|
|
59
|
-
v(s.promptSessionsMergeToMain, ({ cwd, epicId, branch, dir }) => mergeEpicToMainViaIpc(cwd, epicId, branch, dir)),
|
|
59
|
+
v(s.promptSessionsMergeToMain, ({ cwd, epicId, branch, dir, carriedPaths }) => mergeEpicToMainViaIpc(cwd, epicId, branch, dir, carriedPaths)),
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -30,10 +30,10 @@ const { validatePath } = require('../config.cjs');
|
|
|
30
30
|
/**
|
|
31
31
|
* createEpicWorktreeViaIpc(cwd, epicId) → Promise<EpicWorktree | null>
|
|
32
32
|
*
|
|
33
|
-
* Returns the `worktree` field shape (`{ dir, branch, baseCwd, status
|
|
34
|
-
* ready to persist onto the Epic record on success, or
|
|
35
|
-
* failure — createEpicWorktree/createWorktree never throw, so
|
|
36
|
-
* does either.
|
|
33
|
+
* Returns the `worktree` field shape (`{ dir, branch, baseCwd, status,
|
|
34
|
+
* carriedPaths }`) ready to persist onto the Epic record on success, or
|
|
35
|
+
* `null` on any failure — createEpicWorktree/createWorktree never throw, so
|
|
36
|
+
* this never does either.
|
|
37
37
|
*/
|
|
38
38
|
async function createEpicWorktreeViaIpc(cwd, epicId) {
|
|
39
39
|
validatePath(cwd);
|
|
@@ -42,7 +42,19 @@ async function createEpicWorktreeViaIpc(cwd, epicId) {
|
|
|
42
42
|
console.log(`[epicWorktreeMint] ${epicId}: not isolated (${result.reason})`);
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
// carriedPaths (base-tree WIP carried into this worktree — PRD 1094) MUST
|
|
46
|
+
// survive onto the persisted record: it's the only way integrateEpicBranch
|
|
47
|
+
// (via epicWorktreeMerge.cjs) can later apply the carried-wip-only skip
|
|
48
|
+
// instead of attempting a doomed merge against the base tree's still-dirty
|
|
49
|
+
// paths. Dropping it here silently (as this used to) meant that skip never
|
|
50
|
+
// fired for Epics, only for scheduler jobs.
|
|
51
|
+
return {
|
|
52
|
+
dir: result.dir,
|
|
53
|
+
branch: result.branch,
|
|
54
|
+
baseCwd: result.baseCwd,
|
|
55
|
+
status: 'active',
|
|
56
|
+
...(Array.isArray(result.carriedPaths) && result.carriedPaths.length ? { carriedPaths: result.carriedPaths } : {}),
|
|
57
|
+
};
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
function registerEpicWorktreeMintHandlers() {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fixPlanSlug.cjs — single source of truth for recognizing a scheduler-
|
|
3
|
+
* authored fix-plan PRD (PRD 1131).
|
|
4
|
+
*
|
|
5
|
+
* `isFixPlanSlug`'s `/^\d+-fix-/` name test used to be treated as sufficient
|
|
6
|
+
* evidence on its own. But any PRD an agent or human authors through
|
|
7
|
+
* `scheduler_create_prd` whose title happens to kebab-case into a
|
|
8
|
+
* `fix-`-leading slug silently inherited every fix-plan special case
|
|
9
|
+
* (depth-capped auto-investigation, `commitGuardVerdict`'s zero-edit
|
|
10
|
+
* exemption) it never asked for — PRD 1126
|
|
11
|
+
* (`126-fix-plan-death-reopens-parent`, `createdVia: scheduler-api`) was
|
|
12
|
+
* wrongly stamped `investigationDepth: 2` before it ever ran and had to be
|
|
13
|
+
* withdrawn and re-slugged.
|
|
14
|
+
*
|
|
15
|
+
* A genuine fix plan is authored directly by scheduler.cjs's
|
|
16
|
+
* `spawnInvestigation` — a raw `Write`, never through `scheduler_create_prd`
|
|
17
|
+
* (see `guard-prd-writes.cjs`'s exemption) — so it never carries a
|
|
18
|
+
* `createdVia` stamp. Its prompt template also stamps an explicit
|
|
19
|
+
* `isFixPlan: true` frontmatter key so classification has a positive
|
|
20
|
+
* signal to check, not just an absence.
|
|
21
|
+
*
|
|
22
|
+
* `classifyDiscoveredFixPlan` is the ONE place a freshly-parsed PRD file
|
|
23
|
+
* (which has real `createdVia`/`isFixPlan` frontmatter) is turned into a
|
|
24
|
+
* boolean fix-plan verdict — `reconcile()` calls it once per newly
|
|
25
|
+
* discovered PRD and persists the verdict onto the queue row's `isFixPlan`
|
|
26
|
+
* field, so every later consumer reads that stamp instead of re-deriving it
|
|
27
|
+
* from the name. `resolveIsFixPlan` is what those consumers call: an
|
|
28
|
+
* explicit `true`/`false` on the row wins; a row with the field entirely
|
|
29
|
+
* absent (any queue.json row persisted before this change shipped) falls
|
|
30
|
+
* back to the legacy slug-only heuristic so live history is never
|
|
31
|
+
* re-classified.
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
function isFixPlanSlug(slug) {
|
|
36
|
+
return /^\d+-fix-/.test(slug);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Classify a freshly-parsed PRD file (real `createdVia`/`isFixPlan`
|
|
41
|
+
* frontmatter available). `createdVia` is checked FIRST and always wins —
|
|
42
|
+
* proof the PRD was authored through the sanctioned scheduler_create_prd API
|
|
43
|
+
* (a lower-trust path than spawnInvestigation's own raw write) must never be
|
|
44
|
+
* overridable by an `isFixPlan: true` stamp in the same file, even though no
|
|
45
|
+
* current write path can produce both on one file (see prdCreate.cjs's
|
|
46
|
+
* slug-refusal + guard-prd-writes.cjs's write-guard). Structural belt, not
|
|
47
|
+
* just relying on those two other files staying in sync.
|
|
48
|
+
*/
|
|
49
|
+
function classifyDiscoveredFixPlan({ isFixPlan, createdVia } = {}, slug) {
|
|
50
|
+
if (createdVia) return false;
|
|
51
|
+
if (isFixPlan === true) return true;
|
|
52
|
+
return isFixPlanSlug(slug);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Classify a queue-row job, honoring an already-persisted `isFixPlan` stamp over the slug. */
|
|
56
|
+
function resolveIsFixPlan(slug, isFixPlan) {
|
|
57
|
+
if (isFixPlan === true) return true;
|
|
58
|
+
if (isFixPlan === false) return false;
|
|
59
|
+
return isFixPlanSlug(slug);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { isFixPlanSlug, classifyDiscoveredFixPlan, resolveIsFixPlan };
|