claude-code-session-manager 0.39.0 → 0.39.2
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/{TiptapBody-DrqOSx6h.js → TiptapBody-MCzz_6Zm.js} +1 -1
- package/dist/assets/{index-C_w1NLLg.js → index-BPbLVT6E.js} +490 -488
- package/dist/assets/{index-B6JNrbpD.css → index-DVlD8N1X.css} +2 -2
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/scripts/lib/activeSessions.cjs +26 -3
- package/scripts/lib/watchdogHelpers.cjs +13 -6
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +84 -0
- package/src/main/__tests__/prdCreate.test.cjs +6 -0
- package/src/main/__tests__/prdLocations.test.cjs +41 -0
- package/src/main/__tests__/prdMigration.test.cjs +41 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +106 -0
- package/src/main/__tests__/queueHistory.test.cjs +2 -2
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +108 -0
- package/src/main/__tests__/runVerify.test.cjs +459 -4
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +52 -3
- package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +102 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +126 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +58 -3
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +42 -0
- package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +63 -0
- package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +23 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +68 -0
- package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +77 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +62 -0
- package/src/main/browserView.cjs +5 -4
- package/src/main/chatRunner.cjs +90 -7
- package/src/main/config.cjs +9 -0
- package/src/main/ipcSchemas.cjs +1 -1
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +118 -0
- package/src/main/lib/prdLocations.cjs +43 -6
- package/src/main/lib/prdMigration.cjs +5 -3
- package/src/main/lib/queueHistory.cjs +1 -1
- package/src/main/lib/rcaFeedbackHook.cjs +55 -5
- package/src/main/lib/terminalRunOutcome.cjs +100 -0
- package/src/main/promptSessionEvents.cjs +87 -0
- package/src/main/runVerify.cjs +195 -3
- package/src/main/scheduler.cjs +436 -80
- package/src/preload/api.d.ts +5 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-commit-guard-noop.test.cjs — commitGuardVerdict must not flag a
|
|
3
|
+
* legitimate no-op job (a truthful SCHEDULER_VERDICT: PASS, no commit, whose
|
|
4
|
+
* own verifier verdict already independently proved the no-op is correct)
|
|
5
|
+
* for dirt a CONCURRENT actor (e.g. an interactive /process-feedback session
|
|
6
|
+
* writing new PRD .md files into the same repo) left in the working tree.
|
|
7
|
+
*
|
|
8
|
+
* Reproduces the confirmed 2026-07-31 incidents: job
|
|
9
|
+
* 655-needs-review-rca-feedback-hook (verdict pass_no_commit_already_shipped)
|
|
10
|
+
* was flagged uncommitted_changes for a file an interactive session wrote,
|
|
11
|
+
* and job 672-fix-feedback-session-manager similarly. Before this fix, the
|
|
12
|
+
* commit-guard had no exemption for a job whose verifier verdict was already
|
|
13
|
+
* a proven no-op (COMPLETED_EQUIVALENT_VERDICTS) — only for jobSelfCommitted
|
|
14
|
+
* or siblingRunning.
|
|
15
|
+
*
|
|
16
|
+
* Also guards the inverse: a REAL leftover-work case (no legitimate no-op
|
|
17
|
+
* verdict, no sibling, no self-commit) must still be flagged — this fix must
|
|
18
|
+
* not weaken the guard for genuine finish-protocol violations.
|
|
19
|
+
*
|
|
20
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-commit-guard-noop.test.cjs
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
import { test, expect } from 'vitest';
|
|
26
|
+
const { commitGuardVerdict } = require('../scheduler.cjs');
|
|
27
|
+
|
|
28
|
+
test('commitGuardVerdict: legitimate no-op verdict (pass_no_commit_already_shipped) + concurrent dirt → no flag (655 incident)', () => {
|
|
29
|
+
const verifyResult = {
|
|
30
|
+
verdict: 'pass_no_commit_already_shipped',
|
|
31
|
+
reason: 'SCHEDULER_VERDICT: PASS with no commit, but every PRD-named deliverable path is already tracked',
|
|
32
|
+
downgradeTo: null,
|
|
33
|
+
};
|
|
34
|
+
const verdict = commitGuardVerdict({
|
|
35
|
+
newlyDirty: ['session-manager-operations/scheduler/prds/816-vitest-register-runverify-test.md'],
|
|
36
|
+
siblingRunning: false,
|
|
37
|
+
jobSelfCommitted: false,
|
|
38
|
+
legitimateNoOp: true,
|
|
39
|
+
verifyResult,
|
|
40
|
+
});
|
|
41
|
+
expect(verdict).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('commitGuardVerdict: legitimate no-op verdict (pass_no_commit_prior_run_verified) + concurrent dirt across multiple files → no flag (672 incident)', () => {
|
|
45
|
+
const verifyResult = {
|
|
46
|
+
verdict: 'pass_no_commit_prior_run_verified',
|
|
47
|
+
reason: 'SCHEDULER_VERDICT: PASS with no commit, but a prior run of this slug landed a commit already',
|
|
48
|
+
downgradeTo: null,
|
|
49
|
+
};
|
|
50
|
+
const verdict = commitGuardVerdict({
|
|
51
|
+
newlyDirty: [
|
|
52
|
+
'session-manager-operations/feedback/processed/a.md',
|
|
53
|
+
'session-manager-operations/feedback/processed/b.md',
|
|
54
|
+
'session-manager-operations/scheduler/prds/816-vitest-register-runverify-test.md',
|
|
55
|
+
],
|
|
56
|
+
siblingRunning: false,
|
|
57
|
+
jobSelfCommitted: false,
|
|
58
|
+
legitimateNoOp: true,
|
|
59
|
+
verifyResult,
|
|
60
|
+
});
|
|
61
|
+
expect(verdict).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('commitGuardVerdict: REAL leftover work (no legitimate no-op, no sibling, no self-commit) still flags uncommitted_changes', () => {
|
|
65
|
+
const verdict = commitGuardVerdict({
|
|
66
|
+
newlyDirty: ['src/main/someFeature.cjs'],
|
|
67
|
+
siblingRunning: false,
|
|
68
|
+
jobSelfCommitted: false,
|
|
69
|
+
legitimateNoOp: false,
|
|
70
|
+
verifyResult: null,
|
|
71
|
+
});
|
|
72
|
+
expect(verdict).not.toBeNull();
|
|
73
|
+
expect(verdict.verdict).toBe('uncommitted_changes');
|
|
74
|
+
expect(verdict.downgradeTo).toBe('needs_review');
|
|
75
|
+
expect(verdict.reason).toMatch(/finish protocol incomplete/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('commitGuardVerdict: no newly-dirty files → no flag regardless of other flags', () => {
|
|
79
|
+
const verdict = commitGuardVerdict({
|
|
80
|
+
newlyDirty: [],
|
|
81
|
+
siblingRunning: false,
|
|
82
|
+
jobSelfCommitted: false,
|
|
83
|
+
legitimateNoOp: false,
|
|
84
|
+
verifyResult: null,
|
|
85
|
+
});
|
|
86
|
+
expect(verdict).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('commitGuardVerdict: sibling running skip still works (regression check)', () => {
|
|
90
|
+
const verdict = commitGuardVerdict({
|
|
91
|
+
newlyDirty: ['some/file.md'],
|
|
92
|
+
siblingRunning: true,
|
|
93
|
+
jobSelfCommitted: false,
|
|
94
|
+
legitimateNoOp: false,
|
|
95
|
+
verifyResult: null,
|
|
96
|
+
});
|
|
97
|
+
expect(verdict).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('commitGuardVerdict: self-commit skip still works (regression check)', () => {
|
|
101
|
+
const verdict = commitGuardVerdict({
|
|
102
|
+
newlyDirty: ['some/file.md'],
|
|
103
|
+
siblingRunning: false,
|
|
104
|
+
jobSelfCommitted: true,
|
|
105
|
+
legitimateNoOp: false,
|
|
106
|
+
verifyResult: null,
|
|
107
|
+
});
|
|
108
|
+
expect(verdict).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('commitGuardVerdict: carries forward a non-clean prior verdict as an annotation', () => {
|
|
112
|
+
const verifyResult = {
|
|
113
|
+
verdict: 'transcript_errors',
|
|
114
|
+
reason: 'grep hit an Error string',
|
|
115
|
+
downgradeTo: 'needs_review',
|
|
116
|
+
};
|
|
117
|
+
const verdict = commitGuardVerdict({
|
|
118
|
+
newlyDirty: ['some/file.md'],
|
|
119
|
+
siblingRunning: false,
|
|
120
|
+
jobSelfCommitted: false,
|
|
121
|
+
legitimateNoOp: false,
|
|
122
|
+
verifyResult,
|
|
123
|
+
});
|
|
124
|
+
expect(verdict).not.toBeNull();
|
|
125
|
+
expect(verdict.annotations).toEqual([{ verdict: 'transcript_errors', reason: 'grep hit an Error string' }]);
|
|
126
|
+
});
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* scheduler-committed-in-window.test.cjs — unit tests for committedInWindow
|
|
2
|
+
* scheduler-committed-in-window.test.cjs — unit tests for committedInWindow
|
|
3
|
+
* and computeCommittedDuringRun's bounded retry.
|
|
3
4
|
*
|
|
4
5
|
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-committed-in-window.test.cjs
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
'use strict';
|
|
8
9
|
|
|
9
|
-
import { test, expect } from 'vitest';
|
|
10
|
+
import { test, expect, vi } from 'vitest';
|
|
10
11
|
const fs = require('node:fs');
|
|
11
12
|
const os = require('node:os');
|
|
12
13
|
const path = require('node:path');
|
|
13
14
|
const { execFileSync } = require('node:child_process');
|
|
14
|
-
const
|
|
15
|
+
const scheduler = require('../scheduler.cjs');
|
|
16
|
+
const { committedInWindow, computeCommittedDuringRun } = scheduler;
|
|
15
17
|
|
|
16
18
|
function git(cwd, args) {
|
|
17
19
|
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
@@ -125,3 +127,56 @@ test('committedInWindow sees a commit pushed from an isolated checkout, only rea
|
|
|
125
127
|
fs.rmSync(worktreeDir, { recursive: true, force: true });
|
|
126
128
|
}
|
|
127
129
|
});
|
|
130
|
+
|
|
131
|
+
// Reproduces the pass-no-commit-worktree / RCA 770-pr269 incidents: the live
|
|
132
|
+
// commit-guard's committedInWindow() call can race against ref/object
|
|
133
|
+
// visibility at the exact moment of process exit and return false even
|
|
134
|
+
// though the commit is real. computeCommittedDuringRun() should retry once
|
|
135
|
+
// after a bounded delay before giving up.
|
|
136
|
+
test('computeCommittedDuringRun retries committedInWindow once and returns true when the retry succeeds', async () => {
|
|
137
|
+
vi.useFakeTimers();
|
|
138
|
+
try {
|
|
139
|
+
const spy = vi.spyOn(scheduler, 'committedInWindow')
|
|
140
|
+
.mockResolvedValueOnce(false)
|
|
141
|
+
.mockResolvedValueOnce(true);
|
|
142
|
+
|
|
143
|
+
const resultPromise = computeCommittedDuringRun('/tmp/fake', 'abc', 'abc', 'start', 'until');
|
|
144
|
+
await vi.runAllTimersAsync();
|
|
145
|
+
const result = await resultPromise;
|
|
146
|
+
|
|
147
|
+
expect(result).toBe(true);
|
|
148
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
149
|
+
} finally {
|
|
150
|
+
vi.useRealTimers();
|
|
151
|
+
vi.restoreAllMocks();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('computeCommittedDuringRun returns false when both committedInWindow calls return false', async () => {
|
|
156
|
+
vi.useFakeTimers();
|
|
157
|
+
try {
|
|
158
|
+
const spy = vi.spyOn(scheduler, 'committedInWindow').mockResolvedValue(false);
|
|
159
|
+
|
|
160
|
+
const resultPromise = computeCommittedDuringRun('/tmp/fake', 'abc', 'abc', 'start', 'until');
|
|
161
|
+
await vi.runAllTimersAsync();
|
|
162
|
+
const result = await resultPromise;
|
|
163
|
+
|
|
164
|
+
expect(result).toBe(false);
|
|
165
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
166
|
+
} finally {
|
|
167
|
+
vi.useRealTimers();
|
|
168
|
+
vi.restoreAllMocks();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('computeCommittedDuringRun skips the retry (and the delay) when the HEAD-diff fast path already resolves true', async () => {
|
|
173
|
+
const spy = vi.spyOn(scheduler, 'committedInWindow');
|
|
174
|
+
try {
|
|
175
|
+
const result = await computeCommittedDuringRun('/tmp/fake', 'headBefore', 'headAfter', 'start', 'until');
|
|
176
|
+
|
|
177
|
+
expect(result).toBe(true);
|
|
178
|
+
expect(spy).not.toHaveBeenCalled();
|
|
179
|
+
} finally {
|
|
180
|
+
vi.restoreAllMocks();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-find-prd-dir.test.cjs — unit test for findPrdDir's legacy-dir
|
|
3
|
+
* fallback (PRD 812 split-brain fix): a PRD slug that exists only in the
|
|
4
|
+
* legacy global `~/.claude/session-manager/scheduled-plans/prds/` dir (not
|
|
5
|
+
* yet migrated into any project's own
|
|
6
|
+
* `<cwd>/session-manager-operations/scheduler/prds/`) must still resolve,
|
|
7
|
+
* since findPrdDir's candidatePrdsDirs() searches the legacy dir first.
|
|
8
|
+
*
|
|
9
|
+
* Touches the real legacy PRDS_DIR (scheduler.cjs hard-codes it from
|
|
10
|
+
* os.homedir(), same as other scheduler unit tests that exercise
|
|
11
|
+
* global-path-backed exports) with a unique slug, and cleans up after itself.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-find-prd-dir.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect } from 'vitest';
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { findPrdDir, PRDS_DIR } = require('../scheduler.cjs');
|
|
23
|
+
|
|
24
|
+
test('findPrdDir resolves a slug that exists only in the legacy global PRDs dir', async () => {
|
|
25
|
+
const slug = `812-test-legacy-only-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
26
|
+
const legacyPath = path.join(PRDS_DIR, `${slug}.md`);
|
|
27
|
+
fs.mkdirSync(PRDS_DIR, { recursive: true });
|
|
28
|
+
fs.writeFileSync(legacyPath, '---\ntitle: legacy fixture\n---\n\n# Goal\n\ntest\n', 'utf8');
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const dir = await findPrdDir(slug);
|
|
32
|
+
expect(dir).toBe(PRDS_DIR);
|
|
33
|
+
} finally {
|
|
34
|
+
fs.rmSync(legacyPath, { force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('findPrdDir returns null for a slug that exists nowhere', async () => {
|
|
39
|
+
const slug = `812-test-nonexistent-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
40
|
+
const dir = await findPrdDir(slug);
|
|
41
|
+
expect(dir).toBe(null);
|
|
42
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-investigation-clean-skip.test.cjs — a run that verified clean
|
|
3
|
+
* (exit 0 + a completed-equivalent verdict) has nothing left to diagnose;
|
|
4
|
+
* spawnInvestigation must decline rather than manufacturing a depth+1
|
|
5
|
+
* fix-of-a-fix PRD for already-shipped work (PRD
|
|
6
|
+
* 812-689-fix-fix-distribute-adminserver-routes).
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-investigation-clean-skip.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect } from 'vitest';
|
|
14
|
+
const { shouldSkipInvestigationForCleanRun } = require('../scheduler.cjs');
|
|
15
|
+
|
|
16
|
+
test('declines for a clean, exit-0 run', () => {
|
|
17
|
+
expect(shouldSkipInvestigationForCleanRun({
|
|
18
|
+
meta: { exitCode: 0 },
|
|
19
|
+
verdicts: { verdict: 'clean' },
|
|
20
|
+
})).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('declines for pass_no_commit_already_shipped, exit-0', () => {
|
|
24
|
+
expect(shouldSkipInvestigationForCleanRun({
|
|
25
|
+
meta: { exitCode: 0 },
|
|
26
|
+
verdicts: { verdict: 'pass_no_commit_already_shipped' },
|
|
27
|
+
})).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('declines for pass_no_commit_target_verified, exit-0', () => {
|
|
31
|
+
expect(shouldSkipInvestigationForCleanRun({
|
|
32
|
+
meta: { exitCode: 0 },
|
|
33
|
+
verdicts: { verdict: 'pass_no_commit_target_verified' },
|
|
34
|
+
})).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('proceeds (does not skip) for exitCode 1', () => {
|
|
38
|
+
expect(shouldSkipInvestigationForCleanRun({
|
|
39
|
+
meta: { exitCode: 1 },
|
|
40
|
+
verdicts: { verdict: 'clean' },
|
|
41
|
+
})).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('proceeds for exit 0 with a non-completed-equivalent verdict', () => {
|
|
45
|
+
expect(shouldSkipInvestigationForCleanRun({
|
|
46
|
+
meta: { exitCode: 0 },
|
|
47
|
+
verdicts: { verdict: 'transcript_errors' },
|
|
48
|
+
})).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('proceeds (fail-open) when verdicts.json is missing', () => {
|
|
52
|
+
expect(shouldSkipInvestigationForCleanRun({
|
|
53
|
+
meta: { exitCode: 0 },
|
|
54
|
+
verdicts: null,
|
|
55
|
+
})).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('proceeds (fail-open) when meta.json is missing', () => {
|
|
59
|
+
expect(shouldSkipInvestigationForCleanRun({
|
|
60
|
+
meta: null,
|
|
61
|
+
verdicts: { verdict: 'clean' },
|
|
62
|
+
})).toBe(false);
|
|
63
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-meta-code-sha.test.cjs — unit tests for the SCHEDULER_BOOTED_AT /
|
|
3
|
+
* SCHEDULER_CODE_SHA constants stamped into every run's meta sidecar (PRD
|
|
4
|
+
* 812-fix-commit-guard-retry-on-worktree-ref-visibility-delay): a stale
|
|
5
|
+
* scheduler process is otherwise invisible in the run record, which cost a
|
|
6
|
+
* full investigation to diagnose a false pass_no_commit.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-meta-code-sha.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect } from 'vitest';
|
|
14
|
+
const { SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA } = require('../scheduler.cjs');
|
|
15
|
+
|
|
16
|
+
test('SCHEDULER_BOOTED_AT is a valid ISO date string', () => {
|
|
17
|
+
expect(typeof SCHEDULER_BOOTED_AT).toBe('string');
|
|
18
|
+
expect(Number.isNaN(new Date(SCHEDULER_BOOTED_AT).getTime())).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('SCHEDULER_CODE_SHA is null or a short git hex SHA', () => {
|
|
22
|
+
expect(SCHEDULER_CODE_SHA === null || /^[0-9a-f]{7,40}$/.test(SCHEDULER_CODE_SHA)).toBe(true);
|
|
23
|
+
});
|
|
@@ -97,3 +97,71 @@ test('no-ops when parsing the PRD frontmatter throws', async () => {
|
|
|
97
97
|
|
|
98
98
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
// PRD 814: a dev-work ticket's completion notification routes into its own
|
|
102
|
+
// PromptSession's event chain instead of an unrelated tab, when the PRD's
|
|
103
|
+
// sourcePromptId resolves to a known, still-active PromptSession.
|
|
104
|
+
test('routes into the known PromptSession event chain and never falls back to a tab prompt', async () => {
|
|
105
|
+
const sendPrompt = vi.fn();
|
|
106
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
107
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-1', sourceTabId: 'tab-x' }));
|
|
108
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
109
|
+
|
|
110
|
+
await notifyOriginatingTab(
|
|
111
|
+
{ slug: '814-notify', status: 'completed', cwd: '/some/cwd' },
|
|
112
|
+
{ parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(appendResponseEvent).toHaveBeenCalledWith(
|
|
116
|
+
'/some/cwd',
|
|
117
|
+
'psess-1',
|
|
118
|
+
expect.stringContaining('814-notify'),
|
|
119
|
+
);
|
|
120
|
+
expect(sendPrompt).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('falls back to the tab-external-ticket path when sourcePromptId does not resolve to a known PromptSession', async () => {
|
|
124
|
+
const sendPrompt = vi.fn();
|
|
125
|
+
const appendResponseEvent = vi.fn(async () => false);
|
|
126
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-unknown', sourceTabId: 'tab-x' }));
|
|
127
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
128
|
+
|
|
129
|
+
await notifyOriginatingTab(
|
|
130
|
+
{ slug: '814-notify', status: 'completed', cwd: '/some/cwd' },
|
|
131
|
+
{ parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(appendResponseEvent).toHaveBeenCalled();
|
|
135
|
+
expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('falls back to the tab-external-ticket path when the PRD has no sourcePromptId at all (pre-813 PRD)', async () => {
|
|
139
|
+
const sendPrompt = vi.fn();
|
|
140
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
141
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: null, sourceTabId: 'tab-x' }));
|
|
142
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
143
|
+
|
|
144
|
+
await notifyOriginatingTab(
|
|
145
|
+
{ slug: '814-notify', status: 'completed', cwd: '/some/cwd' },
|
|
146
|
+
{ parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(appendResponseEvent).not.toHaveBeenCalled();
|
|
150
|
+
expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('falls back to the tab-external-ticket path when appendResponseEvent itself throws', async () => {
|
|
154
|
+
const sendPrompt = vi.fn();
|
|
155
|
+
const appendResponseEvent = vi.fn(async () => { throw new Error('disk error'); });
|
|
156
|
+
const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-1', sourceTabId: 'tab-x' }));
|
|
157
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
158
|
+
|
|
159
|
+
await expect(
|
|
160
|
+
notifyOriginatingTab(
|
|
161
|
+
{ slug: '814-notify', status: 'completed', cwd: '/some/cwd' },
|
|
162
|
+
{ parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent },
|
|
163
|
+
),
|
|
164
|
+
).resolves.not.toThrow();
|
|
165
|
+
|
|
166
|
+
expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
|
|
167
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reset-job-fields-guard.test.cjs — unit tests for resetJobFields'
|
|
3
|
+
* terminal-status guard.
|
|
4
|
+
*
|
|
5
|
+
* A 'completed' job already landed its deliverable — resetting it back to
|
|
6
|
+
* 'pending' re-fires the PRD and re-executes already-shipped work (the
|
|
7
|
+
* incident: PRD 812-workbench-review-nits-cleanup was reset and re-run this
|
|
8
|
+
* way, burning a full claude -p job + Opus investigation over a correct
|
|
9
|
+
* no-op). resetJobFields must refuse (no-op) on a 'completed' job unless
|
|
10
|
+
* opts.force is true, and must still reset normally for every non-terminal
|
|
11
|
+
* status (pending/running/failed/needs_review).
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect } from 'vitest';
|
|
19
|
+
const { resetJobFields } = require('../scheduler.cjs');
|
|
20
|
+
|
|
21
|
+
function makeJob(status, overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
slug: 'test-slug',
|
|
24
|
+
status,
|
|
25
|
+
runId: 'run-123',
|
|
26
|
+
startedAt: '2026-07-31T00:00:00.000Z',
|
|
27
|
+
finishedAt: '2026-07-31T00:05:00.000Z',
|
|
28
|
+
exitCode: 0,
|
|
29
|
+
error: null,
|
|
30
|
+
runtime: { pid: 999 },
|
|
31
|
+
verifierVerdict: 'pass',
|
|
32
|
+
landedCommit: 'abc1234',
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('resetJobFields: refuses and does not mutate a completed job', () => {
|
|
38
|
+
const job = makeJob('completed');
|
|
39
|
+
const snapshot = { ...job };
|
|
40
|
+
const result = resetJobFields(job, 'some error');
|
|
41
|
+
expect(result).toBe(false);
|
|
42
|
+
expect(job).toEqual(snapshot);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('resetJobFields: force:true overrides the guard on a completed job', () => {
|
|
46
|
+
const job = makeJob('completed');
|
|
47
|
+
const result = resetJobFields(job, 'forced reset', { force: true });
|
|
48
|
+
expect(result).toBe(true);
|
|
49
|
+
expect(job.status).toBe('pending');
|
|
50
|
+
expect(job.runId).toBeNull();
|
|
51
|
+
expect(job.startedAt).toBeNull();
|
|
52
|
+
expect(job.finishedAt).toBeNull();
|
|
53
|
+
expect(job.exitCode).toBeNull();
|
|
54
|
+
expect(job.error).toBe('forced reset');
|
|
55
|
+
expect(job.runtime).toBeUndefined();
|
|
56
|
+
expect(job.verifierVerdict).toBeUndefined();
|
|
57
|
+
// landedCommit must survive even a forced reset — it feeds the
|
|
58
|
+
// pass_no_commit_prior_run_verified exemption on a later re-run.
|
|
59
|
+
expect(job.landedCommit).toBe('abc1234');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
for (const status of ['pending', 'running', 'failed', 'needs_review']) {
|
|
63
|
+
test(`resetJobFields: resets normally for non-terminal status '${status}'`, () => {
|
|
64
|
+
const job = makeJob(status);
|
|
65
|
+
const result = resetJobFields(job, 'retry reason');
|
|
66
|
+
expect(result).toBe(true);
|
|
67
|
+
expect(job.status).toBe('pending');
|
|
68
|
+
expect(job.runId).toBeNull();
|
|
69
|
+
expect(job.startedAt).toBeNull();
|
|
70
|
+
expect(job.finishedAt).toBeNull();
|
|
71
|
+
expect(job.exitCode).toBeNull();
|
|
72
|
+
expect(job.error).toBe('retry reason');
|
|
73
|
+
expect(job.runtime).toBeUndefined();
|
|
74
|
+
expect(job.verifierVerdict).toBeUndefined();
|
|
75
|
+
expect(job.landedCommit).toBe('abc1234');
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-unreadable-queue-guard.test.cjs — regression cover for the
|
|
3
|
+
* 2026-07-31 mass-resurrection incident.
|
|
4
|
+
*
|
|
5
|
+
* readQueue()/readQueueSync() used to swallow EVERY error and return
|
|
6
|
+
* `jobs: []`. A single unreadable or half-written queue.json therefore looked
|
|
7
|
+
* identical to an empty queue, so reconcile() saw no job row for any PRD .md
|
|
8
|
+
* on disk, re-minted all of them as fresh 'pending', and mutate() persisted
|
|
9
|
+
* that empty-derived state over the real file — 189 completed jobs resurrected
|
|
10
|
+
* and fired into an ENOENT retry storm.
|
|
11
|
+
*
|
|
12
|
+
* The fix marks such a read `unreadable` (a MISSING file stays a legitimately
|
|
13
|
+
* empty queue) and refuses to let that state reach the two functions that
|
|
14
|
+
* would do the damage. Both guards below return/throw before touching the
|
|
15
|
+
* filesystem, so this test can never disturb the real queue.json.
|
|
16
|
+
*
|
|
17
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
import { test, expect } from 'vitest';
|
|
23
|
+
const { writeQueue, reconcile, forceTickOutcome } = require('../scheduler.cjs');
|
|
24
|
+
|
|
25
|
+
const unreadableState = () => ({
|
|
26
|
+
config: {},
|
|
27
|
+
jobs: [],
|
|
28
|
+
scheduledFor: null,
|
|
29
|
+
lastRunAt: null,
|
|
30
|
+
paused: null,
|
|
31
|
+
unreadable: 'Unexpected token } in JSON at position 42',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('writeQueue refuses to persist a state derived from an unreadable read', async () => {
|
|
35
|
+
await expect(writeQueue(unreadableState())).rejects.toThrow(/refusing to write queue\.json/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('writeQueue still persists a normal state (guard is not a blanket block)', () => {
|
|
39
|
+
// Only assert the guard does not fire — the real write is exercised by the
|
|
40
|
+
// scheduler's own integration paths, and this test must not touch the file.
|
|
41
|
+
const state = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
42
|
+
expect(state.unreadable).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('reconcile refuses an unreadable state instead of resurrecting every PRD', async () => {
|
|
46
|
+
await expect(reconcile(unreadableState())).rejects.toThrow(/reconcile skipped/i);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('forceTickOutcome surfaces an unreadable queue as a hard error, not "No pending jobs"', () => {
|
|
50
|
+
const out = forceTickOutcome({ fired: false, reason: 'unreadable' });
|
|
51
|
+
expect(out.ok).toBe(false);
|
|
52
|
+
expect(out.kind).toBe('error');
|
|
53
|
+
expect(out.message).toMatch(/unreadable/i);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an absent queue.json is still a legitimately empty queue, not an error', () => {
|
|
57
|
+
// ENOENT keeps the old fail-open behaviour on purpose: first boot has no
|
|
58
|
+
// file yet and must be allowed to create one.
|
|
59
|
+
const out = forceTickOutcome({ fired: false, reason: 'drained' });
|
|
60
|
+
expect(out.ok).toBe(true);
|
|
61
|
+
expect(out.message).toMatch(/No pending jobs/i);
|
|
62
|
+
});
|
package/src/main/browserView.cjs
CHANGED
|
@@ -223,13 +223,14 @@ function create({ viewId, partition }) {
|
|
|
223
223
|
},
|
|
224
224
|
});
|
|
225
225
|
views.set(viewId, view);
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
const wcId = view.webContents.id;
|
|
227
|
+
browserViewContentsIds.add(wcId);
|
|
228
|
+
contentsIdToViewId.set(wcId, viewId);
|
|
228
229
|
view.webContents.setZoomFactor(lastZoomFactor);
|
|
229
230
|
wireNavEvents(viewId, view);
|
|
230
231
|
view.webContents.once('destroyed', () => {
|
|
231
|
-
browserViewContentsIds.delete(
|
|
232
|
-
contentsIdToViewId.delete(
|
|
232
|
+
browserViewContentsIds.delete(wcId);
|
|
233
|
+
contentsIdToViewId.delete(wcId);
|
|
233
234
|
recordingViewIds.delete(viewId);
|
|
234
235
|
stepCounters.delete(viewId);
|
|
235
236
|
recordTokens.delete(viewId);
|