claude-code-session-manager 0.79.0 → 0.80.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-zS3jw_1e.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-Cy_vxTpi.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-C6JRuqfT.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-BafPy9mB.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-BZwhQOFt.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-C8JDDliz.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CqiOdwLc.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-CyLyP67L.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-BzMv-_84.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSBYQdJR.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CLUhkNNA.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-BfC2-HN4.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-BKi40jT5.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-avk2p9jv.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-Bf_6MdJo.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-Djd8OoBA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-D_qAqxZ_.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DbHFLQV3.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-C2y91BT0.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-D9iz4xQx.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-BGnFL2E5.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-_2ARyFDj.js} +4 -4
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-JK15eJU8.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/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- 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__/scheduleJobTransitions.test.cjs +1 -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-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- 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__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- 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 +97 -12
- 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/scheduleJobTransitions.cjs +12 -2
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +813 -40
- package/src/preload/api.d.ts +8 -0
|
@@ -196,9 +196,25 @@ function makeFakeRemoteWithPrdsDir(prdsDir) {
|
|
|
196
196
|
return { ok: false, error: e?.message };
|
|
197
197
|
}
|
|
198
198
|
},
|
|
199
|
+
// Minimal stand-in for scheduler.cjs's real remote.listPrds: every
|
|
200
|
+
// `<slug>.md` under the fake project's prdsDir, as a `{ slug }` row —
|
|
201
|
+
// enough for prdCreate.cjs's dependsOn-existence validation, which only
|
|
202
|
+
// ever reads `.slug` off each entry.
|
|
203
|
+
async listPrds() {
|
|
204
|
+
const entries = await fsp.readdir(prdsDir);
|
|
205
|
+
const prds = entries
|
|
206
|
+
.filter((f) => f.endsWith('.md'))
|
|
207
|
+
.map((f) => ({ slug: f.slice(0, -3) }));
|
|
208
|
+
return { prds, total: prds.length, limit: prds.length, offset: 0, hasMore: false };
|
|
209
|
+
},
|
|
199
210
|
};
|
|
200
211
|
}
|
|
201
212
|
|
|
213
|
+
/** Pre-seed the fake project's prdsDir with an existing (empty-body) PRD file at `slug`. */
|
|
214
|
+
async function seedExistingPrd(prdsDir, slug) {
|
|
215
|
+
await fsp.writeFile(path.join(prdsDir, `${slug}.md`), '---\ntitle: seed\n---\n# Goal\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
202
218
|
function validCreateBody(overrides = {}) {
|
|
203
219
|
return {
|
|
204
220
|
title: 'Add widget frobnication',
|
|
@@ -797,16 +813,20 @@ test('createPrd refuses a depth-3 fix-chain slug the same way as depth-2', async
|
|
|
797
813
|
expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
|
|
798
814
|
});
|
|
799
815
|
|
|
800
|
-
test('createPrd
|
|
816
|
+
test('createPrd refuses a depth-1 fix slug too (PRD 1131) — that shape is reserved for spawnInvestigation-authored fix plans, not this API', async () => {
|
|
801
817
|
const prdsDir = await mkTmpPrdsDir();
|
|
802
818
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
819
|
+
const writePrdSpy = vi.fn(remote.writePrd);
|
|
820
|
+
remote.writePrd = writePrdSpy;
|
|
803
821
|
|
|
804
822
|
const result = await createPrd(validCreateBody({ slug: 'fix-widget-frobnication' }), remote);
|
|
805
823
|
|
|
806
|
-
expect(result.ok).toBe(
|
|
807
|
-
expect(result.
|
|
808
|
-
|
|
809
|
-
expect(
|
|
824
|
+
expect(result.ok).toBe(false);
|
|
825
|
+
expect(result.status).toBe(400);
|
|
826
|
+
expect(result.error).toMatch(/reserved NN-fix-\.\.\. fix-plan naming convention/i);
|
|
827
|
+
expect(writePrdSpy).not.toHaveBeenCalled();
|
|
828
|
+
const entries = await fsp.readdir(prdsDir);
|
|
829
|
+
expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
|
|
810
830
|
});
|
|
811
831
|
|
|
812
832
|
test('createPrd is unaffected by the fix-chain guard for an ordinary slug with no fix chain at all', async () => {
|
|
@@ -820,14 +840,15 @@ test('createPrd is unaffected by the fix-chain guard for an ordinary slug with n
|
|
|
820
840
|
expect(written).toMatch(/title: Add widget frobnication/);
|
|
821
841
|
});
|
|
822
842
|
|
|
823
|
-
test('createPrd
|
|
843
|
+
test('createPrd refuses a slug starting with "fix-" even when a later segment merely reads "fix" (isFixPlanSlug matches on the leading segment alone, unlike fixChainDepthOf)', async () => {
|
|
824
844
|
const prdsDir = await mkTmpPrdsDir();
|
|
825
845
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
826
846
|
|
|
827
847
|
const result = await createPrd(validCreateBody({ slug: 'fix-loop-extraction-and-round-trip' }), remote);
|
|
828
848
|
|
|
829
|
-
expect(result.ok).toBe(
|
|
830
|
-
expect(result.
|
|
849
|
+
expect(result.ok).toBe(false);
|
|
850
|
+
expect(result.status).toBe(400);
|
|
851
|
+
expect(result.error).toMatch(/reserved NN-fix-\.\.\. fix-plan naming convention/i);
|
|
831
852
|
});
|
|
832
853
|
|
|
833
854
|
test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
|
|
@@ -850,3 +871,107 @@ test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches n
|
|
|
850
871
|
await fsp.rm(root, { recursive: true, force: true });
|
|
851
872
|
}
|
|
852
873
|
});
|
|
874
|
+
|
|
875
|
+
// ──────────────────────────────────────────── PRD 1121: reject double-prefixed
|
|
876
|
+
// slugs, validate dependsOn against existing PRDs at write time
|
|
877
|
+
|
|
878
|
+
test('createPrd refuses a caller-supplied slug already starting with a numeric prefix', async () => {
|
|
879
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
880
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
881
|
+
|
|
882
|
+
const result = await createPrd(validCreateBody({ slug: '254-perf-x' }), remote);
|
|
883
|
+
|
|
884
|
+
expect(result.ok).toBe(false);
|
|
885
|
+
expect(result.status).toBe(400);
|
|
886
|
+
expect(result.error).toMatch(/254-/);
|
|
887
|
+
expect(result.error).toMatch(/bare name/);
|
|
888
|
+
const entries = await fsp.readdir(prdsDir);
|
|
889
|
+
expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('createPrd still accepts a bare (non-prefixed) caller-supplied slug', async () => {
|
|
893
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
894
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
895
|
+
|
|
896
|
+
const result = await createPrd(validCreateBody({ slug: 'perf-x' }), remote);
|
|
897
|
+
|
|
898
|
+
expect(result.ok).toBe(true);
|
|
899
|
+
expect(result.filename).toMatch(/^\d+-perf-x\.md$/);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test('createPrd accepts dependsOn naming an existing bare slug', async () => {
|
|
903
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
904
|
+
await seedExistingPrd(prdsDir, '3-widget-base');
|
|
905
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
906
|
+
|
|
907
|
+
const result = await createPrd(validCreateBody({ slug: 'widget-follow-up', dependsOn: ['widget-base'] }), remote);
|
|
908
|
+
|
|
909
|
+
expect(result.ok).toBe(true);
|
|
910
|
+
const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
|
|
911
|
+
expect(written).toMatch(/^dependsOn: \[widget-base\]$/m);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test('createPrd accepts dependsOn naming an existing full NN-prefixed slug', async () => {
|
|
915
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
916
|
+
await seedExistingPrd(prdsDir, '3-widget-base');
|
|
917
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
918
|
+
|
|
919
|
+
const result = await createPrd(validCreateBody({ slug: 'widget-follow-up-2', dependsOn: ['3-widget-base'] }), remote);
|
|
920
|
+
|
|
921
|
+
expect(result.ok).toBe(true);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test('createPrd refuses dependsOn naming nothing, listing near-matches in the message, and writes no file', async () => {
|
|
925
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
926
|
+
await seedExistingPrd(prdsDir, '3-widget-base');
|
|
927
|
+
await seedExistingPrd(prdsDir, '4-widget-basics');
|
|
928
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
929
|
+
|
|
930
|
+
const result = await createPrd(validCreateBody({ slug: 'widget-follow-up-3', dependsOn: ['wigdet-bsae'] }), remote);
|
|
931
|
+
|
|
932
|
+
expect(result.ok).toBe(false);
|
|
933
|
+
expect(result.status).toBe(400);
|
|
934
|
+
expect(result.error).toMatch(/wigdet-bsae/);
|
|
935
|
+
expect(result.error).toMatch(/widget-base/);
|
|
936
|
+
const entries = await fsp.readdir(prdsDir);
|
|
937
|
+
expect(entries.filter((f) => f.endsWith('.md') && f.includes('widget-follow-up-3')).length).toBe(0);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test('createPrd treats an empty dependsOn array as a no-op (no listPrds call, no rejection)', async () => {
|
|
941
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
942
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
943
|
+
const listPrdsSpy = vi.fn(remote.listPrds);
|
|
944
|
+
remote.listPrds = listPrdsSpy;
|
|
945
|
+
|
|
946
|
+
const result = await createPrd(validCreateBody({ slug: 'no-deps-here', dependsOn: [] }), remote);
|
|
947
|
+
|
|
948
|
+
expect(result.ok).toBe(true);
|
|
949
|
+
expect(listPrdsSpy).not.toHaveBeenCalled();
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test('createPrd treats an absent dependsOn as a no-op (no listPrds call, no rejection)', async () => {
|
|
953
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
954
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
955
|
+
const listPrdsSpy = vi.fn(remote.listPrds);
|
|
956
|
+
remote.listPrds = listPrdsSpy;
|
|
957
|
+
|
|
958
|
+
const result = await createPrd(validCreateBody({ slug: 'no-deps-field' }), remote);
|
|
959
|
+
|
|
960
|
+
expect(result.ok).toBe(true);
|
|
961
|
+
expect(listPrdsSpy).not.toHaveBeenCalled();
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('createPrd warns and skips dependsOn validation (never fails the write) when listPrds throws', async () => {
|
|
965
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
966
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
967
|
+
remote.listPrds = async () => { throw new Error('simulated I/O failure'); };
|
|
968
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
const result = await createPrd(validCreateBody({ slug: 'deps-during-outage', dependsOn: ['anything-at-all'] }), remote);
|
|
972
|
+
expect(result.ok).toBe(true);
|
|
973
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
974
|
+
} finally {
|
|
975
|
+
warnSpy.mockRestore();
|
|
976
|
+
}
|
|
977
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
];
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* coverage in scheduler-autofix-select.test.cjs (node --test) exercises the
|
|
11
11
|
* same helpers in more granular detail.
|
|
12
12
|
*
|
|
13
|
+
* PRD 1129 added one sub-shape to case (d): "outcome 'plan' WITH a queue
|
|
14
|
+
* row is neither retried nor annotated" only holds while that queue row
|
|
15
|
+
* (the fix-plan child) is still alive. Once the child itself reaches a
|
|
16
|
+
* terminal, non-completed status, isFixPlanDead makes the parent eligible
|
|
17
|
+
* for exactly one further attempt (stamped autoFixReopened: true), after
|
|
18
|
+
* which it is permanently excluded and annotated again.
|
|
19
|
+
*
|
|
13
20
|
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-autofix-outcome.test.cjs
|
|
14
21
|
*/
|
|
15
22
|
|
|
@@ -17,7 +24,7 @@
|
|
|
17
24
|
|
|
18
25
|
import { test, expect } from 'vitest';
|
|
19
26
|
const {
|
|
20
|
-
selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor,
|
|
27
|
+
selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, isFixPlanDead, fixSlugFor,
|
|
21
28
|
} = require('../scheduler.cjs');
|
|
22
29
|
|
|
23
30
|
const noSiblingOnDisk = () => false;
|
|
@@ -82,6 +89,71 @@ test("(d) outcome 'plan' with the fix slug present in the queue is neither retri
|
|
|
82
89
|
expect(isAnnotatable(job, queuedSlugs)).toBe(false);
|
|
83
90
|
});
|
|
84
91
|
|
|
92
|
+
// (e) dead fix-plan child: outcome 'plan' whose fix-plan row itself died —
|
|
93
|
+
// the parent is eligible again, exactly once.
|
|
94
|
+
test('(e) a dead fix-plan child makes the parent eligible for one further attempt', () => {
|
|
95
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
96
|
+
const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
|
|
97
|
+
const jobs = [job, deadChild];
|
|
98
|
+
expect(isFixPlanDead(job, jobs)).toBe(true);
|
|
99
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(true);
|
|
100
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('(e) a failed, quarantined, or skipped fix-plan child also counts as dead', () => {
|
|
104
|
+
for (const status of ['failed', 'quarantined', 'skipped']) {
|
|
105
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
106
|
+
const deadChild = { slug: fixSlugFor(job), status, parallelGroup: job.parallelGroup };
|
|
107
|
+
expect(isFixPlanDead(job, [job, deadChild])).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('(e) an already-reopened parent is annotated, never eligible again — even with another dead child', () => {
|
|
112
|
+
const job = makeJob({
|
|
113
|
+
autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 1, autoFixReopened: true,
|
|
114
|
+
});
|
|
115
|
+
const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
|
|
116
|
+
const jobs = [job, deadChild];
|
|
117
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
118
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
119
|
+
expect(isExhaustedAutoFix(job)).toBe(true);
|
|
120
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('(e) a reopened parent whose second attempt is still in flight (outcome cleared) is untouched — not re-eligible, not yet annotated', () => {
|
|
124
|
+
const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1, autoFixReopened: true });
|
|
125
|
+
expect(isFixPlanDead(job, [job])).toBe(false);
|
|
126
|
+
expect(selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
127
|
+
expect(isExhaustedAutoFix(job)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('(e) a fix-plan child that is still pending leaves the parent untouched', () => {
|
|
131
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
132
|
+
const pendingChild = { slug: fixSlugFor(job), status: 'pending', parallelGroup: job.parallelGroup };
|
|
133
|
+
const jobs = [job, pendingChild];
|
|
134
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
135
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
136
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('(e) a fix-plan child that is still running leaves the parent untouched', () => {
|
|
140
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
141
|
+
const runningChild = { slug: fixSlugFor(job), status: 'running', parallelGroup: job.parallelGroup };
|
|
142
|
+
const jobs = [job, runningChild];
|
|
143
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
144
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
145
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('(e) a fix-plan child that completed leaves the parent untouched (unchanged from today)', () => {
|
|
149
|
+
const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
|
|
150
|
+
const completedChild = { slug: fixSlugFor(job), status: 'completed', parallelGroup: job.parallelGroup };
|
|
151
|
+
const jobs = [job, completedChild];
|
|
152
|
+
expect(isFixPlanDead(job, jobs)).toBe(false);
|
|
153
|
+
expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
|
|
154
|
+
expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
85
157
|
// (invariant) every needs_review job is either an auto-fix target or
|
|
86
158
|
// annotatable (would receive a non-null verifierVerdict) — never both false.
|
|
87
159
|
test('invariant: fresh, retry-eligible, exhausted, and plan-unqueued jobs are always selected or annotatable', () => {
|
|
@@ -308,6 +308,23 @@ test('isFixPlanBeyondDepthCap: fix-plan at depth 1 is not capped, depth 2 is', (
|
|
|
308
308
|
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1), true);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
// PRD 1131: classification is now provenance-aware (isFixPlan), not slug-only.
|
|
312
|
+
// A legacy row with no `isFixPlan` argument at all still falls back to the
|
|
313
|
+
// slug-only heuristic above (no re-classification of live history).
|
|
314
|
+
|
|
315
|
+
test('isFixPlanBeyondDepthCap: a scheduler-authored fix plan (isFixPlan:true) is still classified as one and capped at the same depth', () => {
|
|
316
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH, true), false);
|
|
317
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1, true), true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('isFixPlanBeyondDepthCap: a human-authored PRD (isFixPlan:false) whose slug merely matches "fix-" is never capped, even with no investigationDepth (PRD 1126 regression)', () => {
|
|
321
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('126-fix-plan-death-reopens-parent', undefined, false), false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('isFixPlanBeyondDepthCap: a legacy row (isFixPlan argument omitted/undefined) with a fix- slug and no depth field is still treated as a fix plan via the slug fallback', () => {
|
|
325
|
+
assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', undefined, undefined), true);
|
|
326
|
+
});
|
|
327
|
+
|
|
311
328
|
test('healTargetForFix: depth-2 fix-of-a-fix slug resolves back to the depth-1 fix job (521-fix-recorder-export-footer-redesign)', () => {
|
|
312
329
|
// Depth-1 fix job: originally authored to fix job 521, itself in needs_review.
|
|
313
330
|
const depth1FixJob = {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { test, expect } from 'vitest';
|
|
26
26
|
const { commitGuardVerdict } = require('../scheduler.cjs');
|
|
27
|
+
const { resolveIsFixPlan } = require('../lib/fixPlanSlug.cjs');
|
|
27
28
|
|
|
28
29
|
test('commitGuardVerdict: legitimate no-op verdict (pass_no_commit_already_shipped) + concurrent dirt → no flag (655 incident)', () => {
|
|
29
30
|
const verifyResult = {
|
|
@@ -102,6 +103,25 @@ test('commitGuardVerdict: fix-plan job (523-fix-bounded-fix-plan-retry incident)
|
|
|
102
103
|
expect(verdict).toBeNull();
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
test('commitGuardVerdict: a zero-edit run of a human-authored PRD whose slug merely starts with "fix-" still yields silent_no_op (PRD 1131 — not exempted by name alone)', () => {
|
|
107
|
+
// Mirrors PRD 1126: authored via scheduler_create_prd (isFixPlan stamped
|
|
108
|
+
// false at reconcile time), slug happens to read "fix-...". Feeding the
|
|
109
|
+
// job's own persisted isFixPlan through resolveIsFixPlan (the same call
|
|
110
|
+
// spawnJob makes) must NOT exempt this zero-edit run from the guard.
|
|
111
|
+
const job = { slug: '126-fix-plan-death-reopens-parent', isFixPlan: false };
|
|
112
|
+
const verdict = commitGuardVerdict({
|
|
113
|
+
newlyDirty: [],
|
|
114
|
+
siblingRunning: false,
|
|
115
|
+
jobSelfCommitted: false,
|
|
116
|
+
legitimateNoOp: false,
|
|
117
|
+
isFixPlanJob: resolveIsFixPlan(job.slug, job.isFixPlan),
|
|
118
|
+
verifyResult: null,
|
|
119
|
+
});
|
|
120
|
+
expect(verdict).not.toBeNull();
|
|
121
|
+
expect(verdict.verdict).toBe('silent_no_op');
|
|
122
|
+
expect(verdict.downgradeTo).toBe('needs_review');
|
|
123
|
+
});
|
|
124
|
+
|
|
105
125
|
test('commitGuardVerdict: fix-plan job that DID leave real dirt behind is still flagged (isFixPlanJob only exempts the zero-edit case)', () => {
|
|
106
126
|
const verdict = commitGuardVerdict({
|
|
107
127
|
newlyDirty: ['src/main/someFeature.cjs'],
|