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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdFrontmatterDependsOn.test.cjs — PRD 1124: `dependsOn` is a recognized,
|
|
3
|
+
* patchable PRD frontmatter key. Before this PRD, RECOGNIZED_KEYS omitted
|
|
4
|
+
* `dependsOn`, so it only round-tripped verbatim via `extras` and
|
|
5
|
+
* scheduler_update_prd could not patch it — the only repair path for a
|
|
6
|
+
* wrong dependsOn was archive-and-recreate, which is unsafe (archiving
|
|
7
|
+
* marks the PRD completed, freeing its dependents with zero work done).
|
|
8
|
+
*
|
|
9
|
+
* Covers the pure parse/serialize contract in prdFrontmatter.cjs. The
|
|
10
|
+
* write-time FK validation + clearing semantics wired into
|
|
11
|
+
* scheduler.remote.updatePrd are covered by
|
|
12
|
+
* prdUpdateDependsOn.test.cjs.
|
|
13
|
+
*
|
|
14
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdFrontmatterDependsOn.test.cjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
import { test, expect } from 'vitest';
|
|
20
|
+
|
|
21
|
+
const { parsePrdFile, serializePrdFile, RECOGNIZED_KEYS } = require('../lib/prdFrontmatter.cjs');
|
|
22
|
+
|
|
23
|
+
test('dependsOn is a recognized key', () => {
|
|
24
|
+
expect(RECOGNIZED_KEYS.has('dependsOn')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parsePrdFile parses an inline dependsOn list into an array, and round-trips it byte-identically when unedited', () => {
|
|
28
|
+
const raw = [
|
|
29
|
+
'---',
|
|
30
|
+
'title: A follow-up PRD',
|
|
31
|
+
'cwd: ~/Projects/session-manager',
|
|
32
|
+
'estimateMinutes: 20',
|
|
33
|
+
'agentType: dev-lead',
|
|
34
|
+
'dependsOn: [widget-base, widget-shared]',
|
|
35
|
+
'---',
|
|
36
|
+
'# Goal',
|
|
37
|
+
'',
|
|
38
|
+
'Build on the base.',
|
|
39
|
+
'',
|
|
40
|
+
].join('\n');
|
|
41
|
+
|
|
42
|
+
const { frontmatter, body } = parsePrdFile(raw);
|
|
43
|
+
expect(frontmatter.dependsOn).toEqual(['widget-base', 'widget-shared']);
|
|
44
|
+
expect(serializePrdFile(frontmatter, body)).toBe(raw);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('parsePrdFile leaves dependsOn undefined when omitted — a PRD with no dependsOn round-trips unaffected', () => {
|
|
48
|
+
const raw = [
|
|
49
|
+
'---',
|
|
50
|
+
'title: An older PRD authored before dependsOn was patchable',
|
|
51
|
+
'cwd: ~/Projects/session-manager',
|
|
52
|
+
'estimateMinutes: 10',
|
|
53
|
+
'---',
|
|
54
|
+
'# Goal',
|
|
55
|
+
'',
|
|
56
|
+
'Do the thing.',
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
59
|
+
|
|
60
|
+
const { frontmatter, body } = parsePrdFile(raw);
|
|
61
|
+
expect(frontmatter.dependsOn).toBeUndefined();
|
|
62
|
+
expect(serializePrdFile(frontmatter, body)).toBe(raw);
|
|
63
|
+
|
|
64
|
+
// An unrelated patch (title) must not introduce a dependsOn line.
|
|
65
|
+
const updated = serializePrdFile({ ...frontmatter, title: 'Renamed' }, body);
|
|
66
|
+
expect(updated).not.toContain('dependsOn');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('serializePrdFile emits a newly-set dependsOn onto a frontmatter object that lacked one', () => {
|
|
70
|
+
const raw = [
|
|
71
|
+
'---',
|
|
72
|
+
'title: Legacy PRD',
|
|
73
|
+
'cwd: ~/Projects/session-manager',
|
|
74
|
+
'estimateMinutes: 5',
|
|
75
|
+
'---',
|
|
76
|
+
'body',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n');
|
|
79
|
+
|
|
80
|
+
const { frontmatter, body } = parsePrdFile(raw);
|
|
81
|
+
const updated = serializePrdFile({ ...frontmatter, dependsOn: ['some-other-prd'] }, body);
|
|
82
|
+
expect(updated).toContain('dependsOn: [some-other-prd]');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('serializePrdFile patches an existing dependsOn to a new list', () => {
|
|
86
|
+
const raw = [
|
|
87
|
+
'---',
|
|
88
|
+
'title: A follow-up PRD',
|
|
89
|
+
'cwd: ~/Projects/session-manager',
|
|
90
|
+
'estimateMinutes: 20',
|
|
91
|
+
'dependsOn: [widget-base]',
|
|
92
|
+
'---',
|
|
93
|
+
'body',
|
|
94
|
+
'',
|
|
95
|
+
].join('\n');
|
|
96
|
+
|
|
97
|
+
const { frontmatter, body } = parsePrdFile(raw);
|
|
98
|
+
const updated = serializePrdFile({ ...frontmatter, dependsOn: ['widget-shared', 'widget-extra'] }, body);
|
|
99
|
+
expect(updated).toContain('dependsOn: [widget-shared, widget-extra]');
|
|
100
|
+
expect(updated).not.toContain('widget-base');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('serializePrdFile patching dependsOn to an explicit empty array CLEARS it', () => {
|
|
104
|
+
const raw = [
|
|
105
|
+
'---',
|
|
106
|
+
'title: A follow-up PRD',
|
|
107
|
+
'cwd: ~/Projects/session-manager',
|
|
108
|
+
'estimateMinutes: 20',
|
|
109
|
+
'dependsOn: [widget-base]',
|
|
110
|
+
'---',
|
|
111
|
+
'body',
|
|
112
|
+
'',
|
|
113
|
+
].join('\n');
|
|
114
|
+
|
|
115
|
+
const { frontmatter, body } = parsePrdFile(raw);
|
|
116
|
+
const updated = serializePrdFile({ ...frontmatter, dependsOn: [] }, body);
|
|
117
|
+
expect(updated).not.toContain('dependsOn');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('unrelated recognized-key patch leaves an existing dependsOn line untouched', () => {
|
|
121
|
+
const raw = [
|
|
122
|
+
'---',
|
|
123
|
+
'title: A follow-up PRD',
|
|
124
|
+
'cwd: ~/Projects/session-manager',
|
|
125
|
+
'estimateMinutes: 20',
|
|
126
|
+
'dependsOn: [widget-base]',
|
|
127
|
+
'---',
|
|
128
|
+
'body',
|
|
129
|
+
'',
|
|
130
|
+
].join('\n');
|
|
131
|
+
|
|
132
|
+
const { frontmatter, body } = parsePrdFile(raw);
|
|
133
|
+
const updated = serializePrdFile({ ...frontmatter, estimateMinutes: 30 }, body);
|
|
134
|
+
expect(updated).toContain('dependsOn: [widget-base]');
|
|
135
|
+
expect(updated).toContain('estimateMinutes: 30');
|
|
136
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdUpdateDependsOn.test.cjs — PRD 1124: scheduler.remote.updatePrd accepts
|
|
3
|
+
* a `dependsOn` array patch, validates it write-time against the SAME
|
|
4
|
+
* resolver (depSlugResolve.cjs) scheduler_create_prd's prdCreate.cjs uses,
|
|
5
|
+
* and accepts an explicit empty array to CLEAR the dependency.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors scheduler-reconcile-quarantine.test.cjs's HOME-isolation fixture.
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdUpdateDependsOn.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
let tmpHome;
|
|
20
|
+
let originalHome;
|
|
21
|
+
let scheduler;
|
|
22
|
+
let queueStore;
|
|
23
|
+
let config;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
originalHome = process.env.HOME;
|
|
27
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-update-prd-dependson-home-'));
|
|
28
|
+
process.env.HOME = tmpHome;
|
|
29
|
+
|
|
30
|
+
scheduler = require('../scheduler.cjs');
|
|
31
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
32
|
+
config = require('../config.cjs');
|
|
33
|
+
|
|
34
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
35
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
process.env.HOME = originalHome;
|
|
41
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function registerActiveProject(cwd) {
|
|
45
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
46
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
47
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
48
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeFixtureProject(prefix) {
|
|
52
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
53
|
+
registerActiveProject(cwd);
|
|
54
|
+
// updatePrd writes through config.writeTextAtomic, which enforces
|
|
55
|
+
// validatePath's allowed-write-root boundary (os.homedir() by default).
|
|
56
|
+
// The live app widens this via pty.cjs's spawn / activeIndexMerge.cjs's
|
|
57
|
+
// boot registration as a side effect of opening the project; a test that
|
|
58
|
+
// never spawns a PTY has to register it explicitly, same as
|
|
59
|
+
// prdCreate.cjs's own createPrd does for a chat-only Epic.
|
|
60
|
+
config.addAllowedRoot(cwd);
|
|
61
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
62
|
+
const prdsDir = path.join(opsRoot, 'scheduler', 'epics', 'test-epic-1', 'prds');
|
|
63
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
64
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
65
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
67
|
+
queueStore.bustCwdCache();
|
|
68
|
+
return { cwd, prdsDir, stateDir };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writePrd(prdsDir, filename, cwd, extraFrontmatter = '') {
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
path.join(prdsDir, filename),
|
|
74
|
+
`---\ntitle: ${filename}\ncwd: ${cwd}\nestimateMinutes: 10\ncreatedVia: scheduler-api\n${extraFrontmatter}---\n\n# Goal\nDo the thing.\n`,
|
|
75
|
+
'utf8',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('updatePrd patches dependsOn to a new valid list naming an existing bare slug', async () => {
|
|
80
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-valid-');
|
|
81
|
+
try {
|
|
82
|
+
writePrd(prdsDir, '3-widget-base.md', cwd);
|
|
83
|
+
writePrd(prdsDir, '4-widget-follow-up.md', cwd);
|
|
84
|
+
|
|
85
|
+
const result = await scheduler.remote.updatePrd({
|
|
86
|
+
slug: '4-widget-follow-up',
|
|
87
|
+
cwd,
|
|
88
|
+
frontmatter: { dependsOn: ['widget-base'] },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.ok).toBe(true);
|
|
92
|
+
const written = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
|
|
93
|
+
expect(written).toMatch(/^dependsOn: \[widget-base\]$/m);
|
|
94
|
+
} finally {
|
|
95
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('updatePrd refuses a dependsOn entry that resolves to no existing PRD, with a near-match suggestion, and writes nothing', async () => {
|
|
100
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-invalid-');
|
|
101
|
+
try {
|
|
102
|
+
writePrd(prdsDir, '3-widget-base.md', cwd);
|
|
103
|
+
writePrd(prdsDir, '4-widget-follow-up.md', cwd);
|
|
104
|
+
const before = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
|
|
105
|
+
|
|
106
|
+
const result = await scheduler.remote.updatePrd({
|
|
107
|
+
slug: '4-widget-follow-up',
|
|
108
|
+
cwd,
|
|
109
|
+
frontmatter: { dependsOn: ['wigdet-bsae'] },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result.ok).toBe(false);
|
|
113
|
+
expect(result.error).toMatch(/does not resolve to any existing PRD/);
|
|
114
|
+
expect(result.error).toMatch(/widget-base/);
|
|
115
|
+
const after = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
|
|
116
|
+
expect(after).toBe(before);
|
|
117
|
+
} finally {
|
|
118
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('updatePrd patches dependsOn to an explicit empty array, clearing a previously-set dependency without validation', async () => {
|
|
123
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-clear-');
|
|
124
|
+
try {
|
|
125
|
+
writePrd(prdsDir, '3-widget-base.md', cwd);
|
|
126
|
+
writePrd(prdsDir, '4-widget-follow-up.md', cwd, 'dependsOn: [widget-base]\n');
|
|
127
|
+
|
|
128
|
+
const result = await scheduler.remote.updatePrd({
|
|
129
|
+
slug: '4-widget-follow-up',
|
|
130
|
+
cwd,
|
|
131
|
+
frontmatter: { dependsOn: [] },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.ok).toBe(true);
|
|
135
|
+
const written = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
|
|
136
|
+
expect(written).not.toContain('dependsOn');
|
|
137
|
+
} finally {
|
|
138
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('updatePrd patching an unrelated field leaves a PRD with no dependsOn unaffected', async () => {
|
|
143
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-unrelated-');
|
|
144
|
+
try {
|
|
145
|
+
writePrd(prdsDir, '3-standalone.md', cwd);
|
|
146
|
+
|
|
147
|
+
const result = await scheduler.remote.updatePrd({
|
|
148
|
+
slug: '3-standalone',
|
|
149
|
+
cwd,
|
|
150
|
+
frontmatter: { estimateMinutes: 25 },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.ok).toBe(true);
|
|
154
|
+
const written = fs.readFileSync(path.join(prdsDir, '3-standalone.md'), 'utf8');
|
|
155
|
+
expect(written).not.toContain('dependsOn');
|
|
156
|
+
expect(written).toMatch(/^estimateMinutes: 25$/m);
|
|
157
|
+
} finally {
|
|
158
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
@@ -257,3 +257,36 @@ test('historyTerminalBySlug: cache invalidates after a new appendHistory call (m
|
|
|
257
257
|
expect(second.has('cache-1')).toBe(true);
|
|
258
258
|
expect(second.has('cache-2')).toBe(true);
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
// ---------- completedSlugsForCwd (PRD 1122) ----------
|
|
262
|
+
//
|
|
263
|
+
// Feeds scheduler.cjs's computeDepHistorySatisfaction: a dependsOn slug with
|
|
264
|
+
// no live queue row is only satisfied when it shows up here (or in
|
|
265
|
+
// prds-archived/) — scoped to ONE project's own history shard so a
|
|
266
|
+
// same-named PRD in an unrelated project can never satisfy a dep.
|
|
267
|
+
|
|
268
|
+
test('completedSlugsForCwd: returns slugs completed in this project\'s own shard only', async () => {
|
|
269
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projA-'));
|
|
270
|
+
const otherCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projB-'));
|
|
271
|
+
await queueHistory.appendHistory([
|
|
272
|
+
old({ slug: '900-base', status: 'completed', runId: 'ra', cwd: projectCwd }),
|
|
273
|
+
old({ slug: '901-failed', status: 'failed', runId: 'rb', cwd: projectCwd }),
|
|
274
|
+
old({ slug: '900-base', status: 'completed', runId: 'rc', cwd: otherCwd }),
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const slugs = await queueHistory.completedSlugsForCwd(projectCwd);
|
|
278
|
+
expect(slugs.has('900-base')).toBe(true);
|
|
279
|
+
expect(slugs.has('901-failed')).toBe(false);
|
|
280
|
+
|
|
281
|
+
// Same slug completed in a DIFFERENT project must not leak across.
|
|
282
|
+
const otherSlugs = await queueHistory.completedSlugsForCwd(otherCwd);
|
|
283
|
+
expect(otherSlugs.has('900-base')).toBe(true);
|
|
284
|
+
const unrelatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projC-'));
|
|
285
|
+
expect((await queueHistory.completedSlugsForCwd(unrelatedCwd)).size).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('completedSlugsForCwd: returns an empty Set when the project has no history shard yet', async () => {
|
|
289
|
+
const freshCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-fresh-'));
|
|
290
|
+
const slugs = await queueHistory.completedSlugsForCwd(freshCwd);
|
|
291
|
+
expect(slugs.size).toBe(0);
|
|
292
|
+
});
|
|
@@ -240,3 +240,27 @@ test('writeRcaReport: SM_RCA_DISABLE=1 skips without writing', async () => {
|
|
|
240
240
|
expect(res.reason).toBe('disabled');
|
|
241
241
|
expect(fs.readdirSync(runDir).filter((f) => f.startsWith('root-cause-'))).toHaveLength(0);
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
test('classifyFailure: blocked_by_foreign_wip_streak verdict classifies as BLOCKED_BY_FOREIGN_WIP, never UNKNOWN', () => {
|
|
245
|
+
const failureClass = rcaReport.classifyFailure({ verdict: 'blocked_by_foreign_wip_streak', logTail: 'anything' });
|
|
246
|
+
expect(failureClass).toBe(rcaReport.FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('writeRcaReport: a blocked_by_foreign_wip_streak verdict points at the sibling job, not "re-run the gate"', async () => {
|
|
250
|
+
const cwd = makeProject();
|
|
251
|
+
const runDir = path.join(tmpHome, 'run-blocked');
|
|
252
|
+
writeRun(runDir, 'blockedslug');
|
|
253
|
+
writePrd(cwd, 'blockedslug');
|
|
254
|
+
|
|
255
|
+
const res = await rcaReport.writeRcaReport({
|
|
256
|
+
job: baseJob({ cwd, slug: 'blockedslug' }),
|
|
257
|
+
runDir,
|
|
258
|
+
verdict: 'blocked_by_foreign_wip_streak',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(res.filed).toBe(true);
|
|
262
|
+
expect(res.failureClass).toBe(rcaReport.FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP);
|
|
263
|
+
const body = fs.readFileSync(res.path, 'utf8');
|
|
264
|
+
expect(body).toMatch(/sibling job/i);
|
|
265
|
+
expect(body).not.toMatch(/Re-run the acceptance criteria gate/);
|
|
266
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runVerify-blocked-by-foreign-wip.test.cjs — scanSentinel's new
|
|
3
|
+
* BLOCKED_BY_FOREIGN_WIP token and the scanForeignWipPathsClaim evidence-line
|
|
4
|
+
* scanner (the executor-facing half of "give the executor a first-class
|
|
5
|
+
* verdict for a sibling job's in-flight file, but validate the claim").
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect } from 'vitest';
|
|
13
|
+
const { scanSentinel, scanForeignWipPathsClaim } = require('../runVerify.cjs');
|
|
14
|
+
|
|
15
|
+
function resultEventFor(text) {
|
|
16
|
+
return { kind: 'result', seq: 0, subtype: 'success', resultText: text };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('scanSentinel: recognizes BLOCKED_BY_FOREIGN_WIP alongside PASS/FAIL', () => {
|
|
20
|
+
expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: PASS'), [])).toBe('pass');
|
|
21
|
+
expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: FAIL bad thing'), [])).toBe('fail');
|
|
22
|
+
expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP'), [])).toBe('blocked_by_foreign_wip');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('scanSentinel: BLOCKED_BY_FOREIGN_WIP only matches at line start, like PASS/FAIL', () => {
|
|
26
|
+
expect(scanSentinel(resultEventFor('I saw a SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP mention mid-sentence'), [])).toBe(null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('scanSentinel: falls back to the last tool_result content when no resultEvent matches', () => {
|
|
30
|
+
const events = [
|
|
31
|
+
{ kind: 'tool_result', seq: 1, toolUseId: 'a', content: 'nothing here' },
|
|
32
|
+
{ kind: 'tool_result', seq: 2, toolUseId: 'b', content: 'SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP\nFOREIGN_WIP_PATHS: src/foo.ts' },
|
|
33
|
+
];
|
|
34
|
+
expect(scanSentinel(null, events)).toBe('blocked_by_foreign_wip');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('scanForeignWipPathsClaim: extracts a comma-separated FOREIGN_WIP_PATHS line from resultEvent', () => {
|
|
38
|
+
const text = 'SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP\nFOREIGN_WIP_PATHS: src/foo.ts, src/bar.ts';
|
|
39
|
+
expect(scanForeignWipPathsClaim(resultEventFor(text), [])).toEqual(['src/foo.ts', 'src/bar.ts']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('scanForeignWipPathsClaim: dedupes and trims whitespace', () => {
|
|
43
|
+
const text = 'FOREIGN_WIP_PATHS: src/foo.ts ,src/foo.ts, src/bar.ts ';
|
|
44
|
+
expect(scanForeignWipPathsClaim(resultEventFor(text), [])).toEqual(['src/foo.ts', 'src/bar.ts']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('scanForeignWipPathsClaim: returns [] when no FOREIGN_WIP_PATHS line is present', () => {
|
|
48
|
+
expect(scanForeignWipPathsClaim(resultEventFor('SCHEDULER_VERDICT: FAIL nope'), [])).toEqual([]);
|
|
49
|
+
expect(scanForeignWipPathsClaim(null, [])).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('scanForeignWipPathsClaim: falls back to the last tool_result content', () => {
|
|
53
|
+
const events = [
|
|
54
|
+
{ kind: 'tool_result', seq: 1, toolUseId: 'a', content: 'FOREIGN_WIP_PATHS: src/stale.ts' },
|
|
55
|
+
{ kind: 'tool_result', seq: 2, toolUseId: 'b', content: 'FOREIGN_WIP_PATHS: src/latest.ts' },
|
|
56
|
+
];
|
|
57
|
+
expect(scanForeignWipPathsClaim(null, events)).toEqual(['src/latest.ts']);
|
|
58
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runVerify-policy-denial.test.cjs — a PreToolUse hook denial (`Blocked: ...`
|
|
3
|
+
* tool_result) must not downgrade an otherwise-clean run, but a genuine
|
|
4
|
+
* late-run failure must still downgrade normally.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/runVerify-policy-denial.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { test } from 'vitest';
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { verifyRun } = require('../runVerify.cjs');
|
|
15
|
+
|
|
16
|
+
function makeTmpDir() {
|
|
17
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-policy-denial-test-'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rmdir(dir) {
|
|
21
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeLog(dir, slug, events) {
|
|
25
|
+
const lines = ['[scheduler] starting ' + slug + ' at 2026-09-02T00:00:00.000Z'];
|
|
26
|
+
for (const ev of events) lines.push(JSON.stringify(ev));
|
|
27
|
+
lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
|
|
28
|
+
fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writePrd(dir, slug, body) {
|
|
32
|
+
const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
|
|
33
|
+
fs.writeFileSync(path.join(dir, `${slug}.md`), text);
|
|
34
|
+
return path.join(dir, `${slug}.md`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('PreToolUse policy denial (Blocked: ...) in final 20% → clean, surfaced as annotation', async () => {
|
|
38
|
+
const tmp = makeTmpDir();
|
|
39
|
+
try {
|
|
40
|
+
const slug = '1106-policy-denial-exempt';
|
|
41
|
+
const events = [];
|
|
42
|
+
for (let k = 0; k < 8; k++) {
|
|
43
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
44
|
+
{ type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
|
|
45
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
46
|
+
{ type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
|
|
47
|
+
}
|
|
48
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
49
|
+
{ type: 'tool_use', id: 'tgit', name: 'Bash', input: { command: 'git stash', description: 'stash before checkout' } }] } });
|
|
50
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
51
|
+
{ type: 'tool_result', tool_use_id: 'tgit',
|
|
52
|
+
content: 'Blocked: `stash` in what this hook believes is a SHARED working tree (/home/bilko/Projects/session-manager).',
|
|
53
|
+
is_error: true }] } });
|
|
54
|
+
events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
|
|
55
|
+
|
|
56
|
+
writeLog(tmp, slug, events);
|
|
57
|
+
const prdPath = writePrd(tmp, slug, '# Policy denial exempt');
|
|
58
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
59
|
+
assert.equal(verdict.verdict, 'clean', `policy denial must not flag, got ${verdict.verdict}: ${verdict.reason}`);
|
|
60
|
+
assert.ok(
|
|
61
|
+
Array.isArray(verdict.annotations) && verdict.annotations.some((a) => a.verdict === 'policy_denial'),
|
|
62
|
+
`expected a policy_denial annotation, got ${JSON.stringify(verdict.annotations)}`,
|
|
63
|
+
);
|
|
64
|
+
} finally { rmdir(tmp); }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('genuine late-run failure (not a policy denial) still downgrades to transcript_errors', async () => {
|
|
68
|
+
const tmp = makeTmpDir();
|
|
69
|
+
try {
|
|
70
|
+
const slug = '1106-genuine-failure-still-flags';
|
|
71
|
+
const events = [];
|
|
72
|
+
for (let k = 0; k < 8; k++) {
|
|
73
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
74
|
+
{ type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
|
|
75
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
76
|
+
{ type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
|
|
77
|
+
}
|
|
78
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
79
|
+
{ type: 'tool_use', id: 'ttest', name: 'Bash', input: { command: 'npm test', description: 'run tests' } }] } });
|
|
80
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
81
|
+
{ type: 'tool_result', tool_use_id: 'ttest', content: 'FAILED: 1 test failed', is_error: true }] } });
|
|
82
|
+
events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
|
|
83
|
+
|
|
84
|
+
writeLog(tmp, slug, events);
|
|
85
|
+
const prdPath = writePrd(tmp, slug, '# Genuine failure still flags');
|
|
86
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
87
|
+
assert.equal(verdict.verdict, 'transcript_errors', `genuine failure must still flag, got ${verdict.verdict}: ${verdict.reason}`);
|
|
88
|
+
} finally { rmdir(tmp); }
|
|
89
|
+
});
|
|
@@ -120,6 +120,7 @@ test('LEGAL_TRANSITIONS covers every real scheduler.cjs edge', () => {
|
|
|
120
120
|
['needs_review', 'investigating'],
|
|
121
121
|
['needs_review', 'pending'],
|
|
122
122
|
['needs_review', 'completed'],
|
|
123
|
+
['needs_review', 'running'],
|
|
123
124
|
['completed', 'pending'],
|
|
124
125
|
['skipped', 'pending'],
|
|
125
126
|
];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-already-satisfied-on-main.test.cjs — PRD 1136.
|
|
3
|
+
*
|
|
4
|
+
* Two live rows (1133-reaper-must-verify-integration-before-completed,
|
|
5
|
+
* 1134-land-stranded-sm-job-branches) were false-negatived on 2026-09-06:
|
|
6
|
+
* their work had already landed on main before their runs dispatched, so
|
|
7
|
+
* each run exited 0, made no commit, and left a clean tree — the exact
|
|
8
|
+
* 'silent_no_op' shape commitGuardVerdict parks as needs_review ("finish
|
|
9
|
+
* protocol incomplete"). Both then auto-minted a redundant `-fix-` child
|
|
10
|
+
* queued to re-do already-shipped work.
|
|
11
|
+
*
|
|
12
|
+
* This exercises the full finalize decision chain — commitGuardVerdict
|
|
13
|
+
* (unchanged) feeding resolveCommitGuardOutcome (new) — entirely at the
|
|
14
|
+
* pure-function level, per standards.md's TDD guidance and the PRD's own
|
|
15
|
+
* implementation note to prefer a pure isAlreadySatisfiedOnMain-style helper
|
|
16
|
+
* over driving the whole spawnJob pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
import { test, expect } from 'vitest';
|
|
24
|
+
const { commitGuardVerdict, selectAutoFixTargets } = require('../scheduler.cjs');
|
|
25
|
+
const { resolveCommitGuardOutcome, isAlreadySatisfiedOnMain } = require('../lib/reaperHelpers.cjs');
|
|
26
|
+
|
|
27
|
+
const noSiblingOnDisk = () => false;
|
|
28
|
+
|
|
29
|
+
// AC 1: exit 0 / no commit / clean tree, with a commit on main newer than
|
|
30
|
+
// queuedAt touching the PRD's declared paths → completed, naming the sha.
|
|
31
|
+
test('silent_no_op + a satisfying commit on main → completed-shaped verdict naming the sha, not needs_review', () => {
|
|
32
|
+
const guardVerdict = commitGuardVerdict({
|
|
33
|
+
newlyDirty: [],
|
|
34
|
+
siblingRunning: false,
|
|
35
|
+
jobSelfCommitted: false,
|
|
36
|
+
legitimateNoOp: false,
|
|
37
|
+
isFixPlanJob: false,
|
|
38
|
+
verifyResult: null,
|
|
39
|
+
});
|
|
40
|
+
expect(guardVerdict.verdict).toBe('silent_no_op'); // sanity: this is the shape 1133/1134 hit
|
|
41
|
+
|
|
42
|
+
const outcome = resolveCommitGuardOutcome(guardVerdict, ['5de4134abc123']);
|
|
43
|
+
expect(outcome.verdict).toBe('already_satisfied_on_main');
|
|
44
|
+
expect(outcome.verdict).not.toBe('needs_review');
|
|
45
|
+
expect(outcome.satisfyingSha).toBe('5de4134abc123');
|
|
46
|
+
expect(outcome.reason).toMatch(/5de4134abc123/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// AC 2: the inverse — same clean-tree/no-commit shape, but NO commit on main
|
|
50
|
+
// satisfies it → still parks as the existing 'finish protocol incomplete'
|
|
51
|
+
// verdict. The gate must not be weakened into always-passing.
|
|
52
|
+
test('silent_no_op + NO satisfying commit on main → unchanged silent_no_op verdict, still parks needs_review', () => {
|
|
53
|
+
const guardVerdict = commitGuardVerdict({
|
|
54
|
+
newlyDirty: [],
|
|
55
|
+
siblingRunning: false,
|
|
56
|
+
jobSelfCommitted: false,
|
|
57
|
+
legitimateNoOp: false,
|
|
58
|
+
isFixPlanJob: false,
|
|
59
|
+
verifyResult: null,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const outcome = resolveCommitGuardOutcome(guardVerdict, []);
|
|
63
|
+
expect(outcome).toBe(guardVerdict); // untouched — same object, not just same shape
|
|
64
|
+
expect(outcome.verdict).toBe('silent_no_op');
|
|
65
|
+
expect(outcome.downgradeTo).toBe('needs_review');
|
|
66
|
+
expect(outcome.reason).toMatch(/finish protocol incomplete/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('resolveCommitGuardOutcome never touches the uncommitted_changes shape, satisfied or not', () => {
|
|
70
|
+
const guardVerdict = commitGuardVerdict({
|
|
71
|
+
newlyDirty: ['src/main/someFeature.cjs'],
|
|
72
|
+
siblingRunning: false,
|
|
73
|
+
jobSelfCommitted: false,
|
|
74
|
+
legitimateNoOp: false,
|
|
75
|
+
verifyResult: null,
|
|
76
|
+
});
|
|
77
|
+
expect(guardVerdict.verdict).toBe('uncommitted_changes');
|
|
78
|
+
|
|
79
|
+
const outcome = resolveCommitGuardOutcome(guardVerdict, ['5de4134abc123']);
|
|
80
|
+
expect(outcome).toBe(guardVerdict);
|
|
81
|
+
expect(outcome.verdict).toBe('uncommitted_changes');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('resolveCommitGuardOutcome: null guardVerdict (no violation) passes through as null', () => {
|
|
85
|
+
expect(resolveCommitGuardOutcome(null, ['5de4134'])).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// AC 3: a job terminalised via the already-satisfied path never becomes a
|
|
89
|
+
// fix-plan target — selectAutoFixTargets only ever considers needs_review
|
|
90
|
+
// rows, and the already-satisfied path never produces one.
|
|
91
|
+
test('a job whose status is completed (the already-satisfied outcome) is never selected for an auto-fix child', () => {
|
|
92
|
+
const jobs = [{
|
|
93
|
+
slug: '1133-reaper-must-verify-integration-before-completed',
|
|
94
|
+
status: 'completed',
|
|
95
|
+
runId: '2026-09-06T13-03-00-000Z',
|
|
96
|
+
verifierVerdict: 'already_satisfied_on_main',
|
|
97
|
+
}];
|
|
98
|
+
const targets = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
99
|
+
expect(targets).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('isAlreadySatisfiedOnMain picks the newest (first) satisfying commit when several land', () => {
|
|
103
|
+
const verdict = isAlreadySatisfiedOnMain(['newest111', 'older222']);
|
|
104
|
+
expect(verdict.sha).toBe('newest111');
|
|
105
|
+
});
|